On this page
Description
Much of a backend's code is about working with lists of things: keep the paid orders, turn them into totals, group them by city, find the biggest one. Before Java 8 that meant loops with temporary lists and maps. A stream lets you describe the result instead, step by step, almost like a database query:
Map<String, Long> revenueByCity = orders.stream()
.filter(o -> !o.refunded())
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingLong(Order::cents)));
Streams are everywhere in modern Java, and interviewers use them to see how fluently you handle data. They also have rules that differ from loops: when the code actually runs, why a stream works only once, and a few collectors that throw where you'd expect them to just keep going. This skill covers both.
Here's the path, from easiest to most technical:
- The shape of a pipeline: source, steps, and a final operation.
- When a pipeline runs (later than you think), and in what order.
- The everyday steps:
filter,map,flatMap, and streams of numbers. - Collectors: building lists, maps, groups and totals.
- The rules that keep streams correct.
The shape of a pipeline
Every stream pipeline has three parts:
List<String> bigOrderCities = orders.stream() // source
.filter(o -> o.cents() > 10_000) // intermediate: keep some elements
.map(Order::city) // intermediate: turn each into something else
.distinct() // intermediate: drop repeats
.toList(); // terminal: produce the result
Intermediate steps return a new stream, so you can chain them. The terminal operation produces the actual result (a list, a number, a map) and ends the stream.
When a pipeline runs
Three rules surprise people coming from loops:
1. Nothing runs until the terminal operation. Intermediate steps only describe the work. A pipeline with no terminal operation does nothing at all, so a map that prints never prints.
2. Elements go through one at a time. The stream doesn't filter the whole list, then map the whole list. Each element travels through every step before the next element starts. So a pipeline can stop early:
Stream.of(1, 2, 3, 4, 5)
.peek(x -> System.out.println("saw " + x))
.filter(x -> x % 2 == 0)
.findFirst(); // prints "saw 1", "saw 2", then stops: 3, 4 and 5 are never looked at
Operations that can stop early (findFirst, anyMatch, limit) are what make endless streams like Stream.iterate usable. Steps that need to see everything first, like sorted, can't stop early.
3. A stream can be used only once. After the terminal operation, using the same stream again throws IllegalStateException: stream has already been operated upon or closed. To stream twice, call .stream() on the list again.
The everyday steps
filter(predicate)keeps the elements that pass.map(function)turns each element into exactly one new value.flatMap(function)turns each element into a stream and joins them into one. To get words from lines,map(l -> l.split(" "))gives you a stream of arrays;flatMap(l -> Arrays.stream(l.split(" ")))gives a stream of words.sorted(),distinct(),limit(n),skip(n)do what their names say.
For numbers, use the primitive streams IntStream, LongStream and DoubleStream. They avoid boxing and come with sum(), average(), max() and summaryStatistics():
long total = orders.stream().mapToLong(Order::cents).sum();
int[] scores = {3, 1, 2};
Arrays.stream(scores).max(); // OptionalInt[3]
Stream.of(scores).count(); // 1 (!): the whole int[] is a single element
That last line is a classic trap: Stream.of can't hold a plain int, so the whole array becomes one element. Use Arrays.stream(array) or IntStream.of(...).
reduce folds all elements into one value, such as reduce(0, Integer::sum). The first argument must be a true starting point for the operation (0 for addition, 1 for multiplication). Usually a dedicated terminal like sum(), count() or max() is clearer.
Collectors: building lists, maps and groups
collect(...) takes a collector, which describes what to build. The ones you'll use most:
| You want | Collector | Watch out for |
|---|---|---|
| A list | toList() (Java 16) or Collectors.toList() | toList() is read-only |
| One value per key | Collectors.toMap(key, value) | throws on a duplicate key unless you pass a merge function |
| Elements grouped by key | Collectors.groupingBy(key) | the map's order is random unless you ask for one |
| Counts or totals per key | groupingBy(key, counting()), summingLong(...) | counts come back as Long |
| Two groups: yes and no | Collectors.partitioningBy(predicate) | always has both true and false keys |
| One string | Collectors.joining(", ") |
Three of these deserve a closer look.
toMap refuses duplicate keys. A revenue report built with toMap(Order::city, Order::cents) passed every test (each test had one order per city), then failed in production with IllegalStateException: Duplicate key Seattle. toMap doesn't add up, overwrite or keep the first value; it throws. Tell it what to do with a third argument:
Map<String, Long> revenue = orders.stream()
.collect(Collectors.toMap(Order::city, Order::cents, Long::sum)); // add the values up
groupingBy has no order. By default it builds a HashMap, so the cities come out in an order that's neither the order you added them nor alphabetical. If the order matters, say which map to build:
Map<String, Long> ordersPerCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.counting()));
partitioningBy always has both sides. Even if no order is big, the result still has a true key mapped to an empty list.
The rules that keep streams correct
- Don't change outside state from inside a stream.
forEach(list::add)or a counter updated inmapbreaks as soon as the stream runs in parallel. Let the collector build the result. peekis for debugging only. In some pipelines Java skips steps it doesn't need (for example,count()on a list may not run them at all), so side effects inpeekormapmight never happen.toList()is read-only. If you need to add to the result, useCollectors.toCollection(ArrayList::new).
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| No terminal operation | nothing runs | end with collect, toList, count... |
| Using a stream twice | IllegalStateException | call .stream() again |
toMap on data with repeated keys | IllegalStateException: Duplicate key | pass a merge function |
Relying on groupingBy's order | random order | pass TreeMap::new or LinkedHashMap::new |
Stream.of(intArray) | a stream with one element | Arrays.stream(intArray) |
map where flatMap was meant | a stream of lists or arrays | flatMap |
Adding to a list from forEach | wrong in parallel | collect or toList() |
Adding to the result of toList() | UnsupportedOperationException | Collectors.toCollection(ArrayList::new) |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. A pipeline is peek(print), then filter(even), then findFirst() over 1 to 5. What does peek print?
Show answer
Only 1 and 2. Elements go through the pipeline one at a time, and findFirst stops as soon as 2 passes the filter.
2. What does toMap do when two elements have the same key?
Show answer
It throws IllegalStateException: Duplicate key ..., unless you pass a merge function as the third argument to say how to combine the two values.
3. Why is Stream.of(new int[]{3, 1, 2}).count() equal to 1?
Show answer
Stream.of can't hold primitives, so the whole int[] becomes the single element. Arrays.stream(array) gives an IntStream of three numbers.
4. A report groups orders by city with groupingBy(Order::city), and the cities come out in a strange order. How do you get them alphabetically?
Show answer
Pass a map supplier: groupingBy(Order::city, TreeMap::new, downstreamCollector). The default builds a HashMap, which has no useful order.
Key takeaways
- A pipeline is source, intermediate steps, terminal operation. Nothing runs before the terminal operation.
- Elements flow one at a time, so pipelines can stop early; streams are single-use.
flatMapflattens; primitive streams avoid boxing; useArrays.streamfor arrays of numbers.toMapneeds a merge function for repeated keys,groupingByneeds a map supplier for order, andpartitioningByalways has both keys.- Build results with collectors, never by changing outside state.