W3docs

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:

  1. Eine Quelle. Woher die Elemente kommen. Meistens eine Collection (coll.stream()), gelegentlich ein Literal (Stream.of(\"a\", \"b\")), ein Array (Arrays.stream(arr)), ein IntStream-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.
  2. 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 von filter testet noch nichts; er speichert nur das Predicate.
  3. 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 bei forEach) und verbraucht den Stream — er kann nicht wiederverwendet werden.
list.stream()              // SOURCE
    .filter(...)           // intermediate
    .map(...)              // intermediate
    .sorted()              // intermediate
    .toList();             // TERMINAL — runs the pipeline

Ohne 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 = 144

Stream.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 upon

Wenn 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

AspektCollectionStream
Speichert Daten?JaNein
Wiederverwendbar?JaNein (ein Terminal)
Eager oder lazy?EagerLazy 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
KostenmodellVerwaltungsaufwand pro ElementEin 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, break mit 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 erweitertes for, eine List als Rückgabewert einer Methode), zuerst toList() 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.

java— editable, runs on the server

Was aus dem Lauf mitzunehmen ist:

  • Die kanonische vierstufige Pipeline — streamfiltermaptoList — erzeugte eine sortierte Liste von Erwachsenennamen ohne explizite Schleife, ohne temporäre Collection und ohne Null-Überprüfung.
  • peek druckte einmal pro gezogenem Element. findFirst zog Elemente, bis eines n*n > 50 erfüllte (was bei n = 8, Quadrat 64 passiert), 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() warf IllegalStateException. 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 QuellenCollection.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.

Übungen

Übung
Man schreibt `list.stream().filter(p).map(f);` und ruft keine Terminal-Operation auf. Was passiert, wenn diese Zeile ausgeführt wird?
Man schreibt `list.stream().filter(p).map(f);` und ruft keine Terminal-Operation auf. Was passiert, wenn diese Zeile ausgeführt wird?
Was this page helpful?