Streams

July 8, 20265 min readUpdated 8/20/2026

A stream lets you describe what you want done to a collection instead of writing the loop that does it. It is the single biggest change in how Java is written since Java 8, and the mental model is small: a source, some intermediate operations, and one terminal operation that makes it all run.

The shape

List<String> names = List.of("Ana", "Bo", "Christopher", "Dee");

List<String> result = names.stream()          // source
        .filter(n -> n.length() > 2)          // intermediate — returns another stream
        .map(String::toUpperCase)             // intermediate
        .sorted()                             // intermediate
        .toList();                            // terminal — produces the answer

System.out.println(result);                   // [ANA, CHRISTOPHER, DEE]

The equivalent loop is longer and, more to the point, mixes what with how — you have to read the whole body to discover it is a filter and a transform.

Nothing happens until the terminal operation. Intermediate operations are lazy: they build a description of the work. This is not a detail, it is what makes the next section possible:

List<String> names = List.of("Ana", "Bo", "Christopher");

Optional<String> first = names.stream()
        .filter(n -> n.length() > 2)
        .findFirst();                          // stops as soon as it has one

System.out.println(first.orElse("none"));      // Ana — "Christopher" was never examined

A stream processes each element through the whole pipeline before moving to the next, so findFirst, anyMatch and limit can short-circuit and skip the rest of the collection entirely.

The intermediate operations you will use

List<String> words = List.of("banana", "apple", "cherry", "apple", "date");

System.out.println(words.stream().filter(w -> w.contains("a")).toList());
// [banana, apple, apple, date]

System.out.println(words.stream().map(String::length).toList());
// [6, 5, 6, 5, 4]

System.out.println(words.stream().distinct().toList());
// [banana, apple, cherry, date]

System.out.println(words.stream().sorted().limit(2).toList());
// [apple, apple]

System.out.println(words.stream().skip(3).toList());
// [apple, date]

System.out.println(words.stream()
        .sorted(Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()))
        .toList());
// [date, apple, apple, banana, cherry]

flatMap is the one that looks odd until it clicks. It flattens nested structure — turning a stream of lists into a stream of their contents:

List<List<String>> nested = List.of(
        List.of("a", "b"),
        List.of("c"),
        List.of());

System.out.println(nested.stream().flatMap(List::stream).toList());   // [a, b, c]

List<String> sentences = List.of("hello world", "goodbye moon");
System.out.println(sentences.stream()
        .flatMap(s -> Arrays.stream(s.split(" ")))
        .toList());                                                   // [hello, world, goodbye, moon]

Terminal operations

List<Integer> numbers = List.of(3, 1, 4, 1, 5);

System.out.println(numbers.stream().count());                       // 5
System.out.println(numbers.stream().anyMatch(n -> n > 4));          // true
System.out.println(numbers.stream().allMatch(n -> n > 0));          // true
System.out.println(numbers.stream().noneMatch(n -> n > 10));        // true
System.out.println(numbers.stream().max(Comparator.naturalOrder()).orElse(0));   // 5

System.out.println(numbers.stream().mapToInt(Integer::intValue).sum());          // 14
System.out.println(numbers.stream().mapToInt(Integer::intValue).average().orElse(0)); // 2.8

numbers.stream().limit(2).forEach(System.out::println);             // 3, then 1

Note mapToInt before sum() and average(). A Stream<Integer> has no sum — you convert to an IntStream first, which also avoids boxing every element.

Collectors

collect is the general-purpose terminal operation, and three collectors cover most real use:

record Person(String name, String city, int age) { }

