CodeGym /Kursy /JAVA 25 SELF /Spliterator i strumienie równoległe

Spliterator i strumienie równoległe

JAVA 25 SELF
Poziom 33, Lekcja 3
Dostępny

1. Wprowadzenie do Spliteratora

Jeśli myśleliście, że kolekcje w Javie iteruje się tylko przez Iterator, to do Java 8 mieliście absolutnie rację. Ale wraz z pojawieniem się Stream API i mody na równoległość pojawił się nowy bohater — Spliterator.

Spliterator to interfejs, który pozwala nie tylko iterować po elementach kolekcji, lecz także dzielić źródło danych na części do przetwarzania równoległego. Nazwa to zlepek słów split i iterator.

Wyobraźcie sobie duży tort. Zwykły Iterator kroi go na kawałki i je po kolei. Spliterator potrafi przeciąć tort na pół, dać połowę koledze — i obaj zaczniecie jeść jednocześnie. Przyjaciół jest wielu — dzielimy dalej!

Interfejs Spliterator — najważniejsze metody

public interface Spliterator<T> {
    boolean tryAdvance(java.util.function.Consumer<? super T> action);
    Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
    // ... jest jeszcze kilka metod, ale te są najważniejsze
}
  • tryAdvance — wykonuje coś na kolejnym elemencie (analog next() + działanie).
  • trySplit — próbuje podzielić źródło na dwie części i zwrócić nowy Spliterator dla „odłączonej” części.
  • estimateSize — szacuje, ile elementów pozostało.
  • characteristics — zwraca bitową maskę charakterystyk (uporządkowanie, unikalność, niezmienność itd.).

2. Użycie Spliteratora: ręczne iterowanie i dzielenie

Pobranie Spliteratora z kolekcji

Każda kolekcja implementująca Collection może udostępnić swój Spliterator:

import java.util.List;
import java.util.Spliterator;

List<String> names = List.of("John", "Bob", "Alice", "Eve");
Spliterator<String> spliterator = names.spliterator();

Ręczne iterowanie elementów

Spliterator<String> spliterator = names.spliterator();
while (spliterator.tryAdvance(name -> System.out.println("Imię: " + name))) {
    // Wszystko dzieje się wewnątrz tryAdvance
}

Dzielenie kolekcji

Najciekawsze — metoda trySplit():

Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();

System.out.println("Pierwsza część:");
spliterator1.forEachRemaining(System.out::println);

System.out.println("Druga część:");
if (spliterator2 != null) {
    spliterator2.forEachRemaining(System.out::println);
}

Co się stanie: Spliterator spróbuje podzielić kolekcję na dwie części (nie zawsze równo po połowie — zależy od implementacji). Teraz możecie przetwarzać obie części niezależnie — nawet w różnych wątkach!

3. Strumienie równoległe: po co i jak to działa

Strumień równoległy (parallelStream()) to strumień, który przetwarza elementy nie po kolei, lecz jednocześnie w wielu wątkach. Szczególnie przydatne przy dużych wolumenach danych i procesorach wielordzeniowych.

import java.util.List;

List<String> names = List.of("John", "Bob", "Alice", "Eve");
// Zwykły strumień:
names.stream().forEach(System.out::println);
// Strumień równoległy:
names.parallelStream().forEach(System.out::println);

O co chodzi?
W zwykłym strumieniu elementy przetwarzane są w jednym wątku. W równoległym — źródło dzieli się na części (za pomocą Spliterator), a każda część jest przetwarzana w osobnym wątku.

Jak to działa pod spodem?

  1. Spliterator dzieli kolekcję na części — zwykle według liczby dostępnych rdzeni (lub nieco więcej).
  2. Każda część jest przetwarzana w swoim wątku — używany jest wspólny ForkJoinPool.
  3. Wyniki są zbierane z powrotem — łączone w wynikową kolekcję lub wartość.

Schemat działania strumienia równoległego

flowchart LR
    A[Kolekcja] --> B{Spliterator}
    B --> C1[Część 1] --> D1[Wątek 1]
    B --> C2[Część 2] --> D2[Wątek 2]
    B --> C3[Część 3] --> D3[Wątek 3]
    D1 & D2 & D3 --> E[Scalanie wyniku]

