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?
- Spliterator dzieli kolekcję na części — zwykle według liczby dostępnych rdzeni (lub nieco więcej).
- Każda część jest przetwarzana w swoim wątku — używany jest wspólny ForkJoinPool.
- 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.
GO TO FULL VERSION