1. Einführung in den Spliterator
Wenn Sie dachten, dass Collections in Java nur über den Iterator durchlaufen werden, lagen Sie bis Java 8 vollkommen richtig. Mit dem Aufkommen des Stream API und dem Trend zur Parallelisierung ist jedoch ein neuer Held hinzugekommen – Spliterator.
Spliterator ist ein Interface, das nicht nur das Durchlaufen von Collection-Elementen erlaubt, sondern die Datenquelle auch aufteilen kann – in Teile für die parallele Verarbeitung. Der Name ist eine Verschmelzung der Wörter split und iterator.
Stellen Sie sich eine große Torte vor. Ein gewöhnlicher Iterator schneidet sie Stück für Stück und isst der Reihe nach. Spliterator kann die Torte halbieren, die eine Hälfte einem Freund geben – und ihr esst beide gleichzeitig. Gibt es viele Freunde, wird weiter aufgeteilt!
Das Interface Spliterator – die wichtigsten Methoden
public interface Spliterator<T> {
boolean tryAdvance(java.util.function.Consumer<? super T> action);
Spliterator<T> trySplit();
long estimateSize();
int characteristics();
// ... noch ein paar weitere Methoden, aber diese sind die wichtigsten
}
- tryAdvance – führt eine Aktion mit dem nächsten Element aus (analog zu next() plus Aktion).
- trySplit – versucht, die Quelle in zwei Teile zu teilen und gibt einen neuen Spliterator für die „abgetrennte“ Teilmenge zurück.
- estimateSize – schätzt, wie viele Elemente noch verbleiben.
- characteristics – gibt eine Bitmaske der Eigenschaften zurück (Reihenfolge, Eindeutigkeit, Unveränderlichkeit usw.).
2. Verwendung von Spliterator: manuelles Durchlaufen und Aufteilen
Einen Spliterator aus einer Collection erhalten
Jede Implementierung von Collection kann ihren Spliterator bereitstellen:
import java.util.List;
import java.util.Spliterator;
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
Spliterator<String> spliterator = names.spliterator();
Elemente manuell durchlaufen
Spliterator<String> spliterator = names.spliterator();
while (spliterator.tryAdvance(name -> System.out.println("Name: " + name))) {
// Alles passiert innerhalb von tryAdvance
}
Aufteilen der Collection
Das Spannendste ist die Methode trySplit():
Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();
System.out.println("Erster Teil:");
spliterator1.forEachRemaining(System.out::println);
System.out.println("Zweiter Teil:");
if (spliterator2 != null) {
spliterator2.forEachRemaining(System.out::println);
}
Was passiert: Spliterator versucht, die Collection in zwei Teile zu teilen (nicht immer exakt halb – das hängt von der Implementierung ab). Jetzt können Sie beide Teile unabhängig voneinander verarbeiten – gern auch in verschiedenen Threads!
3. Parallele Streams: wozu und wie das funktioniert
Ein paralleler Stream (parallelStream()) ist ein Stream, der Elemente nicht der Reihe nach, sondern gleichzeitig in mehreren Threads verarbeitet. Das ist besonders bei großen Datenmengen und auf Mehrkernprozessoren hilfreich.
import java.util.List;
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
// Sequentieller Stream:
names.stream().forEach(System.out::println);
// Paralleler Stream:
names.parallelStream().forEach(System.out::println);
Was ist der Clou?
Im sequentiellen Stream werden die Elemente in einem Thread verarbeitet. Im parallelen Stream wird die Quelle in Teile aufgeteilt (mit Spliterator), und jeder Teil wird in einem eigenen Thread verarbeitet.
Wie funktioniert das intern?
- Spliterator teilt die Collection in Teile – üblicherweise in etwa nach der Anzahl der verfügbaren Kerne (oder etwas mehr).
- Jeder Teil wird in einem eigenen Thread verarbeitet – es wird der gemeinsame ForkJoinPool verwendet.
- Die Ergebnisse werden wieder zusammengeführt – zu einer finalen Collection oder einem Wert.
Schematische Darstellung eines parallelen Streams
flowchart LR
A[Collection] --> B{Spliterator}
B --> C1[Teil 1] --> D1[Thread 1]
B --> C2[Teil 2] --> D2[Thread 2]
B --> C3[Teil 3] --> D3[Thread 3]
D1 & D2 & D3 --> E[Ergebnis zusammenführen]
4. Vorteile und Einschränkungen paralleler Streams
Vorteile
- Beschleunigung der Verarbeitung großer Collections: Bei rechenintensiven Aufgaben kann ein paralleler Stream die Ausführung deutlich beschleunigen.
- Einfachheit: Sie müssen keinen Multithreading-Code von Hand schreiben – ersetzen Sie stream() einfach durch parallelStream().
Einschränkungen und Fallstricke
- Nicht immer schneller: Bei kleinen Collections können die Overheads den Vorteil zunichtemachen.
- Reihenfolge ist nicht garantiert: In forEach/map/filter kann die Reihenfolge abweichen. Wenn eine Reihenfolge nötig ist, verwenden Sie forEachOrdered.
- Probleme mit Thread-Sicherheit: Operationen mit Seiteneffekten (Änderungen an externen Collections/Variablen) führen zu Data Races.
- Nicht alle Operationen sind geeignet: Abhängige Berechnungen (z. B. sequentielle Akkumulation) funktionieren möglicherweise nicht wie erwartet.
Wann sollte man parallele Streams verwenden?
- Große Collections (Zehntausende Elemente und mehr).
- Rechenintensive Operationen pro Element.
- Strikte Reihenfolge ist nicht kritisch.
- Keine Seiteneffekte (pure Funktionen).
Wann NICHT verwenden?
- Wenige Elemente.
- Der Code verändert externe Variablen oder Collections.
- Wichtige Beibehaltung der Verarbeitungsreihenfolge.
- Die Datenquelle lässt sich schlecht aufteilen (z. B. LinkedList).
5. Praxisbeispiele
Beispiel 1: Laufzeitvergleich
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("Sequentieller Stream: " + time + " ms, Primzahlen gefunden: " + count);
start = System.currentTimeMillis();
count = numbers.parallelStream()
.filter(n -> isPrime(n))
.count();
time = System.currentTimeMillis() - start;
System.out.println("Paralleler Stream: " + time + " ms, Primzahlen gefunden: " + count);
}
// Einfachster Primzahltest (nur als Beispiel)
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;
}
}
Ergebnis: Bei großen Datenmengen ist der parallele Stream häufig schneller (insbesondere auf Mehrkernprozessoren). Bei kleinen Datenmengen kann es keinen Unterschied geben oder die parallele Variante ist sogar langsamer.
Beispiel 2: Problem mit der Reihenfolge
import java.util.List;
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
System.out.println("Sequentieller Stream:");
names.stream().forEach(System.out::println);
System.out.println("Paralleler Stream:");
names.parallelStream().forEach(System.out::println);
System.out.println("Paralleler Stream mit forEachOrdered:");
names.parallelStream().forEachOrdered(System.out::println);
Fazit: Im sequentiellen Stream und bei Verwendung von forEachOrdered bleibt die Reihenfolge erhalten, im parallelen Stream ohne forEachOrdered jedoch nicht.
Beispiel 3: Gefahr von Nebenwirkungen
import java.util.*;
import java.util.stream.*;
List<Integer> numbers = IntStream.range(1, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();
// GEFÄHRLICH! Nicht so machen!
numbers.parallelStream().forEach(n -> results.add(n * n));
System.out.println("Listengröße: " + results.size());
Was kann passieren? Die Listengröße kann kleiner als erwartet sein, und manchmal tritt eine ConcurrentModificationException auf. Der Grund: ArrayList ist nicht thread-sicher, während der parallele Stream mehrere Threads gleichzeitig startet.
6. Spliterator: Besonderheiten und Eigenschaften
Spliterator-Eigenschaften
Spliterator beschreibt seine Eigenschaften über eine Bitmaske:
- ORDERED – Elemente haben eine definierte Reihenfolge (z. B. bei einer Liste).
- DISTINCT – alle Elemente sind eindeutig (z. B. bei einem Set).
- SORTED – Elemente sind sortiert.
- SIZED – die Größe ist bekannt.
- IMMUTABLE – die Collection ist unveränderlich.
- CONCURRENT – die Collection ist thread-sicher.
- SUBSIZED – alle Spliteratoren nach trySplit() kennen ebenfalls ihre Größe.
Spliterator<String> spliterator = names.spliterator();
int characteristics = spliterator.characteristics();
System.out.println(Integer.toBinaryString(characteristics));
Wozu ist das gut? Das Stream API und parallele Streams nutzen diese Merkmale für Optimierungen. Ist die Quelle beispielsweise unveränderlich und sortiert, kann sie sicherer und effizienter aufgeteilt und das Ergebnis zusammengeführt werden.
7. Wann und wie sollte man Spliterator direkt verwenden?
Im Alltag schreibt man selten eigene Spliterator-Implementierungen: Die Standard-Collections bringen bereits alles mit. Wenn Sie jedoch eine eigene Datenquelle erstellen oder das Durchlaufen/Aufteilen fein steuern möchten, ist Spliterator hilfreich.
Beispiel: manuelles Durchlaufen mit tryAdvance
import java.util.List;
import java.util.Spliterator;
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
Spliterator<String> spliterator = names.spliterator();
spliterator.tryAdvance(name -> System.out.println("Erstes Element: " + name));
spliterator.forEachRemaining(name -> System.out.println("Übrige: " + name));
Beispiel: Aufteilen der Collection
Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();
if (spliterator2 != null) {
spliterator2.forEachRemaining(name -> System.out.println("Teil 2: " + name));
}
spliterator1.forEachRemaining(name -> System.out.println("Teil 1: " + name));
8. Häufige Fehler beim Arbeiten mit Spliterator und parallelen Streams
Fehler Nr. 1: Verwendung paralleler Streams für kleine Collections. Statt einer Beschleunigung erhalten Sie eine Verlangsamung – die Overheads für Aufteilung und Task-Planung überwiegen den Nutzen.
Fehler Nr. 2: Erwartung, dass die Reihenfolge der Elemente erhalten bleibt. Parallele Streams garantieren keine Reihenfolge. Wenn sie wichtig ist, verwenden Sie forEachOrdered – allerdings geht dabei ein Teil der Parallelitätseffizienz verloren.
Fehler Nr. 3: Seiteneffekte in Lambda-Ausdrücken. Innerhalb eines parallelen Streams sollten externe Variablen/Collections nicht verändert werden – sonst drohen Data Races und schwer reproduzierbare Bugs.
Fehler Nr. 4: Verwendung nicht threadsicherer Collections innerhalb eines parallelen Streams. In einen normalen ArrayList aus mehreren Threads einzufügen, führt direkt zu Fehlern wie ConcurrentModificationException.
Fehler Nr. 5: Erwartung einer sofortigen Beschleunigung. Parallele Streams sind kein Zauberstab. Profilieren Sie: Wenn es wenig Daten sind oder die Operation leicht ist, ist der sequentielle Stream schneller.
Fehler Nr. 6: Parallele Streams mit Quellen, die sich schlecht aufteilen lassen. Zum Beispiel wird LinkedList oft ineffizient aufgeteilt – Parallelität kann die Ausführung dann sogar verlangsamen.
GO TO FULL VERSION