4. Zalety i ograniczenia strumieni równoległych

Zalety

  • Przyspieszenie przetwarzania dużych kolekcji: przy ciężkich obliczeniach strumień równoległy wyraźnie przyspiesza wykonanie.
  • Prostota: nie trzeba pisać kodu wielowątkowego ręcznie — zamień stream() na parallelStream().

Ograniczenia i pułapki

  • Nie zawsze szybciej: dla małych kolekcji narzut może „zjeść” zysk.
  • Kolejność nie jest gwarantowana: w forEach/map/filter kolejność może się różnić. Jeśli potrzebna jest kolejność — użyj forEachOrdered.
  • Problemy z bezpieczeństwem wątkowym: operacje z efektami ubocznymi (modyfikacja zewnętrznych kolekcji/zmiennych) prowadzą do wyścigów danych.
  • Nie wszystkie operacje się nadają: obliczenia zależne (np. sekwencyjne akumulowanie) mogą działać inaczej, niż oczekujesz.

Kiedy używać strumieni równoległych?

  • Duże kolekcje (dziesiątki tysięcy elementów i więcej).
  • Ciężkie operacje na każdym elemencie.
  • Niekrytyczna ścisła kolejność.
  • Brak efektów ubocznych (czyste funkcje).

Kiedy NIE używać?

  • Mało elementów.
  • Kod zmienia zewnętrzne zmienne lub kolekcje.
  • Ważne jest zachowanie kolejności przetwarzania.
  • Źródło danych słabo się dzieli (np. LinkedList).

5. Praktyczne przykłady

Przykład 1: Porównanie czasu wykonania

import java.util.*;
import java.util.stream.*;

public class ParallelStreamDemo {
    public static void main(String[] args) {
        List<Integer> numbers = IntStream.range(0, 10_000_000)
                                         .boxed()
                                         .collect(Collectors.toList());

        long start = System.currentTimeMillis();
        long count = numbers.stream()
                .filter(n -> isPrime(n))
                .count();
        long time = System.currentTimeMillis() - start;
        System.out.println("Zwykły strumień: " + time + " ms, znalezionych liczb pierwszych: " + count);

        start = System.currentTimeMillis();
        count = numbers.parallelStream()
                .filter(n -> isPrime(n))
                .count();
        time = System.currentTimeMillis() - start;
        System.out.println("Strumień równoległy: " + time + " ms, znalezionych liczb pierwszych: " + count);
    }

    // Najprostsze sprawdzenie, czy liczba jest pierwsza (na potrzeby przykładu)
    public static boolean isPrime(int n) {
        if (n < 2) return false;
        for (int i = 2, sqrt = (int)Math.sqrt(n); i <= sqrt; i++)
            if (n % i == 0) return false;
        return true;
    }
}

Wynik: na dużych wolumenach danych strumień równoległy bywa często szybszy (zwłaszcza na procesorach wielordzeniowych). Na małych — różnicy może nie być albo wariant równoległy okaże się wolniejszy.

Przykład 2: Problem z kolejnością

import java.util.List;

List<String> names = List.of("John", "Bob", "Alice", "Eve");
System.out.println("Zwykły strumień:");
names.stream().forEach(System.out::println);

System.out.println("Strumień równoległy:");
names.parallelStream().forEach(System.out::println);

System.out.println("Strumień równoległy z forEachOrdered:");
names.parallelStream().forEachOrdered(System.out::println);

Wniosek: w zwykłym strumieniu oraz przy użyciu forEachOrdered kolejność jest zachowana, a w równoległym bez tego — nie.

Przykład 3: Niebezpieczeństwo efektów ubocznych

import java.util.*;
import java.util.stream.*;

