Streams sind eine der Sachen in Java, die am Anfang ein bisschen ungewohnt aussehen, aber im Alltag extrem praktisch werden. Gerade wenn du noch nicht lange programmierst, wirkt der "Punkt-Operator-Marathon" schnell nach "Magie". In Wirklichkeit ist das Ganze aber ziemlich bodenständig: Streams helfen dir dabei, mit Datenfolgen (Listen, Arrays, Sets, whatever) lesbarer und kürzer zu arbeiten.

In diesem Artikel schauen wir uns gemeinsam an, was Streams sind, wie du sie benutzt und worauf du am Anfang achten solltest. Ziel ist, dass du am Ende nicht mehr vor einem Stream-Ausdruck zurückschreckst, sondern grob verstehst: Was passiert hier Schritt für Schritt? Und lass dich nicht von der Länge der Beitrags abschrecken - ich möchte dir vor allem mit einfachen Beispielen zeigen, wie einfach Streams zu nutzen und zu verstehen sind. 

 

Was ist ein Stream überhaupt?

Ein Stream ist eine Art "verarbeitete Sicht" auf eine Datenquelle. Das kann zum Beispiel eine List sein, ein Array von int oder auch etwas Komplexeres. Wichtig: Ein Stream speichert keine Daten. Er beschreibt nur, wie Daten verarbeitet werden sollen.

Typischerweise machst du drei Dinge:

- Du erstellst einen Stream aus einer Datenquelle.
- Du wendest eine Reihe von Operationen darauf an (Filtern, Umwandeln, Sortieren, ...).
- Du sammelst das Ergebnis wieder ein, zum Beispiel in einer Liste.

Im Code sieht das dann etwa so aus:

List<String> namen = List.of("Anna", "Ben", "Chris", "Alex");

List<String> ergebnis = namen.stream()
    .filter(name -> name.startsWith("A"))
    .sorted()
    .toList();

Leserlich gedacht: "Nimm die Liste, mach einen Stream daraus, filtere alle raus, die nicht mit A anfangen, sortiere sie und pack am Ende alles wieder in eine neue Liste."

 

Zwischenoperationen und Terminaloperationen

Bei Streams wird oft zwischen zwei Arten von Operationen unterschieden:

- Zwischenoperationen: liefern wieder einen Stream (z.B. filter, map, sorted).
- Terminaloperationen: beenden die Stream-Verarbeitung und liefern ein Ergebnis (z.B. toList, collect, count, forEach).

Wichtig: Solange du keine Terminaloperation aufrufst, passiert gar nichts. Das nennt sich "Lazy Evaluation". Der Stream merkt sich nur, was passieren soll. Erst bei der Terminaloperation wird die Pipeline wirklich ausgeführt.

Beispiel:

List<String> namen = List.of("Anna", "Ben", "Chris");

Stream<String> stream = namen.stream()
    .filter(name -> {
        System.out.println("Filter: " + name);
        return name.length() > 3;
    });

// Bis hier wurde noch nichts auf der Konsole ausgegeben

List<String> gefiltert = stream.toList();
// Jetzt wird der Filter wirklich ausgefuehrt

Wenn du das läufst, siehst du: Erst beim toList() wird die Filter-Logik angeworfen.

 

Typische Basisoperationen: filter, map, sorted

Streams werden spannend durch ihre Operationen. Die drei, die du am Anfang am häufigsten siehst, sind filter, map und sorted.

filter lässt nur Elemente durch, die eine Bedingung erfüllen:

List<String> namen = List.of("Anna", "Ben", "Chris", "Alex");

List<String> langeNamen = namen.stream()
    .filter(name -> name.length() >= 4)
    .toList();

map wandelt Elemente um. Aus einem String kannst du zum Beispiel die Länge machen:

List<String> namen = List.of("Anna", "Ben", "Chris");

List<Integer> laengen = namen.stream()
    .map(String::length)
    .toList();

Du bist nicht auf Methodenreferenzen wie String::length festgelegt. Ein Lambda geht genau so gut:

List<Integer> laengen = namen.stream()
    .map(name -> name.length())
    .toList();

sorted sortiert Elemente. Bei Strings und Zahlen funktioniert das direkt, weil sie Comparable sind:

List<String> namen = List.of("Chris", "Anna", "Ben");

List<String> sortiert = namen.stream()
    .sorted()
    .toList();

Wenn du eigene Objekte sortieren willst, brauchst du einen Comparator, den du auch direkt im Stream angeben kannst.

 

Mit eigenen Objekten arbeiten

Streams machen erst richtig Sinn, wenn du mit echten Fachdaten arbeitest. Nimm irgendein einfaches Beispiel, zum Beispiel eine Person-Klasse:

class Person {
    private final String name;
    private final int alter;

    Person(String name, int alter) {
        this.name = name;
        this.alter = alter;
    }

    public String getName() {
        return name;
    }

    public int getAlter() {
        return alter;
    }
}

Jetzt eine Liste von Personen:

List<Person> personen = List.of(
    new Person("Anna", 25),
    new Person("Ben", 17),
    new Person("Chris", 30)
);

Du willst alle volljährigen Personen-Namen als sortierte Liste:

List<String> volljaehrigeNamen = personen.stream()
    .filter(p -> p.getAlter() >= 18)
    .map(Person::getName)
    .sorted()
    .toList();

Gelesen: "Nimm alle Personen, filter die raus, die jünger als 18 sind, wandel jede Person in ihren Namen um, sortiere und pack das Ergebnis in eine Liste."

Genau dieses Muster wirst du immer wieder sehen: filter, map, irgendwas mit sammeln.

 

