The Streams API

Reducing & Collecting

15 min Lesson 4 of 13

Reducing & Collecting

The previous lessons showed you how to filter and transform stream elements. Those operations produce a new stream — but at some point you need a concrete answer: a single number, a list, a map. That is what terminal operations are for. This lesson covers the three most essential ones: reduce, count, and collecting results with Collectors.toList() (and its modern replacement).

Why terminal operations matter

A stream pipeline is lazy. The intermediate steps (filter, map, etc.) are not evaluated until a terminal operation is called. reduce and collect are the two most powerful terminal operations: one folds a stream into a single value, the other pours elements into a mutable container such as a List or Map.

count — the simplest terminal operation

count() returns a long — the number of elements that survive the pipeline.

import java.util.List; List<String> names = List.of("Alice", "Bob", "Charlie", "Dave", "Eve"); long shortNames = names.stream() .filter(n -> n.length() <= 3) .count(); // 2 (Bob, Eve) System.out.println(shortNames); // 2

count() is conceptually equivalent to reduce(0L, (acc, e) -> acc + 1) — which brings us to the more general operation.

reduce — folding a stream into one value

reduce applies a binary operator repeatedly until the stream is exhausted. Think of it like a fold over a list: you start with an identity value and combine it with each element one by one.

The most common overload takes an identity and a BinaryOperator<T>:

// Sum all integers List<Integer> numbers = List.of(1, 2, 3, 4, 5); int sum = numbers.stream() .reduce(0, Integer::sum); // identity=0, then 0+1+2+3+4+5 = 15 System.out.println(sum); // 15

The identity value must be a true identity for the operation — adding 0 never changes the result, so 0 is the identity for addition. For multiplication the identity is 1.

You can also write the lambda explicitly to make the mechanics clear:

int product = numbers.stream() .reduce(1, (accumulator, element) -> accumulator * element); System.out.println(product); // 120

reduce without an identity — Optional

When no identity is provided, reduce returns an Optional<T> because the stream might be empty and there would be no meaningful result to return.

import java.util.Optional; Optional<Integer> max = numbers.stream() .reduce((a, b) -> a > b ? a : b); max.ifPresent(v -> System.out.println("Max: " + v)); // Max: 5
When to use the two-argument vs one-argument form: use the two-argument form (with identity) when you always want a concrete result even on an empty stream. Use the one-argument form when an empty result is a legitimate possibility you want to handle explicitly via Optional.

Collecting results with Collectors.toList()

reduce is great for computing a single scalar. But often you want a new collection. That is what collect does — it accumulates stream elements into a mutable container.

The most common collector is one that produces a List:

import java.util.List; import java.util.stream.Collectors; List<String> filtered = names.stream() .filter(n -> n.startsWith("A") || n.startsWith("E")) .collect(Collectors.toList()); System.out.println(filtered); // [Alice, Eve]

Since Java 16 there is a more concise alternative: Stream.toList(). It returns an unmodifiable list, which is usually what you want:

// Java 16+ — preferred in modern code List<String> filtered = names.stream() .filter(n -> n.startsWith("A") || n.startsWith("E")) .toList(); // unmodifiable List
Prefer .toList() over Collectors.toList() in Java 16+ code. The short form is cleaner and signals immutability upfront. Use Collectors.toList() (or Collectors.toUnmodifiableList()) when you need to target Java 11/8 compatibility.

Collecting into other containers

The Collectors class offers many more collectors — you will explore them deeply in Lesson 7. For now, the two most useful after toList() are:

  • Collectors.toSet() — produces a Set, removing duplicates automatically.
  • Collectors.joining(delimiter) — concatenates String stream elements into one String.
import java.util.Set; import java.util.stream.Collectors; // Deduplicate List<Integer> dupes = List.of(1, 2, 2, 3, 3, 3); Set<Integer> unique = dupes.stream().collect(Collectors.toSet()); System.out.println(unique); // [1, 2, 3] (order not guaranteed) // Join strings String csv = names.stream() .collect(Collectors.joining(", ")); System.out.println(csv); // Alice, Bob, Charlie, Dave, Eve

Combining reduce and map — a practical example

A typical real-world pattern maps objects to a numeric property and then reduces to a summary statistic:

record Product(String name, double price) {} List<Product> cart = List.of( new Product("Keyboard", 79.99), new Product("Mouse", 29.99), new Product("Monitor", 349.99) ); double total = cart.stream() .mapToDouble(Product::price) .sum(); // 459.97 System.out.println("Total: $" + total);
Do not use reduce for side effects. The lambda you pass to reduce must be stateless, non-interfering, and associative (so that parallel streams give the same result). Mutating external state inside the lambda is a common bug that silently breaks parallel pipelines.

Summary

count() is the simplest way to count elements after filtering. reduce lets you fold any stream into a single value — use the identity form when an empty stream should return a neutral result, and the Optional form when an empty stream is genuinely possible. collect(Collectors.toList()) — or the modern .toList() — pours a stream back into a concrete collection. These three operations cover the vast majority of stream terminal needs; the next lessons build on them.