Advanced
Functional Java & the Streams API
A stream is a lazy pipeline over a sequence. filter, map and reduce are composable steps that don't run until a terminal operation pulls them, which separates what to compute from how to iterate. Sorting, flatMap and concat reshape a stream, and reduce, collectors (groupingBy, partitioningBy, toMap) and primitive streams (sum, average) finish it. Optional makes "maybe absent" a type instead of null, and parallel streams trade safety for cores. Gatherers (JDK 24) add intermediate operations of your own, such as windows and running totals. Every behavior is shown with verified output, including a real data race.
Suggest an editFunctional Java & the Streams API — Pipelines Over Data
Lambdas turned behavior into a value. The Streams API builds on that to let you transform sequences declaratively.
- A stream is a pipeline of operations over a source, such as
filter,mapandreducechained together. It describes what to compute, and leaves how to iterate up to the library. - Pipelines are lazy. Nothing runs until a terminal operation pulls the data through, so the work is fused into a single pass, and can stop early.
Optionalturns "maybe absent" from anullwaiting to crash into a type you're forced to handle.- Parallel streams split the work across processor cores. They're fast when the operations are stateless, and wrong when they share mutable state.
💡 The core idea.
- A stream is a pipeline (
filter/map/reduce) describing what to compute, not how to iterate. - Pipelines are lazy — nothing runs until a terminal operation pulls the data — and they compose.
Optionalturns "maybe absent" into a type you must handle.- Parallel streams split work across cores — fast when stateless, dangerous when sharing mutable state.
This lesson builds on generics and lambdas. Every output below was produced by compiling and running the code.
You'll be able to: write a pipeline that filters, maps, sorts, flattens and collects, and predict its result; choose reduce, a collector or a primitive stream for an aggregate, and name the error you get when a key repeats in toMap; window and scan a stream with a gatherer; trace which elements a lazy pipeline touches, and predict when peek prints nothing; consume an Optional without get(), and predict when orElse evaluates its argument; explain why a parallel stream that writes shared state gives a wrong count, and rewrite it as a reduction.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the compiler and the JVM do.
- A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
- A stream pipeline
- Sorting, flattening and joining streams
reduce, collectors and primitive streams- Lazy evaluation
Optional- Parallel streams and their hazards
- Mental-model summary
- Gotcha checklist
- Check yourself
- Sources
1. A stream pipeline
Calling collection.stream() starts a pipeline, which has three parts [1]:
- A source supplies the elements: a collection, an array, or a generator.
- Intermediate operations (
filter,map) each return a new stream, so you can chain them. - A terminal operation (
collect) ends the pipeline and produces a result.
Output:
[LINUS, GRACE, BJARNE]Analysis. The pipeline has three steps:
filterkept the names longer than 3 characters, dropping"Ada".mapupper-cased each name that was left.collect(toList())gathered them into aList.
The chain reads like a description: keep the long names, upper-case them, collect them. There's no loop, no index and no intermediate variable. Each lambda or method reference supplies the behavior for one step.
Since Java 16, a stream also has toList() as a shorter terminal operation [2]. The two differ in one way that can bite you:
Stream.toList()returns an unmodifiable list.Collectors.toList()makes "no guarantees on the type, mutability, serializability, or thread-safety" of its list [3].
When you need to add to the result, ask for a specific list type by name, using Collectors.toCollection(ArrayList::new):
Output (prints the first line, then a thrown exception):
toCollection(ArrayList::new): [3, 1, 2, 4]
Exception in thread "main" java.lang.UnsupportedOperationExceptionIntuition.
Mechanism. stream() produces a stream object. Each intermediate operation wraps it in another stream that records the operation, but doesn't do anything yet. Only the terminal collect drives elements through the whole chain. The pipeline expresses the what, and the library owns the how (the iteration).
Concrete bite. A stream is single-use, and it isn't a collection. The API says a stream "should be operated on (invoking an intermediate or terminal stream operation) only once" [2]. Use it a second time, and it throws:
Output (prints 2, then a thrown exception):
2
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closedThe first count() consumed the stream. A stream is a view of a computation, not data you can come back to. For a fresh pipeline, call stream() on the list again.
💡 Earned rule. Use a stream pipeline when you transform or query a sequence (filter/map/collect). Stick with an explicit loop for side-effecting iteration, or whenever a plain for is clearer.
The cost is learning a new mental model, plus the fact that streams are one-shot. The payoff is declarative, composable data processing that reads as intent, and that the library is free to optimize.
2. Sorting, flattening and joining streams
Three more intermediate operations reshape a stream, rather than filtering or transforming single elements [2]:
sorted()puts the elements in natural order, whilesorted(comparator)uses theComparatoryou pass in.flatMap(f)replaces each element with the elements of the stream thatfreturns, so nested lists become one flat stream.Stream.concat(a, b)is a static method that joins two streams: all ofa, then all ofb.
Comparator.comparing(key) builds a Comparator from a function that extracts a sort key. For example, Comparator.comparing(String::length) orders strings by their length [4].
Output:
[Ada, Bo, Grace, Linus]
[Bo, Ada, Grace, Linus]
[Linus, Grace]
[Grace, Ada, Linus, Bo]Analysis. Three sorts, and one untouched source:
sorted()usedString's natural order.sorted(Comparator.comparing(String::length))ordered by length.GraceandLinusboth have 5 letters, and they kept their original order, because this sort is stable for an ordered stream [2].limit(2)kept the first two elements of the reverse-sorted stream.- The last line printed
namesitself, unchanged. A stream reads its source, but never reorders it.
flatMap is how a stream takes nested data apart. map produces exactly one output element per input element, so a stream of lists stays a stream of lists. flatMap, on the other hand, produces zero or more per input:
Output:
map: [[1, 2], [3], []]
flatMap: [1, 2, 3]
concat: [a, b, c]Intuition.
Mechanism. flatMap(List::stream) turned each inner list into a stream, and poured all those streams into one. The empty list contributed zero elements, so it simply vanished. Stream.of(…) makes a stream from the values you list, and concat ran one stream after the other.
Concrete bite. The map line is the non-example: it printed [[1, 2], [3], []], still three lists. Asking map to flatten does nothing, because map never changes the number of elements.
💡 Earned rule. Sort inside a pipeline with sorted(Comparator.comparing(key)). Flatten nested data with flatMap, not map. Join two streams with Stream.concat.
The cost: sorted must see every element before it passes on the first one, as §4 shows. The payoff is that ordering and flattening become steps in the pipeline, instead of loops wrapped around it.
3. reduce, collectors and primitive streams
Terminal operations turn a stream into a result. reduce folds the elements into a single value using a combining function. Collectors gather them into structures: a List, a joined String, a grouping Map.
Output:
15
1, 2, 3, 4, 5
odd=[1, 3, 5]
even=[2, 4]Analysis. Three terminals over the same five numbers:
reduce(0, Integer::sum)folded1..5into15. It started at0, and combined each element usingsum.Collectors.joining(", ")produced"1, 2, 3, 4, 5".Collectors.groupingBy(...)built aMapfrom each element's group key ("odd"/"even") to the list of elements in that group. It's the stream form of the counting and grouping idioms, packed into one expression.
Intuition.
Mechanism. reduce(identity, accumulator) applies the accumulator over and over, starting from the identity. The API requires that "the identity value must be an identity for the accumulator function" [2]: 0 for a sum, "" for concatenation. A Collector, meanwhile, is a recipe for gathering elements into a container. groupingBy, toList, joining, counting and partitioningBy cover most needs [3].
Concrete bite. Seed a sum with 1 — the identity for multiplication — and every result is off by one:
Output:
15
16The seed is part of the computation — the same lesson as the accumulator-seed trap.
Partitioning is grouping by a yes/no test. partitioningBy(test) always returns a map with exactly two keys, false and true. You can pass a second collector, such as counting(), to apply inside each group:
Output:
{false=[1, 2], true=[3, 4, 5]}
{false=2, true=3}Non-example: toMap with a repeated key. Collectors.toMap(keyFn, valueFn) builds one entry per element. When two elements produce the same key, it throws [3]:
Output (a thrown exception):
Exception in thread "main" java.lang.IllegalStateException: Duplicate key A (attempted merging values Ada and Al)"Ada" and "Al" both map to 'A'. When a key can repeat, use groupingBy instead, or pass toMap a third argument that merges the two values.
Primitive streams. A Stream<Integer> holds boxed Integer objects. IntStream, LongStream and DoubleStream hold primitive values instead, and add arithmetic terminals such as sum() and average() [5]. mapToInt turns a stream of objects into an IntStream:
Output:
13
OptionalDouble[4.333333333333333]
OptionalDouble.empty
0.0The sum() of no elements is 0, but an average of no elements has no value at all. That's why average() returns an OptionalDouble, the primitive cousin of the Optional you'll meet in §5.
💡 Earned rule. Use reduce for a single folded value with a neutral identity. Use Collectors for structured results: toList, joining, groupingBy, partitioningBy. Use toMap only when the keys are unique. And use mapToInt and friends for arithmetic.
The cost is learning the collector vocabulary. The payoff is that aggregation, grouping and joining — loops you'd otherwise write by hand — each become one declarative line.
Gatherers: intermediate operations of your own. A collector lets you write your own terminal step. Since JDK 24, a gatherer does the same for an intermediate step [8]. You pass one to Stream.gather, and the class Gatherers already has the common ones, "such as windowing functions, folding functions" [10]:
Output:
[[1, 2, 3], [4, 5, 6], [7]]
[[1, 2], [2, 3], [3, 4]]
[1, 3, 6, 10]Analysis. windowFixed(3) grouped the elements into lists of three, and the last window held what was left. windowSliding(2) made overlapping pairs. scan emitted a running total. None of these fit map or filter, because each output depends on earlier elements, so each step needs state.
That's what you write when you build your own gatherer. This one drops an element that repeats the one just before it:
Output:
[a, b, a, c]
[x, y]Analysis. ofSequential took two parts [9]. Last::new creates fresh state for each pipeline. The lambda, the integrator, runs once per element: it gets the state, the element, and the downstream (the next step). Its boolean result says whether it wants more elements. push returns false once the downstream wants no more, so after limit(2) was full, the integrator returned false, and the stream stopped pulling elements.
4. Lazy evaluation
Intermediate operations are lazy: they process nothing until a terminal operation runs [1]. Once it does, elements flow through one at a time. That lets the pipeline short-circuit — stop as soon as the answer is known.
Output:
peek 1
peek 2
found: 2Analysis. peek prints each element as it flows by, and the output stops at peek 2. The pipeline never even looked at 3, 4 or 5. findFirst needed only the first even number, so once filter let 2 through, the whole pipeline stopped. A loop that first built a filtered list would have visited all five.
Intuition. Mechanism. The terminal operation pulls elements through the pipeline one at a time, from top to bottom:
peek(1), thenfilterrejects1.peek(2), thenfilteraccepts2, andfindFirstis satisfied and halts.
Stateless steps such as filter and map remember nothing between elements. A short-circuiting terminal (findFirst, anyMatch) or intermediate operation (limit) stops early.
Concrete bite. The peek output is the proof: only 1 and 2 were processed. This is also why a stream can have an infinite source. Stream.iterate(1, n -> n * 2) generates 1, 2, 4, 8, … forever, yet a short-circuiting step still lets it finish:
Output:
1024
[1, 2, 4, 8, 16]Non-example: a stateful step breaks the one-at-a-time flow. sorted is stateful: it can't pass on the smallest element until it has seen them all [1]. Put it before findFirst, and every element gets pulled:
Output:
peek 5
peek 3
peek 4
peek 1
peek 2
found: 2All five elements were peeked, because sorted needed every one of them before filter could see any.
Non-example: peek as logic. Laziness also means that a side effect inside a pipeline might never run:
Output:
no terminal operation, so no peek lines
count = 3- The first pipeline had no terminal operation, so nothing ran at all.
- The second one did have a terminal operation,
count(), and still printed nopeekline. The list knows its own size, and the API letscount()skip the pipeline "if it is capable of computing the count directly from the stream source" [2].
The API says peek "exists mainly to support debugging" [2]. Never put work there that has to happen.
💡 Earned rule. Lean on laziness for efficiency. Put cheap, selective filters early, and end with a short-circuiting terminal (findFirst, anyMatch) or a limit, so the pipeline stops as soon as possible.
The cost: side effects inside a pipeline run in a non-obvious order, only as far as needed, and sometimes not at all. So peek is for debugging, not logic. And a stateful step such as sorted reads the whole source. The payoff is that only the necessary work runs.
5. Optional
A stream query that might find nothing returns an Optional<T>: a container that either holds a value or is empty. It turns "absent" into a type, so you have to handle the missing case, instead of risking a NullPointerException.
Output:
true
Linus
noneAnalysis. The first query found "Linus", so found was present, and orElse returned the value. The second query matched nothing, so missing was empty, and orElse("none") supplied the fallback. The type Optional<String> made the possibility of absence visible right in the signature. You can't use a missing value by accident, the way you can with null.
Intuition.
Mechanism. Optional wraps the idea of "a value, or nothing" [6]:
orElseandorElseGetsupply a default.maptransforms the value only if one is present, andfilterkeeps it only if it passes a test.ifPresentruns an action only if a value is present.
None of them ever exposes a raw null.
Concrete bite. Calling get() on an empty Optional defeats the whole purpose, and throws:
Output (a thrown exception):
Exception in thread "main" java.util.NoSuchElementException: No value presentempty.get() is the Optional version of dereferencing null: it asserts that a value is present when it isn't. Instead of a bare get(), use orElse, orElseThrow(...) with a meaningful exception, or ifPresent.
Edge: orElse always evaluates its argument. orElse(x) is an ordinary method call, so Java computes x before making the call — even when the Optional already has a value. orElseGet(supplier), by contrast, calls the supplier only when the Optional is empty [6]:
Output:
orElse:
fallback() ran
Linus
orElseGet:
Linus
5
0fallback() ran for orElse, even though its result was thrown away. So use orElseGet when the default is costly to compute. The last two lines show map on a present value (5) and on an empty one (the fallback 0).
💡 Earned rule. Return Optional<T> from methods that may find nothing, and consume it with orElse/orElseGet/map/ifPresent — never a naked get().
The cost is the wrapping and unwrapping. Also, Optional is meant "primarily … for use as a method return type" [6], not for fields or parameters. The payoff: "this might be absent" is visible right at the boundary, so a whole class of run-time NullPointerExceptions turns into handled cases.
6. Parallel streams and their hazards
A thread is one independent path of execution through a program. A program can run several threads at once, and on a machine with several processor cores, they genuinely run side by side. The concurrency lessons cover threads in depth.
parallelStream() (or .parallel()) splits a stream's work across several threads [1]. It can speed up stateless operations on large data. But it's a trap when the pipeline touches shared mutable state, because the threads race each other.
Output:
500000500000Analysis. The parallel sum over one million numbers gave the correct 500000500000, on every run. sum is a proper reduction: each thread sums its own chunk, and the chunks combine associatively, with no shared state. This is the safe way to parallelize: a stateless pipeline that ends in a reduction or a collector.
Intuition.
Mechanism. A parallel stream splits the source into chunks, processes them on a pool of threads, and then combines the partial results. That's correct as long as the operations are stateless and the combination is associative [1]. sum, map, filter and collect all qualify.
Concrete bite. Touch shared mutable state, and the threads corrupt it. This is called a data race, and it produces no error — only wrong answers.
First, a word on why the example below counts in an int[]. A lambda may use a local variable only if it's effectively final [7], so the obvious total += n doesn't compile:
Compiler error:
Main.java:6: error: local variables referenced from a lambda expression must be final or effectively final
List.of(1, 2, 3).stream().forEach(n -> total += n);
^
1 errorHere, the compiler is protecting you. An array slot isn't a local variable, though, so counter[0]++ slips past the rule — and the race below is the result:
Output (illustrative — the value changes every run and is almost never 1000000; three runs on JDK 25 printed 124149, 123649, 102111):
124149Many threads ran counter[0]++ at the same time, and each ++ is really three steps: read, add one, write.
- Two threads read the same value, both write value + 1, and one update is lost.
- The count is wrong, and different on every run.
- There's no exception, only silent corruption.
The fix is to let the stream do the counting itself, as a reduction. Same scenario, same million elements:
Output:
1000000
500000💡 Earned rule. Parallelize only stateless pipelines that end in a reduction or collector, and only on data large enough to outweigh the cost of splitting it up. Never let a parallel stream write to shared mutable state.
The cost is that parallelism is correct only under those conditions, and it can actually be slower on small data. The payoff, when the conditions hold, is a multi-core speedup from a one-word change: stream() becomes parallelStream().
7. Mental-model summary
| Principle | Consequence |
|---|---|
| A stream is a lazy pipeline: intermediate ops chain, a terminal op runs it | Describe what to compute; the library owns the iteration; streams are one-shot |
Stream.toList() is unmodifiable; Collectors.toList() guarantees nothing |
Collect with toCollection(ArrayList::new) when you must add to the result |
sorted, flatMap and Stream.concat reshape a stream |
map never flattens; a stream never changes its source |
reduce folds to a value; Collectors build structures |
The reduce identity must be neutral; toMap throws on a repeated key |
IntStream and friends hold primitives |
sum(), and average() as an OptionalDouble |
| A gatherer is a custom intermediate operation (JDK 24) | gather(Gatherers.windowFixed(n)); build one with Gatherer.ofSequential when a step needs state |
| Laziness + short-circuiting process only what's needed | findFirst/limit/anyMatch stop early; infinite sources work; sorted reads everything |
| A side effect in a pipeline may not run | No terminal, no work; count() may skip the pipeline; peek is for debugging |
Optional<T> makes "absent" a type |
Consume with orElse/orElseGet/map/ifPresent; a bare get() on empty throws |
| Parallel streams need stateless ops and a reduction | Shared mutable state races silently — wrong, nondeterministic results |
8. Gotcha checklist
| Symptom | Likely cause | Fix |
|---|---|---|
IllegalStateException: stream has already been operated upon or closed |
a stream was used twice | call stream() again for a fresh pipeline |
UnsupportedOperationException when adding to a stream's result |
Stream.toList() returns an unmodifiable list |
collect with Collectors.toCollection(ArrayList::new) |
| A stream of lists where you wanted one flat stream | map where you meant flatMap |
flatMap(List::stream) |
reduce gives a total that is off |
the identity isn't neutral (1 for a sum) |
seed a sum with 0, a product with 1 |
IllegalStateException: Duplicate key |
two elements gave toMap the same key |
groupingBy, or pass toMap a merge function |
A peek printed nothing, or in a surprising order |
no terminal operation; count() skipped the pipeline; laziness |
use peek only for debugging, never for work |
Every element processed despite findFirst |
a stateful step such as sorted sits before it |
filter first, or drop the sort |
| A default is computed even when a value is present | orElse(expensive()) evaluates its argument first |
orElseGet(() -> expensive()) |
NoSuchElementException: No value present |
get() on an empty Optional |
orElse/orElseThrow/ifPresent |
local variables referenced from a lambda expression must be final or effectively final |
a lambda assigns to a local variable | compute the value with a reduction (sum, count, reduce) |
| A parallel stream gives wrong, varying results | it writes shared mutable state (a data race) | a reduction or collector, or don't parallelize |
✅ Check yourself
One check per objective. Answer before you open anything.
The 🧪 box below: the squares, the peek lines, and the Optional.
Output:
[4, 16]
10
11
false
none- The evens are
2and4, and their squares are[4, 16]. - Only
10is peeked, becausefindFirststops after the first element, whichmapturns into11. - No string is longer than 5, so the
Optionalis empty, andorElsegivesnone. A method returningnullwould compile just the same, and fail only when some caller forgot the check.Optional<String>puts that check into the type.
📚 Sources
java.util.streampackage summary, Java SE 25 API ("Stream operations and pipelines", "Parallelism", "Associativity") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/stream/package-summary.htmljava.util.stream.Stream, Java SE 25 API (toList()since 16;sorted;reduce;peek"exists mainly to support debugging";count()may skip the pipeline) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/stream/Stream.htmljava.util.stream.Collectors, Java SE 25 API (toList()"no guarantees on the type, mutability …";toMapandIllegalStateExceptionon duplicate keys) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/stream/Collectors.htmljava.util.Comparator.comparing(Function), Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Comparator.html#comparing(java.util.function.Function)java.util.stream.IntStream, Java SE 25 API (sum(),average()returningOptionalDouble) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/stream/IntStream.htmljava.util.Optional, Java SE 25 API (the API note on return types;orElseGet) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Optional.html- The Java Language Specification, Java SE 25, §15.27.2 "Lambda Body" (a local variable used but not declared in a lambda body must be final or effectively final) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.27.2
- JEP 485: Stream Gatherers (JDK 24) — https://openjdk.org/jeps/485
java.util.stream.Gatherer, Java SE 25 API ("An intermediate operation that transforms a stream of input elements into a stream of output elements";ofSequential;Downstream.push) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/stream/Gatherer.htmljava.util.stream.Gatherers, Java SE 25 API ("such as windowing functions, folding functions"; since 24) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/stream/Gatherers.html
🧪 Predict, then check.
- Predict the output of
Stream.of(1,2,3,4).filter(n -> n % 2 == 0).map(n -> n * n).collect(Collectors.toList()). - Predict the
peeklines printed byStream.of(10,20,30).peek(System.out::println).map(n -> n + 1).findFirst(). - Predict whether
List.of("a","bb","ccc").stream().filter(s -> s.length() > 5).findFirst()is present, and what.orElse("none")returns. Then explain whyOptionalis safer here than returningnull.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.