Zum Hauptinhalt springen

Die Java Stream API

Die Java Stream API ermöglicht die funktionale Verarbeitung von Elementfolgen. Ein Strom (Stream) repräsentiert eine Sequenz von Elementen, auf der verkettete Operationen ausgeführt werden können — entweder sequentiell oder parallel. Die Originaldaten bleiben dabei unverändert. Die Auswertung erfolgt nach dem Prinzip der Bedarfsauswertung (Lazy Evaluation): Operationen werden erst dann ausgeführt, wenn eine terminale Operation dies erfordert.

info

Ströme (Paket java.util.stream) haben nichts mit Datenströmen (IO-Streams) (Paket java.io) zu tun.

Erzeugen von Strömen

Ströme lassen sich aus Feldern, Listen, Mengen oder einzelnen Werten erzeugen.

MainClass.java
public class MainClass {

public static void main(String[] args) {
int[] array = {4, 8, 15, 16, 23, 42};
IntStream integerStream = Arrays.stream(array);

List<Integer> list = List.of(4, 8, 15, 16, 23, 42);
Stream<Integer> integerStream2 = list.stream();

Stream<Integer> integerStream3 = Stream.of(4, 8, 15, 16, 23, 42);
}

}

Im Gegensatz zu Stream<T> bieten die spezialisierten Klassen IntStream, DoubleStream und LongStream zusätzliche Methoden zur Verarbeitung primitiver Werte, wie etwa sum() oder average().

MainClass.java
public class MainClass {

public static void main(String[] args) {
int[] array = {4, 8, 15, 16, 23, 42};
IntStream integerStream = Arrays.stream(array);
int sum = integerStream.sum();
}

}

Intermediäre Operationen

Intermediäre Operationen transformieren einen Strom in einen neuen Strom. Sie werden erst dann ausgeführt, wenn eine terminale Operation folgt. Typische intermediäre Operationen sind Filtern, Abbilden und Sortieren.

OperationMethodeSchnittstellen-Methode
FilternStream<T> filter(predicate: Predicate<T>)boolean test(t: T)
AbbildenStream<R> map(mapper: Function<T, R>)R apply(t: T)
AbbildenDoubleStream mapToDouble(mapper: ToDoubleFunction<T, R>)double applyAsDouble(value: T)
AbbildenIntStream mapToInt(mapper: ToIntFunction<T, R>)int applyAsInt(value: T)
AbbildenLongStream mapToLong(mapper: ToLongFunction<T, R>)long applyAsLong(value: T)
SpähenStream<T> peek(consumer: Consumer<T>)void accept(t: T)
AbflachenStream<R> flatMap(mapper: Function<T, Stream<R>>)R apply(t: T)
SortierenStream<T> sorted(comparator: Comparator<T>)int compare(o1: T, o2: T)
SortierenStream<T> sorted()-
UnterscheidenStream<T> distinct()-
BegrenzenStream<T> limit(maxSize: long)-
ÜberspringenStream<T> skip(n: long)-
SammelnList<T> toList()-

Terminale Operationen

Terminale Operationen schließen den Strom ab und liefern ein Ergebnis. Da der Strom danach nicht mehr verwendbar ist, können keine weiteren Operationen folgen. Typische Anwendungsfälle sind das Prüfen, Aggregieren und Sammeln von Elementen.

OperationMethodeSchnittstellen-Methode
FindenOptional<T> findAny()-
FindenOptional<T> findFirst()-
Prüfenboolean allMatch(predicate: Predicate<T>)boolean test(t: T)
Prüfenboolean anyMatch(predicate: Predicate<T>)boolean test(t: T)
Prüfenboolean noneMatch(predicate: Predicate<T>)boolean test(t: T)
AggregierenOptional<T> min(comparator: Comparator<T>)int compare(o1: T, o2: T)
AggregierenOptional<T> max(comparator: Comparator<T>)int compare(o1: T, o2: T)
Aggregierenlong count()-
SammelnR collect(collector: Collector<T, A, R>)-
Ausführenvoid forEach(action: Consumer<T>)void accept(t: T)

Zahlenströme (IntStream, DoubleStream, LongStream) bieten zusätzlich die terminalen Operationen sum() und average().

Collectors

Die terminale Operation collect() nimmt einen Collector entgegen und fasst die Elemente des Stroms zu einem Ergebnis zusammen. Die Klasse Collectors (Paket java.util.stream) stellt fertige Collector-Implementierungen für die häufigsten Anwendungsfälle bereit.

