Java Stream API Examples, and Four Ways They Surprise You
A stream is a source, some intermediate operations, and one terminal operation that makes all of it run. The examples below are complete and show their real output — filter/map/collect, groupingBy, flatMap, toMap with a merge function. Then four surprises: a stream cannot be reused, a pipeline with no terminal operation does nothing, peek can be skipped, and toMap throws where put overwrites.
Stream examples are easy to find and usually incomplete — a filter and a map over a list of strings, with the output implied. This article uses one small dataset for every example, shows what each one actually printed, and then spends its second half on the four behaviours that turn a working pipeline into a bug that does not look like a stream bug.
Everything here ran on JDK 21. If you want to follow along you need a JDK — installing Java 21 covers that.
The shape of every pipeline
Three parts, and the third is the one that matters:
- a source — a collection, a range,
Stream.of - any number of intermediate operations —
filter,map,sorted; each returns a new stream and does nothing yet - exactly one terminal operation —
collect,count,forEach; this is what makes the whole thing run, and the package documentation is explicit that nothing happens before it
The data for everything below:
record Order(String customer, String region, int amount) {}
static final List<Order> ORDERS = List.of(
new Order("alice", "north", 120), new Order("bob", "south", 80),
new Order("alice", "north", 200), new Order("carol", "south", 45),
new Order("bob", "north", 310));
filter, map, collect
ORDERS.stream()
.filter(o -> o.amount() > 100)
.map(Order::customer)
.distinct()
.sorted()
.collect(Collectors.toList());
[alice, bob]
Order::customer is a method reference and reads better than o -> o.customer() once you are used to it. Note the order of distinct() and sorted() — both are intermediate, and putting distinct first means sorted has less to sort. On five elements that is irrelevant; on a million it is not.
Modern Java offers .toList() as a shorter terminal operation than collect(Collectors.toList()). The examples here use the longer form because it works on every version that has streams at all.
Grouping, which is where streams start paying
groupingBy on its own gives you a list per key:
ORDERS.stream().collect(Collectors.groupingBy(Order::region)).keySet();
[south, north]
The useful form takes a downstream collector — something to do with each group rather than just collecting it:
ORDERS.stream().collect(
Collectors.groupingBy(Order::region, Collectors.summingInt(Order::amount)));
{south=125, north=630}
That is a GROUP BY region, SUM(amount) in one expression. The loop it replaces needs a map, a null check or computeIfAbsent, and an accumulator — about six lines where this is one, and the stream version says what it is doing rather than how.
Tip
summingIntis one of many downstream collectors.counting(),averagingDouble(),mapping()andtoSet()all compose the same way, and nesting a secondgroupingByas the downstream gives you a two-level grouping for free.
Summing three ways, and the primitive shortcut
ORDERS.stream().map(Order::amount).reduce(0, Integer::sum); // 755
ORDERS.stream().mapToInt(Order::amount).sum(); // 755
ORDERS.stream().mapToInt(Order::amount).summaryStatistics();
IntSummaryStatistics{count=5, sum=755, min=45, average=151.000000, max=310}
All three agree. reduce is the general tool and works for anything associative; mapToInt drops into IntStream and avoids boxing every amount into an Integer on the way. And summaryStatistics() gives five answers in a single pass, which is worth remembering before you write three separate streams over the same list.
flatMap and toMap, the two people get stuck on
flatMap turns a stream of collections into a stream of their contents:
List<List<String>> nested = List.of(List.of("a","b"), List.of("c"), List.of());
nested.stream().flatMap(List::stream).collect(Collectors.joining(","));
a,b,c
The empty inner list contributes nothing at all — not a blank entry, not a null. That is the behaviour you want and it is worth seeing rather than assuming.
toMap is where people reach for the two-argument version and get hurt. The four-argument form is the one to learn:
ORDERS.stream().collect(Collectors.toMap(
Order::customer, // key
Order::amount, // value
Integer::sum, // what to do when two orders share a customer
TreeMap::new)); // which map implementation
{alice=320, bob=390, carol=45}
Summed per customer and sorted by key, because TreeMap was asked for. Which brings us to the second half.
A stream is not a collection
Three behaviours follow from that sentence, and all three were provoked rather than described.
It cannot be reused.
Stream<String> s = Stream.of("a","b","c");
s.count(); // 3
s.count(); // throws
IllegalStateException: stream has already been operated upon or closed
A terminal operation consumes the stream. Keep the collection and make a new stream each time; a stream is a single pass, not a container.
It is lazy — completely.
Stream.of(1,2,3).peek(x -> System.out.println("peeked " + x));
That line prints nothing. No terminal operation, no work, not even the peek. Intermediate operations only build a description of what to do.
And peek is not a logging hook. The same pipeline, terminated two different ways:
// prints nothing — count() on a sized stream never needs the elements
Stream.of(1,2,3).peek(print).count();
// prints all three
Stream.of(1,2,3).peek(print).collect(Collectors.toList());
count() can work out the answer from the stream's size without traversing it, so it skips everything upstream that has no other effect. If you have ever added a peek to debug a pipeline and seen no output, that is why — and it is also why peek should not be the place you do anything that matters.
toMap throws where put overwrites
The two-argument toMap has no answer for a duplicate key, so it refuses:
Stream.of("apple","avocado")
.collect(Collectors.toMap(x -> x.charAt(0), x -> x));
IllegalStateException: Duplicate key a (attempted merging values apple and avocado)
This surprises people because Map.put does the opposite — it overwrites silently. The collector's default is the better one: a collision usually means the key was not unique after all, and finding that out with an exception naming both values beats finding out from a wrong total three weeks later.
The three-argument form is the fix rather than a workaround. Passing Integer::sum, or (a, b) -> a, or (a, b) -> b states what you meant.
parallel(), and why it is rarely the answer
IntStream.range(0, 1000).parallel().forEach(i -> record(Thread.currentThread().getName()));
Measured on an eight-core machine, the work landed on eight threads:
ForkJoinPool.commonPool-worker-1 … -7, and main
Two things in that. The pool contributes one fewer worker than there are cores, and the calling thread joins in — parallel() does not hand work off and return, it participates until the terminal operation finishes.
More importantly, that is the common pool, shared by every parallel stream and every CompletableFuture in the JVM that has not been given its own executor. One slow parallel stream over a blocking call occupies workers that everything else is also waiting for.
So parallel() pays when the work is CPU-bound, large enough that coordination is noise, and over a source that splits cheaply — an array or a range. It costs more than it saves on small collections, and it is the wrong tool entirely for anything doing I/O, where the threads sit blocked. If you want a mental model for why more threads is not automatically more throughput, the difference between parallelism and concurrency in Python is the same argument from the other side.
Common mistake
Adding
.parallel()to a pipeline that was slow for a different reason. It is a one-word change that looks like a free optimisation and can make a service slower under load, because the cost lands on a pool the rest of the application is sharing.
Collect the result and return it — as JSON from a controller, or into whatever the caller needs. The stream ends at the terminal operation, and what comes out is an ordinary collection again.
Frequently asked questions
- Why does my stream throw "stream has already been operated upon or closed"?
- Because you used it twice. A stream is not a collection — it is a single pass over one, and a terminal operation consumes it. Store the source collection and create a fresh stream each time, or collect once into a list and reuse that.
- Why is my peek() not printing anything?
- Two possible reasons, and both were measured. If the pipeline has no terminal operation, nothing runs at all. If the terminal operation is count() on a sized stream, the elements never need to be traversed, so peek is skipped — the same pipeline terminated with collect() printed all of them.
- Why does Collectors.toMap throw on a duplicate key?
- Because a silent overwrite is usually a bug and the collector refuses to guess. Map.put overwrites; toMap throws IllegalStateException naming the key and both values. The fix is the three-argument form with a merge function, which says explicitly what a collision should do.
- Should I use parallel streams to make things faster?
- Usually no. Measured on an eight-core machine, a parallel stream used seven ForkJoinPool workers plus the calling thread — and that pool is shared with everything else in the JVM. It pays for large CPU-bound work over a cheaply splittable source, and costs more than it saves for small collections or anything doing I/O.
- Is reduce or mapToInt better for summing?
- mapToInt, when the values are primitives. Both gave 755 on the sample data, but mapToInt avoids boxing every element into an Integer, and summaryStatistics() gives you count, sum, min, average and max in the same single pass.