Java · Java 8 Features

Java 8: Streams and Collectors

How a stream pipeline runs - lazily, one element at a time, once - map and flatMap, primitive streams, reduce, and the collectors whose defaults surprise: toMap, groupingBy and partitioningBy.

  • 8 min read
  • 13 practice questions
  • 1 graded problem
On this page
  1. Description
  2. The shape of a pipeline
  3. When a pipeline runs
  4. The everyday steps
  5. Collectors: building lists, maps and groups
  6. The rules that keep streams correct
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

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:

  1. The shape of a pipeline: source, steps, and a final operation.
  2. When a pipeline runs (later than you think), and in what order.
  3. The everyday steps: filter, map, flatMap, and streams of numbers.
  4. Collectors: building lists, maps, groups and totals.
  5. The rules that keep streams correct.

The shape of a pipeline

Every stream pipeline has three parts:

Source
a list, an array, a range

Intermediate steps
filter, map, sorted...

Terminal operation
collect, count, sum, findFirst...

Source
a list, an array, a range

Intermediate steps
filter, map, sorted...

Terminal operation
collect, count, sum, findFirst...

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 wantCollectorWatch out for
A listtoList() (Java 16) or Collectors.toList()toList() is read-only
One value per keyCollectors.toMap(key, value)throws on a duplicate key unless you pass a merge function
Elements grouped by keyCollectors.groupingBy(key)the map's order is random unless you ask for one
Counts or totals per keygroupingBy(key, counting()), summingLong(...)counts come back as Long
Two groups: yes and noCollectors.partitioningBy(predicate)always has both true and false keys
One stringCollectors.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 in map breaks as soon as the stream runs in parallel. Let the collector build the result.
  • peek is 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 in peek or map might never happen.
  • toList() is read-only. If you need to add to the result, use Collectors.toCollection(ArrayList::new).

Common mistakes

MistakeWhat happensDo this instead
No terminal operationnothing runsend with collect, toList, count...
Using a stream twiceIllegalStateExceptioncall .stream() again
toMap on data with repeated keysIllegalStateException: Duplicate keypass a merge function
Relying on groupingBy's orderrandom orderpass TreeMap::new or LinkedHashMap::new
Stream.of(intArray)a stream with one elementArrays.stream(intArray)
map where flatMap was meanta stream of lists or arraysflatMap
Adding to a list from forEachwrong in parallelcollect or toList()
Adding to the result of toList()UnsupportedOperationExceptionCollectors.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.
  • flatMap flattens; primitive streams avoid boxing; use Arrays.stream for arrays of numbers.
  • toMap needs a merge function for repeated keys, groupingBy needs a map supplier for order, and partitioningBy always has both keys.
  • Build results with collectors, never by changing outside state.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pipeline rules

  • Source → intermediate steps (lazy) → one terminal operation (runs everything).
  • Elements flow one at a time; findFirst, anyMatch, limit stop early; sorted buffers everything.
  • A stream is single-use: stream the source again.
  • No side effects in map/filter/peek; build results with collectors.

Everyday operations

orders.stream().filter(o -> !o.refunded()).map(Order::city).distinct().sorted().toList();
lines.stream().flatMap(l -> Arrays.stream(l.split(" ")));          // flatten
orders.stream().mapToLong(Order::cents).sum();                     // primitive stream, no boxing
Arrays.stream(intArray).max();                                     // NOT Stream.of(intArray)

Collectors

toMap(Order::city, Order::cents, Long::sum)                         // merge function for repeated keys
groupingBy(Order::city, TreeMap::new, counting())                   // ordered groups, Long counts
groupingBy(Order::city, summingLong(Order::cents))                  // totals per key
groupingBy(Order::city, mapping(Order::id, toList()))               // values transformed
partitioningBy(o -> o.cents() > 10_000)                             // always true and false keys
joining(", ", "[", "]")
toCollection(ArrayList::new)                                        // a mutable list

The traps

  • No terminal operation: nothing runs.
  • Reusing a stream: IllegalStateException.
  • toMap with duplicate keys: IllegalStateException; with a null value: NullPointerException.
  • groupingBy order is random without a map supplier.
  • Stream.of(intArray) has one element.
  • Stream.toList() is unmodifiable.
  • reduce with a non-identity seed is wrong in parallel.

Staff-level points, one line each

  • Stages are linked lazily; the terminal builds a sink chain and the spliterator pushes elements through it.
  • Characteristics let streams skip work: SIZED → count() without running the pipeline (Java 9+), SORTED → no-op sorted(), DISTINCT → no-op distinct().
  • A Collector = supplier, accumulator, combiner (parallel only), finisher, characteristics.
  • groupingByConcurrent shares one ConcurrentHashMap instead of merging per-thread maps.
  • Three-argument reduce: the combiner is never called sequentially; identity and associativity matter only in parallel.
  • Since 8: takeWhile/dropWhile, bounded iterate (9), teeing (12), toList and mapMulti (16).
  • Streams allocate and can go megamorphic: loops often win for small, simple work. Measure with JMH.

Say it in an interview

  • "toMap throws on duplicate keys, so I'd pass Long::sum as the merge function."
  • "groupingBy returns a HashMap; for a sorted report I pass TreeMap::new."
  • "I wouldn't put the audit call in peek: count() can skip the pipeline entirely."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java 8: Streams and Collectors free

A Revenue Report

MediumProCoding problemJava

A marketplace in Seattle needs a daily revenue report from its orders. Each order has an id, a city, a category, an amount in cents, and whether it was refunded. Write three methods in Report, using streams and collectors:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java 8: Streams and Collectors free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime