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 die List<Order> in einen Stream<Order>.
  • filter behält nur Bestellungen mit dem Status COMPLETED.
  • toList() (Java 16+) sammelt das Ergebnis in einer unveränderlichen List, statt collect(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 (wie toList(), forEach()), werden die vorherigen filter/map nicht 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::product ist eine Method-Reference, äquivalent zu order -> order.product().
  • distinct() entfernt doppelte Elemente (basierend auf equals()).
[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 map nicht mit forEach. map gibt einen neuen Stream zurück, ohne Nebeneffekte; forEach wird 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.0 ist die identity – der Startwert, wenn der Stream leer ist.
  • Double::sum ist 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) und Stream.min(Comparator) – kürzer und aussagekräftiger als reduce. Verwende reduce nur, 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.toMap nimmt zwei Funktionen: key extractor und value extractor.
  • Wenn es doppelte Schlüssel gibt, wirft toMap eine IllegalStateException – 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() oder forEach() auf denselben Stream wirft IllegalStateException: stream has already been operated upon or closed.
  • Die falsche identity in reduce vergessen: Die Verwendung der falschen identity (z.B. 1 statt 0 fü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.

javastream-apifunctional-programmingjava21collections