Einführung in Java Streams
Eine Einführung in die Java Stream API zur Verarbeitung von Elementfolgen mit funktionalen Operationen.
Ein Stream ist eine Pipeline, die Elemente einer Quelle durch eine Folge von Operationen leitet und ein Ergebnis erzeugt. Er ist keine Datenstruktur — er speichert nichts. Er ist ein deklaratives Rezept zur Datenverarbeitung, das lazy ausgewertet und einmalig ausgeführt wird. Streams kamen in Java 8 zusammen mit Lambdas, und beide wurden so konzipiert, dass sie zusammenpassen: Jede Stream-Operation nimmt eine Funktion entgegen, und die Sprache lieferte eine saubere Möglichkeit, eine solche zu schreiben.
Die Form, die man hunderte Male schreiben wird:
double avgAdultAge = people.stream()
.filter(p -> p.age() >= 18)
.mapToInt(Person::age)
.average()
.orElse(0.0);Drei Dinge fallen auf. Die Pipeline lässt sich von oben nach unten als Schritte lesen, die beschreiben, was man möchte, nicht wie man iteriert. Jeder Schritt nimmt eine Funktion entgegen — ein Predicate, eine ToIntFunction — genau das Vokabular, das in den vorherigen Kapiteln eingeführt wurde. Und das Ergebnis fällt aus einer einzigen Terminal-Operation heraus; es gibt keine Schleife, keinen Akkumulator, kein frühzeitiges continue.
Die Pipeline-Form: Quelle → Intermediate → Terminal
Jede Stream-Pipeline besteht aus drei Teilen:
- Eine Quelle. Woher die Elemente kommen. Meistens eine Collection (
coll.stream()), gelegentlich ein Literal (Stream.of(\"a\", \"b\")), ein Array (Arrays.stream(arr)), einIntStream-Bereich (IntStream.range(0, 100)), eine I/O-Quelle (Files.lines(path)) oder ein Generator (Stream.iterate,Stream.generate). Das nächste Kapitel widmet sich all diesen Quellen. - Null oder mehr Intermediate-Operationen. Jede gibt einen weiteren Stream zurück, sodass sie sich verketten lassen. Häufige Beispiele:
filter,map,flatMap,distinct,sorted,limit,skip,peek. Sie sind lazy — der Aufruf vonfiltertestet noch nichts; er speichert nur das Predicate. - Genau eine Terminal-Operation. Sie löst die Pipeline aus. Beispiele:
forEach,collect,toList,count,sum,min,max,reduce,findFirst,anyMatch. Das Terminal erzeugt einen Wert (oder einen Seiteneffekt beiforEach) und verbraucht den Stream — er kann nicht wiederverwendet werden.
list.stream() // SOURCE
.filter(...) // intermediate
.map(...) // intermediate
.sorted() // intermediate
.toList(); // TERMINAL — runs the pipelineOhne das Terminal passiert nichts. Ein Stream, den man baut und nie beendet, ist toter Code — es wird keine Arbeit verrichtet, keine Seiteneffekte ausgelöst, die Lambdas werden nicht ausgeführt.
Lazy by Design
Intermediate-Operationen sind lazy, weil die JVM nicht weiß, welche Elemente tatsächlich benötigt werden, bis das Terminal fragt. Das ermöglicht zwei wichtige Optimierungen:
Fusion. Benachbarte Intermediates werden in einem einzigen Durchlauf ausgeführt, nicht je einem Durchlauf pro Operation. stream.filter(p).map(f) erstellt keine zwischengeschaltete gefilterte Liste, die dann gemappt wird; es testet ein Element, und wenn es besteht, wird es gemappt — alles in einem Schritt.
Short-Circuiting. Ein Terminal wie findFirst, anyMatch oder limit(n) hält die Pipeline an, sobald es seine Antwort hat. In Kombination mit Laziness bedeutet das, dass man eine „finde das erste gerade Quadrat größer als 100"-Pipeline über einen unendlichen Stream laufen lassen und in Mikrosekunden eine Antwort erhalten kann:
int answer = Stream.iterate(1, n -> n + 1) // 1, 2, 3, 4, ...
.map(n -> n * n) // 1, 4, 9, 16, ...
.filter(n -> n % 2 == 0 && n > 100) // first match wins
.findFirst()
.orElseThrow();
// answer = 144Stream.iterate(1, n -> n + 1) ist unendlich, aber findFirst forderte nur Elemente an, bis eines passte. Die Pipeline testete 12 Quadrate (1, 4, 9, ..., 144) und hielt dann an.
Einmalige Verwendung, wie ein Iterator
Ein Stream kann einmal durchlaufen werden. Das Terminal verbraucht ihn, danach ist das Stream-Objekt geschlossen; ein weiteres Terminal darauf aufzurufen wirft IllegalStateException:
Stream<String> s = list.stream();
long c1 = s.count(); // ok
long c2 = s.count(); // throws IllegalStateException — stream has already been operated uponWenn man dieselben Daten zweimal verarbeiten muss, baut man den Stream zweimal:
long c1 = list.stream().count();
long c2 = list.stream().count();Das entspricht dem Verhalten eines Iterator. Das Stream-Objekt ist der bewegende Cursor, nicht die Daten. Die Daten sind die Quelle — sie erneut zu streamen ist kostenlos.
Streams vs. Collections — unterschiedliche Aufgaben
| Aspekt | Collection | Stream |
|---|---|---|
| Speichert Daten? | Ja | Nein |
| Wiederverwendbar? | Ja | Nein (ein Terminal) |
| Eager oder lazy? | Eager | Lazy bis zum Terminal |
| Ändert die Quelle? | Ja (z. B. list.add) | Nein — Pipelines sind schreibgeschützt |
| Iteriert explizit? | Oft (for, iterator()) | Nein — die Pipeline steuert die Iteration |
| Kostenmodell | Verwaltungsaufwand pro Element | Ein Durchlauf durch die Quelle |
Eine Collection ist ein Container; ein Stream ist eine Berechnung über einem Container (oder einer anderen Quelle). Sie ergänzen sich: Man holt aus einer Collection, führt eine Stream-Pipeline aus und sammelt das Ergebnis in einer (meist anderen) Collection.
Drei kleine Beispiele, die man ständig schreibt
Elemente zählen, die ein Predicate erfüllen:
long adults = people.stream().filter(p -> p.age() >= 18).count();Eine Liste transformierter Werte erstellen:
List<String> names = people.stream().map(Person::name).toList();Auf einen einzelnen Wert reduzieren:
int totalAge = people.stream().mapToInt(Person::age).sum();Diese drei Muster — zählen, auf Liste mappen, auf Skalar reduzieren — decken die meisten Anwendungsfälle der API ab. Der Rest des Kapitels erkundet die Operationen, die das Wie für jeden Fall ausfüllen.
Drei Dinge, die Streams nicht sind
- Kein Ersatz für
for-Schleifen im Allgemeinen. Eine Schleife, die etwas mit nicht-trivialem Kontrollfluss aufbaut,breakmit Seiteneffekten benötigt oder mehrere Variablen verändert, ist als Schleife nach wie vor klarer. Streams glänzen, wenn die Arbeit selbst eine Pipeline aus reinen Operationen ist. - Kein Performancegewinn bei kleinen Datenmengen. Eine Stream-Pipeline allokiert einige kleine Objekte; eine 10-Element-Schleife wird schneller sein. Der Gewinn liegt in der Klarheit bei beliebigen Datenmengen und in der Parallelisierung bei großen Datenmengen.
- Kein Ersatz für
Iterator/Iterable, wenn anderer Code diese erwartet. Ein Stream erzeugt Werte; wenn man Konsum verschachteln muss (ein erweitertesfor, eineListals Rückgabewert einer Methode), zuersttoList()aufrufen.
Sequenziell standardmäßig, parallel auf Anfrage
Jeder Stream in diesem Kapitel ist sequenziell — Elemente fließen einzeln und in Reihenfolge durch die Pipeline. Es gibt auch coll.parallelStream() (und stream.parallel()), das die Pipeline über den gemeinsamen ForkJoinPool für Multi-Core-Arbeit verteilt. Parallele Streams sind ein späteres Kapitel — sie setzen mehrere Annahmen über die Pipeline voraus (sie muss assoziativ, zustandslos und frei von Seiteneffekten sein), die die „Intro"-Pipelines dieses Kapitels von Natur aus erfüllen, sodass das Upgrade in der Regel eine Änderung mit einem Token ist.
Ein ausgearbeitetes Beispiel: eine vollständige Pipeline, Laziness und die Einmalverwendungsregel
Das folgende Programm erstellt eine kleine Liste von Person-Records, führt die kanonische Pipeline-Form aus (filter → map → sorted → collect), beweist Laziness mit peek, demonstriert Short-Circuiting auf einem unendlichen Stream.iterate und zeigt die IllegalStateException, die man beim Wiederverwenden eines Streams erhält.
Was aus dem Lauf mitzunehmen ist:
- Die kanonische vierstufige Pipeline —
stream→filter→map→toList— erzeugte eine sortierte Liste von Erwachsenennamen ohne explizite Schleife, ohne temporäre Collection und ohne Null-Überprüfung. peekdruckte einmal pro gezogenem Element.findFirstzog Elemente, bis einesn*n > 50erfüllte (was bein = 8, Quadrat64passiert), und hörte dann auf. Das ist Laziness und Short-Circuiting in Zusammenarbeit: Die vorgelagerten Operationen verrichteten genau die notwendige Arbeit und nicht mehr.- Die Pipeline „erstes gerades Quadrat über 100" lief über eine unendliche Quelle. Ohne Short-Circuiting wäre das eine Endlosschleife; damit testete die Pipeline 12 Werte und lieferte
144. - Das zweite
s.count()warfIllegalStateException. Streams sind einmalig verwendbar; wenn ein zweiter Durchlauf benötigt wird, muss ein neuer Stream aus der Quelle erstellt werden. - Die „kein-Terminal"-Pipeline am Ende druckte nichts aus ihrem
peek. Ohne ein Terminal laufen Intermediates nicht — der Stream ist nur ein Rezept, das niemand zur Ausführung gebracht hat.
Was kommt als Nächstes
Man kennt jetzt die Pipeline-Form, die Aufteilung in Quelle/Intermediate/Terminal, den Laziness-Vertrag und die Einmalverwendungsregel. Das nächste Kapitel, Creating Java Streams, ist der Katalog der Quellen — Collection.stream(), Stream.of, Arrays.stream, IntStream.range, Stream.iterate, Stream.generate, Files.lines, String.chars(), Stream.empty und die Stream.Builder-API. Mit dem Quellen-Kapitel hat man alles, was man zum Starten braucht, und der Rest des Teils füllt die Intermediate- und Terminal-Operationen aus.