List<Integer> numbers = IntStream.range(1, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();

// NIEBEZPIECZNE! Nie rób tak!
numbers.parallelStream().forEach(n -> results.add(n * n));

System.out.println("Rozmiar listy: " + results.size());

Co może się stać? Rozmiar listy może być mniejszy od oczekiwanego, a czasem pojawi się ConcurrentModificationException. Powód — ArrayList nie jest bezpieczny wątkowo, a strumień równoległy uruchamia jednocześnie wiele wątków.

6. Spliterator: cechy i charakterystyki

Charakterystyki Spliteratora

Spliterator opisuje swoje właściwości przez bitową maskę:

  • ORDERED — elementy występują w określonej kolejności (np. w liście).
  • DISTINCT — wszystkie elementy są unikalne (np. w zbiorze).
  • SORTED — elementy są posortowane.
  • SIZED — znany jest rozmiar.
  • IMMUTABLE — kolekcja jest niezmienialna.
  • CONCURRENT — kolekcja jest bezpieczna wątkowo.
  • SUBSIZED — wszystkie spliteratory po trySplit() również znają swój rozmiar.
Spliterator<String> spliterator = names.spliterator();
int characteristics = spliterator.characteristics();
System.out.println(Integer.toBinaryString(characteristics));

Po co to wiedzieć? Stream API i strumienie równoległe wykorzystują te cechy do optymalizacji. Na przykład, jeśli źródło jest niezmienialne i posortowane, można je bezpieczniej i wydajniej dzielić oraz scalać wynik.

7. Kiedy i jak używać Spliteratora bezpośrednio?

Na co dzień rzadko zachodzi potrzeba pisania własnych Spliteratorów: standardowe kolekcje już to zapewniają. Ale jeśli tworzycie własne źródło danych lub chcecie precyzyjnie kontrolować iterowanie/dzielenie, Spliterator się przyda.

Przykład: ręczne iterowanie z tryAdvance

import java.util.List;
import java.util.Spliterator;

List<String> names = List.of("John", "Bob", "Alice", "Eve");
Spliterator<String> spliterator = names.spliterator();
spliterator.tryAdvance(name -> System.out.println("Pierwszy element: " + name));
spliterator.forEachRemaining(name -> System.out.println("Pozostałe: " + name));

Przykład: dzielenie kolekcji

Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();

if (spliterator2 != null) {
    spliterator2.forEachRemaining(name -> System.out.println("Część 2: " + name));
}
spliterator1.forEachRemaining(name -> System.out.println("Część 1: " + name));

8. Typowe błędy przy pracy ze Spliteratorem i strumieniami równoległymi

Błąd nr 1: Używanie strumieni równoległych dla małych kolekcji. Zamiast przyspieszenia uzyskacie spowolnienie — narzut na dzielenie i planowanie zadań przeważy korzyści.

Błąd nr 2: Oczekiwanie zachowania kolejności elementów. Strumienie równoległe nie gwarantują kolejności. Jeśli jest ważna — użyj forEachOrdered, ale część efektywności równoległej zostanie utracona.

Błąd nr 3: Efekty uboczne w wyrażeniach lambda. W strumieniu równoległym nie można bezpiecznie zmieniać zewnętrznych zmiennych/kolekcji — pojawią się wyścigi danych i trudne do wychwycenia błędy.

Błąd nr 4: Używanie niebezpiecznych kolekcji wewnątrz strumienia równoległego. Dodawanie do zwykłego ArrayList z wielu wątków to prosta droga do błędów w rodzaju ConcurrentModificationException.

Błąd nr 5: Oczekiwanie natychmiastowego przyspieszenia. Strumienie równoległe to nie magiczna różdżka. Profilujcie: jeśli danych jest mało albo operacja jest lekka — zwykły strumień bywa szybszy.

Błąd nr 6: Strumienie równoległe ze źródłami, które słabo się dzielą. Na przykład LinkedList często dzieli się niewydajnie — równoległość może tylko spowolnić wykonanie.

1
Zadanie
JAVA 25 SELF, poziom 33, lekcja 3
Niedostępne
Przetwarzanie pierwszej otrzymanej paczki w magazynie 📦
Przetwarzanie pierwszej otrzymanej paczki w magazynie 📦
1
Zadanie
JAVA 25 SELF, poziom 33, lekcja 3
Niedostępne
Podział zadań między zespołami na strategicznym spotkaniu 🎯
Podział zadań między zespołami na strategicznym spotkaniu 🎯
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION