Stream API: map, filter, reduce und collect anhand von Beispielen
1. Übersicht
Die Stream API ist seit Java 8 vorhanden und ist ein unverzichtbares Werkzeug zur Verarbeitung von Collections. Dieser Artikel hilft dir, die vier wichtigsten Operationen zu beherrschen: map, filter, reduce und collect durch konkrete Beispiele mit Order und Customer. Nach dem Lesen wirst du wissen, wann du welche Operation einsetzt, wie du sie kombinierst und welche häufigen Fehler du vermeidest.
2. Beispieldaten vorbereiten
Bevor wir jede Operation durchgehen, brauchst du einen Datensatz zum Arbeiten durch den gesamten Artikel.
public record Order(String id, String product, int quantity, double price, String status) {
public double total() {
return quantity * price;
}
}
import java.util.List;
public class OrderData {
public static List<Order> sample() {
return List.of(
new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
);
}
}
Order ist ein Record – kompakt, unveränderbar und perfekt zum Demonstrieren von Streams. Die Methode total() berechnet die Gesamtsumme für jede Bestellung und wird später mehrfach verwendet.
3. filter: Elemente nach Bedingung filtern
filter nimmt ein Predicate<T> entgegen und behält nur die Elemente, die die Bedingung erfüllen.
List<Order> completedOrders = OrderData.sample().stream()
.filter(order -> order.status().equals("COMPLETED"))
.toList();
completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
stream()konvertiert dieList<Order>in einenStream<Order>.filterbehält nur Bestellungen mit dem StatusCOMPLETED.toList()(Java 16+) sammelt das Ergebnis in einer unveränderlichenList, stattcollect(Collectors.toList())zu verwenden.
O1 - Laptop
O2 - Mouse
O4 - Monitor
Hinweis:
filterändert nicht die Originaldaten. Streams sind lazy – wenn du keine Terminal-Operation hast (wietoList(),forEach()), werden die vorherigenfilter/mapnicht ausgeführt.
3.1. Mehrere filter-Bedingungen kombinieren
List<Order> expensiveCompleted = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.toList();
Zwei aufeinanderfolgende filter entsprechen einer AND-Bedingung. Du kannst sie in einen filter mit && schreiben, aber das Trennen macht den Code bei komplexen Bedingungen lesbarer.
4. map: Elemente transformieren
map nimmt eine Function<T, R> entgegen und transformiert jedes Element vom Typ T in ein Element vom Typ R.
List<String> productNames = OrderData.sample().stream()
.map(Order::product)
.distinct()
.toList();
System.out.println(productNames);
Order::productist eine Method-Reference, äquivalent zuorder -> order.product().distinct()entfernt doppelte Elemente (basierend aufequals()).
[Laptop, Mouse, Keyboard, Monitor]
4.1. map zu einem völlig anderen Typ
Die Stärke von map liegt in der Datentyp-Transformation, nicht nur um ein Feld zu extrahieren.
List<Double> totals = OrderData.sample().stream()
.map(Order::total)
.toList();
System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]
Hinweis: Verwechsle
mapnicht mitforEach.mapgibt einen neuen Stream zurück, ohne Nebeneffekte;forEachwird verwendet, um Aktionen auszuführen (Ausgeben, Speichern in DB…) und gibt nichts zurück.
5. reduce: Mehrere Elemente zu einem Wert kombinieren
reduce kombiniert den gesamten Stream zu einem einzelnen Wert, basierend auf einer identity (Startwert) und einem BinaryOperator.
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
System.out.println(totalRevenue);
0.0ist die identity – der Startwert, wenn der Stream leer ist.Double::sumist der Accumulator, der jeden Wert zum Ergebnis addiert.
1.8950000E7
5.1. reduce ohne identity
Wenn du keine identity bereitstellen möchtest, gibt reduce Optional<T> zurück, da der Stream leer sein kann.
Optional<Order> mostExpensive = OrderData.sample().stream()
.reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);
mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7
Hinweis: Für die Suche nach Max/Min bietet Java bereits
Stream.max(Comparator)undStream.min(Comparator)– kürzer und aussagekräftiger alsreduce. Verwendereducenur, wenn die Kombinationslogik komplexer ist als ein einfacher Vergleich.
6. collect: Ergebnisse in Datenstrukturen sammeln
collect ist flexibler als toList() und ermöglicht es, Ergebnisse in einer Map, Set zu sammeln oder groupBy/partition durchzuführen.
6.1. In Map sammeln
import java.util.Map;
import java.util.stream.Collectors;
Map<String, Double> totalByOrderId = OrderData.sample().stream()
.collect(Collectors.toMap(Order::id, Order::total));
System.out.println(totalByOrderId);
Collectors.toMapnimmt zwei Funktionen: key extractor und value extractor.- Wenn es doppelte Schlüssel gibt, wirft
toMapeineIllegalStateException– du musst eine zusätzliche merge-Funktion übergeben, wenn die Daten möglicherweise doppelte Schlüssel haben.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}
6.2. groupingBy – Gruppierung nach Bedingung
Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(Order::status));
ordersByStatus.forEach((status, orders) ->
System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)
6.3. groupingBy mit Downstream-Collector kombinieren
Du kannst den Gesamtumsatz nach jedem Status-Cluster berechnen, ohne danach erneut zu iterieren.
Map<String, Double> revenueByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(
Order::status,
Collectors.summingDouble(Order::total)
));
System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}
groupingBy mit Downstream-Collector (summingDouble, counting, averagingDouble…) hilft dir, das manuelle Schreiben von reduce für jede Gruppe zu vermeiden.
7. Alle vier Operationen in einer Pipeline kombinieren
Hier ist ein praktisches Beispiel: Hol die Liste der Produktnamen von abgeschlossenen Bestellungen mit einem Wert > 1 Million, sortiert absteigend nach Gesamtsumme.
List<String> topProducts = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.sorted(Comparator.comparingDouble(Order::total).reversed())
.map(Order::product)
.toList();
System.out.println(topProducts);
[Laptop, Monitor]
Die Reihenfolge der Operationen ist hier wichtig: filter zuerst, um die Anzahl zu verarbeitender Elemente zu reduzieren; sorted nach dem Filtern, um unnötige Sortierungen zu vermeiden; map am Ende, um nur das benötigte Feld zu holen.
8. Pipeline testen
Schreibe immer Tests für Geschäftslogik, die sich in einem Stream-Pipeline befindet, und verstecke sie nicht in einer langen Codezeile ohne Überprüfung.
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderAnalyticsTest {
@Test
void shouldCalculateTotalRevenueForCompletedOrders() {
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
assertThat(totalRevenue).isEqualTo(18_950_000.0);
}
@Test
void shouldGroupOrdersByStatus() {
var grouped = OrderData.sample().stream()
.collect(java.util.stream.Collectors.groupingBy(Order::status));
assertThat(grouped)
.containsKey("COMPLETED")
.extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
.hasSize(3);
}
}
Der erste Test überprüft, ob das reduce-Ergebnis die korrekte Gesamtsumme liefert. Der zweite Test überprüft, ob groupingBy die korrekte Anzahl von Elementen in der COMPLETED-Gruppe erzeugt.
9. Häufige Fehler
- Verwende eine Schleife statt Stream, wenn die Lesbarkeit wichtiger ist: Wenn die Logik viele Nebeneffekte hat (Logging, Update externer Variablen, Exceptions mit komplexen Bedingungen werfen), ist eine traditionelle
for-Schleife lesbarer als ein Stream. - Wiederverwendung eines verbrauchten Streams: Ein Stream kann nur einmal ausgeführt werden. Das zweimalige Aufrufen von
collect()oderforEach()auf denselben Stream wirftIllegalStateException: stream has already been operated upon or closed. - Die falsche identity in reduce vergessen: Die Verwendung der falschen identity (z.B.
1statt0für eine Summe) führt zu falschen Ergebnissen, ohne einen Fehler zu melden. - toMap mit doppelten Schlüsseln: Wie in Abschnitt 6.1 erwähnt, musst du eine merge-Funktion hinzufügen:
Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1). - Nebeneffekte in map: Vermeide es, den externen State in der Funktion zu ändern, die an
map/filterübergeben wird. Streams können parallel ausgeführt werden, und Nebeneffekte führen zu schwer zu debuggenden Race Conditions.
Hinweis: Wenn deine Pipeline mehr als 4–5 aufeinanderfolgende Operationen hat und schwer zu lesen ist, teile sie in separate Methoden mit aussagekräftigen Namen auf (
filterCompleted,calculateRevenue…), statt alles in eine lange Chain zu zwängen.
10. Fazit
filter, map, reduce und collect sind grundlegende Werkzeuge für die funktionale Datenverarbeitung in Java. filter hilft dir zu filtern, map zu transformieren, reduce zu aggregieren zu einem Wert, während collect die Ergebnisse flexibel in komplexere Strukturen wie Map sammelt oder nach Bedingungen gruppiert. Wenn du die Reihenfolge der Operationen beherrschst und die häufigen Fehler oben vermeidest, wirst du prägnante, aussagekräftige und leicht zu testende Datenverarbeitungs-Pipelines schreiben können.