CollectorMethodeBeschreibung
In Liste sammelntoList()Sammelt Elemente in eine List<T>
In Menge sammelntoSet()Sammelt Elemente in eine Set<T> (Duplikate werden entfernt)
In Map sammelntoMap(keyMapper: Function<T,K>, valueMapper: Function<T,V>)Sammelt Elemente in eine Map<K,V>
GruppierengroupingBy(classifier: Function<T,K>)Gruppiert Elemente nach Schlüssel in eine Map<K, List<T>>
GruppierengroupingBy(classifier: Function<T,K>, downstream: Collector<T,A,D>)Gruppiert und wendet auf jede Gruppe einen weiteren Collector an
PartitionierenpartitioningBy(predicate: Predicate<T>)Teilt Elemente in zwei Gruppen (true/false) auf
PartitionierenpartitioningBy(predicate: Predicate<T>, downstream: Collector<T,A,D>)Partitioniert und wendet auf jede Partition einen weiteren Collector an
Abbildenmapping(mapper: Function<T,U>, downstream: Collector<U,A,R>)Bildet Elemente ab, bevor ein nachgelagerter Collector sie sammelt
Zählencounting()Zählt die Elemente (nützlich als nachgelagerter Collector)
Verbindenjoining()Verbindet String-Elemente zu einem einzigen String
Verbindenjoining(delimiter: CharSequence)Verbindet Elemente mit einem Trennzeichen
Verbindenjoining(delimiter: CharSequence, prefix: CharSequence, suffix: CharSequence)Verbindet Elemente mit Trennzeichen, Präfix und Suffix
SummierensummingInt(mapper: ToIntFunction<T>)Berechnet die Summe der gemappten int-Werte
SummierensummingDouble(mapper: ToDoubleFunction<T>)Berechnet die Summe der gemappten double-Werte
SummierensummingLong(mapper: ToLongFunction<T>)Berechnet die Summe der gemappten long-Werte
DurchschnittaveragingInt(mapper: ToIntFunction<T>)Berechnet den Durchschnitt der gemappten int-Werte als double
DurchschnittaveragingDouble(mapper: ToDoubleFunction<T>)Berechnet den Durchschnitt der gemappten double-Werte
DurchschnittaveragingLong(mapper: ToLongFunction<T>)Berechnet den Durchschnitt der gemappten long-Werte als double
ZusammenfassensummarizingInt(mapper: ToIntFunction<T>)Liefert Statistiken (Anzahl, Summe, Min, Max, Durchschnitt) als IntSummaryStatistics

Das folgende Beispiel zeigt typische Anwendungen von Collectors:

MainClass.java
public class MainClass {

record Person(String name, String city) {}

public static void main(String[] args) {
List<Person> persons = List.of(
new Person("Anna", "Berlin"),
new Person("Ben", "Hamburg"),
new Person("Clara", "Berlin"),
new Person("David", "Hamburg"),
new Person("Eva", "Berlin")
);

// Alle Namen als kommaseparierten String zusammenfassen
String names = persons.stream()
.map(Person::name)
.collect(Collectors.joining(", "));

// Personen nach Stadt gruppieren
Map<String, List<Person>> byCity = persons.stream()
.collect(Collectors.groupingBy(Person::city));

// Anzahl der Personen je Stadt
Map<String, Long> countByCity = persons.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));

// Personen nach Wohnort Berlin / nicht Berlin aufteilen
Map<Boolean, List<Person>> partitioned = persons.stream()
.collect(Collectors.partitioningBy(p -> p.city().equals("Berlin")));
}

}

Bedarfsauswertung (Lazy Evaluation)

Bei der Bedarfsauswertung werden intermediäre Operationen nicht sofort ausgeführt, sondern erst dann, wenn eine terminale Operation den Strom abschließt. Zudem werden bei verketteten Operationen alle Schritte für jedes Element nacheinander durchlaufen — nicht erst alle Elemente durch Schritt 1, dann alle durch Schritt 2.

Das folgende Beispiel filtert den Zahlenstrom 4-8-15-16-23-42 zunächst nach geraden Zahlen, dann nach Zahlen größer als 15, und gibt die verbliebenen Zahlen aus. Zur Veranschaulichung wird jeder Filterschritt ebenfalls ausgegeben.

MainClass.java
public class MainClass {

public static void main(String[] args) {
Stream.of(4, 8, 15, 16, 23, 42).filter(i -> {
System.out.println(i + ": filter 1");
return i % 2 == 0;
}).filter(i -> {
System.out.println(i + ": filter 2");
return i > 15;
}).forEach(i -> System.out.println(i + ": forEach"));
}

}

Ohne Bedarfsauswertung würden die Operationen nacheinander für alle Elemente ausgeführt:

4: filter 1
8: filter 1
15: filter 1
16: filter 1
23: filter 1
42: filter 1
4: filter 2
8: filter 2
16: filter 2
42: filter 2
16: forEach
42: forEach

Aufgrund der Bedarfsauswertung werden alle Operationen für jedes Element einzeln nacheinander ausgeführt:

4: filter 1
4: filter 2
8: filter 1
8: filter 2
15: filter 1
16: filter 1
16: filter 2
16: forEach
23: filter 1
42: filter 1
42: filter 2
42: forEach

Unendliche Ströme

Die Java Stream API stellt Methoden bereit, mit denen sich theoretisch unendlich viele Elemente erzeugen lassen. In der Praxis werden solche Ströme durch limit() begrenzt.

  • Stream<T> iterate(seed: T, f: UnaryOperator<T>) — erzeugt einen unendlichen Strom aus einem Startwert und einer Funktion, die jeweils das nächste Element berechnet
  • Stream<T> iterate(seed: T, hasNext: Predicate<T>, next: UnaryOperator<T>) — wie oben, aber mit einer Abbruchbedingung
  • Stream<T> generate(s: Supplier<T>) — erzeugt Elemente über einen Lieferanten, z.B. Zufallszahlen
MainClass.java
public class MainClass {

public static void main(String[] args) {
// Zahlen 0 bis 99 ausgeben (unendlicher Strom, begrenzt auf 100)
Stream.iterate(0, i -> ++i).limit(100).forEach(System.out::println);
// Zahlen 0 bis 99 mit Abbruchbedingung
Stream.iterate(0, i -> i < 100, i -> ++i).forEach(System.out::println);
// 100 Pseudozufallszahlen von 0 bis 99
Stream.generate(() -> new Random().nextInt(100)).limit(100).forEach(System.out::println);
}

}

Die ersten beiden Ströme geben die Zahlen von 0 bis 99 aus. Der dritte erzeugt 100 Pseudozufallszahlen von 0 bis 99 und wird durch limit(100) begrenzt.