collect und toList: Ergebnisse einsammeln

In modernen Java-Versionen hast du toList() direkt auf dem Stream. Du wirst aber auch oft collect(...) sehen, vor allem in älterem Code oder bei speziellen Sammlungen.

Beispiel mit collect:

List<String> namen = List.of("Anna", "Ben", "Chris");

List<String> result = namen.stream()
    .filter(name -> name.length() > 3)
    .collect(Collectors.toList());

Beispiel mit toList() (seit Java 16 schön bequem):

List<String> result = namen.stream()
    .filter(name -> name.length() > 3)
    .toList();

Wenn du zum Beispiel in ein Set sammeln willst, brauchst du collect:

Set<String> namenSet = namen.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toSet());

Merke dir: toList ist der einfache Standardfall, collect ist der Werkzeugkasten für alles, was darüber hinausgeht.

 

Primitive Streams: IntStream, LongStream, DoubleStream

Wenn du mit Zahlen arbeitest, wirst du früher oder später IntStream, LongStream oder DoubleStream sehen. Die sind wie normale Streams, aber speziell für primitive Datentypen.

Ein einfaches Beispiel: du willst die Summe der Längen aller Namen berechnen:

List<String> namen = List.of("Anna", "Ben", "Chris");

int summe = namen.stream()
    .mapToInt(String::length)
    .sum();

mapToInt statt map macht hier einen IntStream draus, und der bietet dir sum() direkt an. Ähnliches gilt für Durchschnitte, Min/Max und so weiter.

 

Streams vs. klassische Schleifen

Eine typische Frage: "Soll ich jetzt alles nur noch mit Streams machen?" Die kurze Antwort: nein. Streams sind ein Werkzeug, keine Religion.

Eine klassische for-Schleife ist manchmal völlig ok, zum Beispiel wenn du sehr einfache Logik hast oder stark zustandsbehaftete Abläufe. Streams sind stark, wenn du Daten verarbeitest, die sich gut als Pipeline aus Schritten beschreiben lassen: filtern, transformieren, gruppieren, sortieren.

Vergleich:

// Ohne Stream
List<String> langeNamen = new ArrayList<>();
for (String name : namen) {
    if (name.length() >= 4) {
        langeNamen.add(name.toUpperCase());
    }
}
Collections.sort(langeNamen);
// Mit Stream
List<String> langeNamen = namen.stream()
    .filter(name -> name.length() >= 4)
    .map(String::toUpperCase)
    .sorted()
    .toList();

Der Stream-Code wirkt komprimierter und beschreibt direkter, was du haben willst, statt wie du Schritt für Schritt dahin kommst.

 

Häufige Stolperfallen am Anfang

Ein paar Dinge, in die man zu Beginn gerne reinläuft:

1. Einen Stream nur einmal verwenden

Ein Stream kann nur einmal verarbeitet werden. Wenn du versuchst, denselben Stream zweimal zu nutzen, fliegt eine IllegalStateException.

Stream<String> stream = namen.stream();

List<String> eins = stream.toList();
List<String> zwei = stream.toList(); // knallt

Lösung: Erzeuge bei Bedarf einfach einen neuen Stream aus der Datenquelle.

2. forEach für Logik missbrauchen

forEach ist praktisch, um am Ende zum Beispiel etwas zu loggen oder auszugeben. Für eigentliche Fachlogik, die Daten verändert, ist es meistens nicht die erste Wahl. Besser ist es, die Logik in filter/map und Co. zu packen und am Ende das Ergebnis zu sammeln.

3. Zu lange Pipelines

Wenn deine Stream-Pipeline über sechs, sieben Zeilen geht und du beim Lesen nicht mehr weisst, was rauskommen soll, ist das ein Signal. Dann hilft es oft, einzelne Schritte in gut benannte Methoden auszulagern oder die Pipeline aufzuteilen. Lesbarkeit geht vor "möglichst wenig Zeilen".

 

Wie du Streams sinnvoll lernst

Es passiert gerade am Anfang schnell, dass du Streams kopierst, ohne sie wirklich zu verstehen. Das ist normal, aber du kannst dir das Leben leichter machen, wenn du bewusst übst.

Ein ganz pragmatischer Weg:

- Nimm Code, der mit Schleifen arbeitet, und schreib eine zweite Variante daneben mit Streams.
- Debugge beide Varianten und schau dir an, was in den einzelnen Schritten passiert.
- Achte darauf, dass Ergebnislisten nicht modifiziert werden, sondern neue Listen entstehen.

Wenn du dabei noch bewusst versuchst, den Stream "vorzulesen" (filtert hier, wandelt da um, sortiert hier, sammelt da), bekommst du relativ schnell ein gutes Gefühl dafuer, wie Streams ticken.

 

Fazit

Streams sind kein Hexenwerk, sondern ein ziemlich praktisches Werkzeug, um Datenverarbeitung lesbarer und kürzer auszudrücken. Du musst nicht alles sofort perfekt können und du musst auch nicht jede Schleife durch einen Stream ersetzen.

Wichtig ist, dass du das Grundprinzip verstanden hast: Datenquelle -> Stream -> Pipeline aus Operationen -> Ergebnis einsammeln. Wenn du das drauf hast, kannst du dich nach und nach an fortgeschrittenere Sachen wie groupingBy, partitioningBy oder eigene Collector herantasten.

Solange du beim Lesen einer Stream-Pipeline noch verstehst, was da fachlich passiert, bist du auf einem guten Weg. Alles andere kommt mit der Zeit und mit echter Praxis im Projekt.