class Demo {
    void run() {
        List<Person> people = List.of(
                new Person("Ana", "Nuku'alofa", 30),
                new Person("Bo", "Seattle", 25),
                new Person("Cy", "Seattle", 35));

        // Group into a Map
        Map<String, List<Person>> byCity =
                people.stream().collect(Collectors.groupingBy(Person::city));
        System.out.println(byCity.get("Seattle").size());        // 2

        // Group and count in one step
        Map<String, Long> countByCity =
                people.stream().collect(Collectors.groupingBy(Person::city, Collectors.counting()));
        System.out.println(countByCity);                          // {Seattle=2, Nuku'alofa=1}
        // (a HashMap — the iteration order is not part of the contract)

        // Build a Map from two fields
        Map<String, Integer> ages =
                people.stream().collect(Collectors.toMap(Person::name, Person::age));
        System.out.println(ages.get("Cy"));                       // 35

        // Join into a String
        System.out.println(people.stream().map(Person::name)
                .collect(Collectors.joining(", ", "[", "]")));    // [Ana, Bo, Cy]
    }
}

One trap in toMap: a duplicate key throws IllegalStateException rather than overwriting. Pass a merge function — toMap(k, v, (a, b) -> b) — when duplicates are possible.

Where a stream comes from

A collection is the usual source, but not the only one:

System.out.println(Stream.of("a", "b", "c").toList());          // straight from values
System.out.println(Arrays.stream(new int[]{1, 2, 3}).sum());    // from an array
System.out.println(IntStream.range(0, 5).boxed().toList());     // [0, 1, 2, 3, 4]
System.out.println(IntStream.rangeClosed(1, 5).sum());          // 15
System.out.println("a,b,c".chars().count());                    // 5 — characters, commas included

System.out.println(Stream.iterate(1, n -> n * 2).limit(5).toList());   // [1, 2, 4, 8, 16]

IntStream.range is the stream equivalent of a counting for loop, and Stream.iterate produces an infinite stream — which is only safe because of laziness. The limit(5) is what makes it terminate; without it the pipeline never ends.

reduce, and when to leave it alone

List<Integer> numbers = List.of(1, 2, 3, 4);

System.out.println(numbers.stream().reduce(0, Integer::sum));           // 10
System.out.println(numbers.stream().reduce(1, (a, b) -> a * b));        // 24

// but for the common cases there is something clearer:
System.out.println(numbers.stream().mapToInt(Integer::intValue).sum()); // 10

reduce is not only for the cases a specialised method already covers. Here it is in the console bank app this site uses for examples, adding up a customer's balances — there is no sum() for BigDecimal, so reduce is the right tool:

public BigDecimal totalBalance(User user) {
    return accountsOf(user).stream()
            .map(Account::balance)
            // reduce folds the list into one value: start at zero, add each balance.
            // BigDecimal::add is a method reference standing in for (a, b) -> a.add(b).
            .reduce(Money.ZERO, BigDecimal::add);
}

The identity matters: Money.ZERO is what comes back for a customer with no accounts, which is correct and needs no special case.

reduce combines every element into one value, starting from an identity. It is the general tool, and the specific ones — sum, max, count, joining — say what they mean more directly. Reach for reduce when nothing more specific exists.

Rules that keep streams honest

Do not mutate anything inside a stream. This is the most common misuse:

List<String> names = List.of("Ana", "Bo");

// Wrong — a side effect masquerading as a pipeline, and it breaks in parallel
List<String> collected = new ArrayList<>();
names.stream().forEach(collected::add);

// Right — let the stream produce the result
List<String> proper = names.stream().toList();
System.out.println(proper);                       // [Ana, Bo]

A stream is single-use. Reusing one throws IllegalStateException: stream has already been operated upon or closed. Create a new one from the source instead.

Be careful with parallelStream(). It is one word and looks free. It is worth it only for genuinely large datasets doing genuinely expensive per-element work — otherwise the coordination costs more than it saves, and any shared mutable state becomes a race. Measure before and after, or do not use it.

When a loop is still better

Streams are not automatically an improvement. Prefer a plain loop when:

  • The body does several unrelated things, or is long.
  • You need to break out on a complicated condition, or need the index.
  • You need to modify the source collection.
  • The stream version needs a comment to explain it.

A four-stage pipeline that reads like a sentence is better than the loop. A pipeline with a nested lambda and a custom collector usually is not.

Next

Most pipelines end in a collector. Collectors is next — grouping, joining, partitioning, and building a map without tripping on duplicate keys.