1. Was ist träge Auswertung?
Träge Auswertung, oder „lazy evaluation“, ist ein Prinzip, bei dem Operationen auf Daten so lange aufgeschoben werden, bis das Ergebnis wirklich benötigt wird. Im Kontext der Stream-API bedeutet das: Wenn Sie eine Verarbeitungskette über eine Collection schreiben, führt Java diese nicht sofort aus – es wartet, bis eine Terminaloperation aufgerufen wird. Erst dann wird die gesamte Kette berechnet.
Wozu ist das gut? Erstens werden Ressourcen gespart: Elemente, die am Ende nicht benötigt werden, werden einfach nicht verarbeitet. Zweitens steigt die Performance – man kann lange Ketten bauen, ohne zahlreiche Zwischen-Collections zu erstellen. Schließlich sind „Kurzschlussauswertungen“ möglich: Sobald das erste passende Element gefunden wurde, wird die weitere Verarbeitung abgebrochen.
Analogie: Stellen Sie sich einen trägen Kellner vor. Sie sagen: „Bring die Speisekarte, dann Kaffee und dann ein Stück Kuchen.“ Er nickt, macht aber nichts … bis Sie hinzufügen: „Und jetzt bring es wirklich.“ Dann geht er los – und bringt vielleicht nur den Kaffee, wenn es keinen Kuchen mehr gibt. Ungefähr so funktionieren auch träge Streams.
2. Zwischen- und Terminaloperationen
Zwischenoperationen (intermediate):
- filter
- map
- sorted
- distinct
- peek (zum Debuggen)
- und andere
Zwischenoperationen geben einen neuen Stream zurück, starten die Ausführung aber nicht. Sie „bauen nur den Plan“ der Verarbeitung.
Terminaloperationen (terminal):
- collect
- forEach
- reduce
- count
- findFirst, findAny
- anyMatch, allMatch, noneMatch
- und andere
Nur eine Terminaloperation startet die Ausführung der gesamten Kette.
Beispiel: Ohne Terminaloperation passiert nichts
List<String> names = List.of("Alice", "Bob", "John");
names.stream()
.filter(name -> {
System.out.println("Filtere " + name);
return name.startsWith("A");
});
// Es gibt keine Ausgaben! Der obige Code baut nur die Kette auf.
Fügen wir nun eine Terminaloperation hinzu:
names.stream()
.filter(name -> {
System.out.println("Filtere " + name);
return name.startsWith("A");
})
.forEach(System.out::println);
// Jetzt sehen wir eine Ausgabe in der Konsole!
Ergebnis:
Filtere Alice
Alice
Filtere Bob
Filtere John
Achten Sie auf die Reihenfolge: „Alice“ wird direkt nach der eigenen Filterung ausgegeben, noch bevor „Bob“ geprüft wird. Die Elemente durchlaufen die gesamte Kette der Operationen einzeln: Das erste Element geht durch filter und sofort weiter in forEach, dann das zweite und so weiter.
3. Vorteile der trägen Auswertung
Ressourceneinsparung
Träge Auswertung spart Zeit und Speicher für Elemente, die nicht benötigt werden. Wenn Sie z. B. das erste passende Objekt suchen, stoppt die Verarbeitung beim ersten Treffer.
List<String> names = List.of("Alice", "Bob", "John", "Anna");
String firstA = names.stream()
.filter(name -> {
System.out.println("Prüfe: " + name);
return name.startsWith("A");
})
.findFirst()
.orElse("Nicht gefunden");
System.out.println("Ergebnis: " + firstA);
Ausgabe:
Prüfe: Alice
Ergebnis: Alice
Beachten Sie: Die übrigen Elemente werden gar nicht geprüft!
Lange Ketten ohne Zwischen-Collections
Sie können viele Operationen kombinieren (filter, map, sorted usw.), ohne bei jedem Schritt Collections zu erzeugen.
List<String> names = List.of("Alice", "Bob", "John", "Anna");
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Ab Java 16+, früher: .collect(Collectors.toList())
Kurzschlussauswertungen
Wenn es genügt zu wissen, „gibt es ein passendes Element?“, werden die übrigen nicht geprüft:
boolean hasLongName = names.stream()
.anyMatch(name -> {
System.out.println("Prüfe: " + name);
return name.length() > 10;
});
// Wenn das erste Element lang ist, werden die übrigen nicht geprüft!
4. Beispiele: So funktioniert träge Auswertung
Beispiel 1: Ohne Terminaloperation passiert nichts
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> {
System.out.println("Filtere " + n);
return n % 2 == 0;
});
// Keine Ausgabe!
Beispiel 2: Kette mit Terminaloperation
numbers.stream()
.filter(n -> {
System.out.println("Filtere " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("Multipliziere " + n);
return n * 10;
})
.forEach(System.out::println);
Ausgabe:
Filtere 1
Filtere 2
Multipliziere 2
20
Filtere 3
Filtere 4
Multipliziere 4
40
Filtere 5
Wichtiger Hinweis: Die Operationen laufen elementweise ab: zuerst filter, dann map, dann forEach — jeweils nacheinander für jedes Element. Es sind nicht zwei separate Durchläufe („erst alles filtern, dann alles transformieren“).
Beispiel 3: peek zum Debuggen verwenden
numbers.stream()
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("Bestand den Filter: " + n))
.map(n -> n * 10)
.peek(n -> System.out.println("Nach map: " + n))
.forEach(System.out::println);
5. Nützliche Nuancen
Vermeiden Sie Streams mit Seiteneffekten
Trägheit kann tückisch sein, wenn Sie mit einer sofortigen Ausführung rechnen. Seiteneffekte innerhalb von map, filter oder peek (Dateischreiben, Änderung externen Zustands) können in anderer Reihenfolge, nicht für alle Elemente oder ohne Terminaloperation gar nicht ausgeführt werden.
So früh wie möglich filtern
Platzieren Sie filter möglichst am Anfang der Kette, um überflüssige Elemente früh auszuschließen und den weiteren Aufwand zu reduzieren.
Sie brauchen nur das erste Ergebnis? Passende Terminaloperationen verwenden
Wenn nur das erste passende Element benötigt wird, rufen Sie findFirst oder findAny auf. Dadurch kann der Stream unmittelbar nach dem Fund stoppen.
Streams sind nicht zum Ändern der Ausgangs-Collection da
Streams sind nicht dazu gedacht, Elemente der Ausgangs-Collection hinzuzufügen oder zu entfernen. Verwenden Sie zur Modifikation andere Mechanismen.
Visualisierung der Arbeit träger Streams
List<String> words = List.of("cat", "dog", "elephant", "fox", "giraffe");
words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
So läuft es ab:
| Schritt | cat | dog | elephant | fox | giraffe |
|---|---|---|---|---|---|
|
✗ | ✗ | ✓ | ✗ | ✓ |
|
— | — | ELEPHANT | — | GIRAFFE |
|
— | — | Ausgabe | — | Ausgabe |
Tabelle: Vergleich von eager und lazy Ansätzen
| Ansatz | Wann erfolgt die Verarbeitung? | Speichernutzung | Leistung |
|---|---|---|---|
| Eager (gierig) | Sofort beim Aufruf | Kann hoch sein | Mitunter langsam |
| Lazy (träge) | Nur bei Bedarf | Minimal | In der Regel schneller |
Gieriger Ansatz – zum Beispiel, wenn Sie manuell mehrere Durchläufe über eine Collection organisieren und Zwischenlisten erstellen.
Träger Ansatz – das sind Streams: Es passiert nichts, bis ein Ergebnis benötigt wird.
6. Typische Fehler im Umgang mit trägen Streams
Fehler Nr. 1: Erwartung eines unmittelbaren Ergebnisses. Einsteiger meinen oft, dass Aufrufe von filter oder map sofort ausgeführt werden. Ohne Terminal (z. B. collect, forEach) passiert jedoch nichts – daher „funktioniert das Debuggen nicht“, „es wird nichts ausgegeben“.
Fehler Nr. 2: Seiteneffekte in Zwischenoperationen. Dateischreiben, Änderungen externer Variablen innerhalb von map/filter/peek sind schlechte Praxis. Aufgrund der Trägheit und Optimierungen können solche Aktionen unvollständig, nicht in der erwarteten Reihenfolge oder ohne Terminaloperation gar nicht ausgeführt werden.
Fehler Nr. 3: Terminaloperation vergessen. Eine Stream-Kette wurde geschrieben, aber nicht mit collect, forEach usw. abgeschlossen. Ergebnis – „Stille“.
Fehler Nr. 4: Erwartung, dass alle Elemente verarbeitet werden. Operationen wie findFirst oder anyMatch beenden die Pipeline beim ersten Ergebnis. Die übrigen Elemente werden nicht verarbeitet – daher die Verwunderung „warum hat mein println nicht für alle funktioniert?“.
Fehler Nr. 5: Streams zum Ändern der Ausgangs-Collection verwenden. Streams sind nicht zur Modifikation der Ausgangs-Collections gedacht (Hinzufügen/Entfernen von Elementen). Verwenden Sie dafür spezialisierte Methoden der Collections oder Iteratoren.
GO TO FULL VERSION