Command Palette

Search for a command to run...

[Advanced Java] The Java Stream API: map, filter, reduce and collect

This article opens Part 3 of the course, on modern data processing. The first two parts were about shaping types and choosing collections; this part is about what you do with a collection once you have one, and it starts with the API that changed how Java code reads: streams.

A stream is not a collection and not a loop. It is a description of a computation over a sequence of elements, built up one operation at a time and then executed exactly once, all at the end, by a single terminal call. Almost every mistake people make with streams comes from missing that last sentence, so this article spends its longest section proving it with real interleaved output rather than asserting it.

Elements entering a pipeline, two dropped at the filter gate, the survivors reshaped at map and gathered by collect

Every line of output, every exception message and every allocation count below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64). Nothing here is timed: where cost matters, this article counts operations or bytes allocated, because those are reproducible and a stopwatch reading on a shared machine is not.

Prerequisite: the only lambda syntax this article needs

Streams take behaviour as arguments, and the shortest way to write behaviour is a lambda. The next article covers lambdas properly. This section teaches the minimum needed to read the code below and nothing more.

A lambda is a parameter list, an arrow, and a body. If the body is a single expression, write the expression and its value is returned:

n -> n.length() > 3            // one parameter, expression body, returns boolean
(a, b) -> a + b                // two parameters, expression body
w -> { return w.trim(); }      // block body, needs an explicit return
(a, b) -> { int s = a + b; return s; }

Parentheses around a single parameter are optional; with zero or two-plus parameters they are required. A block body needs braces and an explicit return unless the lambda returns nothing.

When the lambda body does nothing but call one existing method, a method reference says the same thing with less noise. There are four shapes, and all four appear in this article:

ShapeWrittenEquivalent lambda
Static methodInteger::parseInts -> Integer.parseInt(s)
Method on a specific object"hello"::toUpperCase() -> "hello".toUpperCase()
Method on the parameterString::lengths -> s.length()
ConstructorStringBuilder::news -> new StringBuilder(s)

The third shape is the one that surprises people: String::length takes the stream element and calls length() on it, so a one-argument function is written with no arguments visible at all. The same rule gives String::startsWith two parameters — the receiver and the argument.

Function<String, Integer> f1 = Integer::parseInt;
Supplier<String>          f2 = "hello"::toUpperCase;
Function<String, Integer> f3 = String::length;
Function<String, StringBuilder> f4 = StringBuilder::new;
BiFunction<String, String, Boolean> f5 = String::startsWith;
static   42
bound    HELLO
unbound  6
ctor     StringBuilder len 2
unbound2 true

That is the whole prerequisite. What a lambda may capture from its enclosing scope, why the captured variable must be effectively final, how the compiler decides which interface a lambda becomes, and what this means inside one — the next article covers all of it. The article after that covers Supplier, Consumer, Function and Predicate as types worth knowing in their own right. Until then, read String::length as "the function that returns a string's length" and move on.

What a stream is, and what it is not

The JDK's own package documentation defines the shape in one sentence: a pipeline "consists of a source (such as a Collection, an array, a generator function, or an I/O channel); followed by zero or more intermediate operations such as Stream.filter or Stream.map; and a terminal operation such as Stream.forEach or Stream.reduce."

That is three parts, with two hard rules attached: intermediate operations may number zero or many, and there is exactly one terminal operation, always last.

Four stages of a stream pipeline with the static return type of each, the lazy stages braced separately from the eager one

Source, intermediate operations, terminal operation

List<String> names = List.of("alice", "bob", "carol", "dan", "erin");

List<String> out = names.stream()          // source     -> Stream<String>
        .filter(n -> n.length() > 3)       // intermediate -> Stream<String>
        .map(String::toUpperCase)          // intermediate -> Stream<String>
        .collect(Collectors.toList());     // terminal   -> List<String>
result [ALICE, CAROL, ERIN]
source [alice, bob, carol, dan, erin]

Every intermediate operation returns a Stream, which is why they chain. The terminal operation returns something else — a List, a long, an Optional, or nothing at all — which is why the chain stops there.

A stream can come from many places, not just a collection:

Stream.of(1, 2, 3)                              // [1, 2, 3]
Arrays.stream(new String[]{"x", "y"})           // [x, y]
"abc".chars().mapToObj(c -> (char) c)           // [a, b, c]
Stream.iterate(1, x -> x * 2).limit(6)          // [1, 2, 4, 8, 16, 32]
Stream.iterate(1, x -> x < 40, x -> x * 3)      // [1, 3, 9, 27]
new TreeMap<>(m).entrySet().stream()            // [a, b]
                .map(Map.Entry::getKey)
Stream.empty()                                  // []

Stream.iterate with two arguments is infinite, and that is legal precisely because nothing is computed until something asks. The three-argument form added in Java 9 carries its own stop condition.

A stream is not a collection

Three properties follow from that, and each one catches people.

It holds no storage. names.stream() does not copy the list. It records where to read from. Nothing is allocated per element until a terminal operation pulls elements through.

It can be consumed once. After a terminal operation runs, the stream is spent. Touching it again is not a silent no-op:

Stream<String> s = names.stream();
System.out.println("count  " + s.count());
s.forEach(System.out::println);
count  5
caught java.lang.IllegalStateException: stream has already been operated upon or closed

The same message appears if you merely stored an intermediate stream and reused it — Stream.of("a","b").map(String::toUpperCase) held in a variable and consumed twice throws exactly the same thing. If you genuinely need two passes, keep a Supplier and call it twice:

Supplier<Stream<String>> sup = () -> Stream.of("a", "b", "c");
System.out.println("sup1 " + sup.get().count() + "  sup2 " + sup.get().toList());
sup1 3  sup2 [a, b, c]

It does not mutate its source. In the first example above, names printed unchanged after a filter and a map ran over it. No stream operation writes back into the collection it read from.

That guarantee is about the collection, not about the objects inside it. A map function that mutates the element it is handed will absolutely change your data:

List<StringBuilder> src = new ArrayList<>(List.of(new StringBuilder("a"), new StringBuilder("b")));
List<StringBuilder> out = src.stream().map(sb -> sb.append("!")).toList();
source after mutating map [a!, b!]  out [a!, b!]  same objects true

The list still holds the same two objects it always held — and both now read a! and b!. map is meant to produce a new value from an old one, not to edit the old one in place.

Laziness: nothing runs until the terminal operation

This is the single most important idea in the API, and it is the one most easily believed without being understood. The documentation states it plainly — intermediate operations "are always lazy; executing an intermediate operation such as filter() does not actually perform any filtering" — but the consequence only lands when you watch the order in which things actually execute.

The stage-by-stage mental model beside the real interleaved trace, and the limit run that never reads the last two elements

A pipeline with no terminal operation runs nothing at all

List<String> words = List.of("apple", "fig", "banana", "kiwi", "cherry");

words.stream()
     .filter(w -> { System.out.println("filter " + w); return w.length() > 4; })
     .map(w -> { System.out.println("  map  " + w); return w.toUpperCase(); });
System.out.println("(nothing above this line)");
--- no terminal operation ---
(nothing above this line)

Five elements, two operations with a println in each, zero lines of output. The two lambdas were never called. filter and map did nothing but build a description of what to do later, and nobody ever asked.

The interleaved trace

Now add a terminal operation and change nothing else:

List<String> r = words.stream()
     .filter(w -> { System.out.println("filter " + w); return w.length() > 4; })
     .map(w -> { System.out.println("  map  " + w); return w.toUpperCase(); })
     .collect(Collectors.toList());
filter apple
  map  apple
filter fig
filter banana
  map  banana
filter kiwi
filter cherry
  map  cherry
result [APPLE, BANANA, CHERRY]

Read that output twice. The obvious mental model — filter processes all five elements, then map processes the three survivors — would have produced five filter lines followed by three map lines. That is not what happened.

What happened is that apple went through filter and then immediately through map, before fig was ever looked at. Each element is pulled down the entire pipeline before the next element is fetched. fig fails the predicate, so no map line follows it; banana passes, so its map line comes next. There is one pass over the source, not one pass per stage, and no intermediate list of survivors is ever built.

This is why a pipeline of ten operations is not ten traversals, and why laziness is a structural property rather than an optimisation. It also explains the exception you get when you mutate the source mid-flight, which the traps section returns to.

limit short-circuits, so later elements are never read

If the terminal operation stops asking, the source stops producing. Add .limit(2) to the same pipeline:

List<String> r2 = words.stream()
     .filter(w -> { System.out.println("filter " + w); return w.length() > 4; })
     .map(w -> { System.out.println("  map  " + w); return w.toUpperCase(); })
     .limit(2)
     .collect(Collectors.toList());
filter apple
  map  apple
filter fig
filter banana
  map  banana
result [APPLE, BANANA]

kiwi and cherry produced no output whatsoever. They were not filtered, not mapped, not read. The pipeline had what it needed after banana and stopped pulling.

findFirst short-circuits the same way. Looking for the first three-letter word in pear, fig, apple, kiwi touches two elements and stops:

map    pear
  filt PEAR
map    fig
  filt FIG
found FIG

Short-circuiting is what makes an infinite source usable. Stream.iterate(0, x -> x + 1).limit(5).toList() returns [0, 1, 2, 3, 4] and terminates, because the source is only ever asked for five elements. Drop the limit and the same terminal operation never returns.

Not every operation lets elements flow through

filter and map are stateless: each element is handled independently. sorted and distinct are stateful, and sorted in particular cannot emit anything until it has seen everything. Put a println on both sides of a sorted and the one-at-a-time flow visibly breaks:

List<String> fruit = List.of("pear", "fig", "apple", "kiwi");

fruit.stream()
     .map(w -> { System.out.println("map    " + w); return w.toUpperCase(); })
     .sorted()
     .map(w -> { System.out.println("  after sort " + w); return w; })
     .toList();
map    pear
map    fig
map    apple
map    kiwi
  after sort APPLE
  after sort FIG
  after sort KIWI
  after sort PEAR

All four map lines come first, because sorted is a barrier: it buffers the whole stream before releasing the first element. That is a real cost worth knowing about — a sorted in the middle of a pipeline means the whole stream is materialised at that point, and it also cancels the benefit of a downstream limit for everything upstream of it.

Operation order changes how much work happens

Because elements flow through the whole chain, the order you write the operations in decides how many times each lambda is invoked. Six words, filtering down to three:

List<String> six = List.of("apple", "fig", "banana", "kiwi", "cherry", "plum");

// map first
six.stream().map(...).filter(...).toList();
// filter first
six.stream().filter(...).map(...).toList();

Counted from the trace output: with map first, the mapping function ran 6 times and the predicate ran 6 times. With filter first, the predicate ran 6 times and the mapping function ran 3 times. Same result, half the mapping work, and the difference is a single line swap.

That is an operation count, not a benchmark. It is exact, it does not depend on the machine, and it is the honest way to reason about the cost of a pipeline: put the cheap, discarding operations first.

map and filter

The two workhorses have complementary jobs, and stating them precisely removes most confusion:

  • map changes the type of each element and never changes the count. n elements in, n elements out.
  • filter changes the count and never changes the type. n elements in, at most n out, all of the same type.
record Person(String name, int age) {}

List<Person> people = List.of(new Person("Alice", 34), new Person("Bob", 17),
                              new Person("Carol", 52), new Person("Dan", 29));

people.stream().map(Person::name).toList();
people.stream().filter(p -> p.age() >= 18).map(Person::name).toList();
people.stream().map(Person::name).map(String::length).toList();
names    [Alice, Bob, Carol, Dan]
adults   [Alice, Carol, Dan]
lengths  [5, 3, 5, 3]
map size 4 from 4

map chains freely because each call just changes the element type of the stream: Stream<Person> becomes Stream<String> becomes Stream<Integer>. The argument to map is any function from the current element type to a new one — a lambda, a method reference to an instance method (String::length), or a method reference to your own static method.

filter takes a predicate: a function returning boolean. It keeps the elements for which the predicate is true. It cannot transform, and it cannot add.

Several terminal operations are worth having in your head alongside them:

Terminal operationReturnsNote
collect(collector)whatever the collector buildsthe general case, covered below
toList()Listunmodifiable, Java 16+
forEach(action)nothingno order guarantee in parallel
count()longmay skip the pipeline entirely
reduce(...)a value or an Optionalcovered below
min / max(comparator)Optionalempty stream gives an empty Optional
findFirst / findAnyOptionalshort-circuiting
anyMatch / allMatch / noneMatchbooleanshort-circuiting

Two of those have edges worth knowing. max returns an Optional because an empty stream has no maximum; call orElse and carry on — a later article in this part covers Optional properly. And allMatch on an empty stream returns true, which is correct logic and still surprising the first time:

max        Carol
anyMatch   true
allMatch   false
noneMatch  true
allMatch empty true

reduce: folding a stream into one value

reduce collapses a stream to a single value by repeatedly combining two values into one. It has three overloads, and choosing between them is mostly about what you want back when the stream is empty.

The three overloads

List<Integer> nums = List.of(3, 1, 4, 1, 5, 9, 2, 6);
List<String>  ws   = List.of("alpha", "be", "gamma", "hi");

Optional<Integer> a = nums.stream().reduce((x, y) -> x + y);              // 1 argument
int b              = nums.stream().reduce(0, (x, y) -> x + y);            // 2 arguments
int c              = ws.stream().reduce(0, (acc, w) -> acc + w.length(),  // 3 arguments
                                        Integer::sum);
1-arg sum        31
1-arg on empty   false -> 0
2-arg sum        31
2-arg product    6480
2-arg max        9
3-arg chars      14

The one-argument form has no starting value, so an empty stream has no answer at all and it returns an Optional. The two-argument form takes an identity, so an empty stream returns that identity and the result is a plain value. The three-argument form exists for the case where the accumulated type differs from the element type — folding String elements into an int total — and it needs a third function to merge two partial results.

Why the identity must actually be an identity

The javadoc is specific: "The identity value must be an identity for the accumulator function. This means that for all t, accumulator.apply(identity, t) is equal to t."

That is a requirement, not advice, and breaking it fails quietly:

nums.stream().reduce(1, (x, y) -> x + y);            // identity 1 with addition
List.<Integer>of().stream().reduce(1, (x, y) -> x + y);
wrong identity 1 32 (should be 31)
wrong id, empty  1

The sum came back one too large, and the empty stream returned 1 rather than 0. Nothing threw. The right identity depends on the operation: 0 for addition, 1 for multiplication, "" for string concatenation, Integer.MIN_VALUE for max. If no such value exists for your operation, that is a signal to use the one-argument overload and handle the empty case explicitly.

Why the accumulator must be associative

Associative means (a op b) op c equals a op (b op c). Addition is; subtraction is not. The JDK does not check, and sequentially you will never notice — the difference only appears once the elements are split into chunks and combined:

List<Integer> small = List.of(10, 3, 2);
small.stream().reduce(0, (x, y) -> x - y);
small.parallelStream().reduce(0, (x, y) -> x - y);
small.parallelStream().reduce(0, Integer::sum);
sub sequential   -15
sub parallel     -9
add parallel     15

The same expression gave -15 in one line and -9 in the next. Neither is a bug in the JDK: a non-associative accumulator simply has no well-defined answer once the work is grouped differently. Addition, being associative, gave 15 both ways.

The same trap hides in the three-argument overload, where the third function — the combiner — merges two partial results. A wrong combiner is invisible sequentially, because it is never called:

List<String> ws = List.of("alpha", "be", "gamma", "hi");
ws.stream().reduce(0, (acc, w) -> acc + w.length(), (x, y) -> x);          // broken combiner
ws.parallelStream().reduce(0, (acc, w) -> acc + w.length(), (x, y) -> x);
bad combiner seq 14
bad combiner par 5

The sequential run was correct by accident. The parallel run threw away half the work. If you write a three-argument reduce, the combiner must genuinely merge, and the only way to exercise it is to run in parallel — which is a good reason to prefer collect for anything more complex than a number.

collect and the Collectors factory

collect is the terminal operation for building a container. In practice you never write its three-function form by hand; you pass a ready-made collector from java.util.stream.Collectors.

Five elements routed by a classifier into three groups, each group fed to a downstream collector whose result becomes the map value

toList, and how it differs from Stream.toList

Java 16 added Stream.toList(), which reads better and is not the same thing:

List<String> a = Stream.of("a", "b").collect(Collectors.toList());
List<String> b = Stream.of("a", "b").toList();
Collectors.toList class java.util.ArrayList
Collectors.toList add   ok -> [a, b, c]
Stream.toList class     java.util.ImmutableCollections$ListN
Stream.toList add       throws java.lang.UnsupportedOperationException

Collectors.toList() gave a mutable ArrayList — though the javadoc guarantees no specific class, so do not rely on it being one. Stream.toList() gave an unmodifiable list and add threw.

Null handling is the part most articles get wrong. Stream.toList() is unmodifiable but does accept nulls; Collectors.toUnmodifiableList() does not:

Collectors.toList null  [a, null]
Stream.toList null      [a, null]
toUnmodifiableList cls  java.util.ImmutableCollections$List12
toUnmodifiableList null throws NullPointerException
mutableaccepts nullsince
Collectors.toList()yes in practice, unspecifiedyesJava 8
Stream.toList()noyesJava 16
Collectors.toUnmodifiableList()nono, throwsJava 10

Default to toList(). Reach for Collectors.toList() only when you actually need to mutate the result afterwards, and for toUnmodifiableList() when a null in the data should be treated as a bug.

toSet, toMap and the duplicate key

toSet() returns a HashSet — unordered, deduplicated, with no ordering guarantee at all:

toSet        [Engineering, Sales, Support]
toSet class  java.util.HashSet

toMap takes a key function and a value function. It works until two elements produce the same key, at which point it throws rather than silently dropping one:

record Employee(String name, String dept, int salary) {}

STAFF.stream().collect(Collectors.toMap(Employee::name, Employee::salary));  // fine
STAFF.stream().collect(Collectors.toMap(Employee::dept, Employee::salary));  // two Engineering rows
toMap        {Dan=82000, Erin=64000, Bob=95000, Alice=120000, Carol=78000}
dup key      java.lang.IllegalStateException: Duplicate key Engineering (attempted merging values 120000 and 95000)

The message names the key and both values, which is unusually helpful for a runtime failure. The fix is the three-argument overload, whose third function decides what to do with a collision:

STAFF.stream().collect(Collectors.toMap(Employee::dept, Employee::salary, Integer::sum));
STAFF.stream().collect(Collectors.toMap(Employee::dept, Employee::salary, Integer::sum, TreeMap::new));
merge fn      {Engineering=215000, Sales=160000, Support=64000}
4-arg TreeMap {Engineering=215000, Sales=160000, Support=64000}  java.util.TreeMap

A fourth argument supplies the map itself, which is how you get a TreeMap or a LinkedHashMap instead of the default HashMap.

One more sharp edge: toMap rejects a null value with a NullPointerException, because it is implemented with Map.merge. groupingBy accepts elements whose value fields are null but rejects a null key, also with a NullPointerException.

groupingBy, partitioningBy and downstream collectors

groupingBy takes a classifier function and returns a map from key to the list of elements with that key:

STAFF.stream().collect(Collectors.groupingBy(Employee::dept));

The second argument is where the real power is. It is another collector — the downstream collector — and it receives the elements of each group. Whatever it produces becomes that key's value:

Collectors.groupingBy(Employee::dept, Collectors.mapping(Employee::name, Collectors.toList()));
Collectors.groupingBy(Employee::dept, Collectors.counting());
Collectors.groupingBy(Employee::dept, Collectors.summingInt(Employee::salary));
Collectors.groupingBy(Employee::dept, Collectors.averagingDouble(Employee::salary));
Collectors.groupingBy(Employee::dept, TreeMap::new, Collectors.summingInt(Employee::salary));
groupingBy   {Engineering=[Alice, Bob], Sales=[Carol, Dan], Support=[Erin]}
counting     {Engineering=2, Sales=2, Support=1}
summingInt   {Engineering=215000, Sales=160000, Support=64000}
averaging    {Engineering=107500.0, Sales=80000.0, Support=64000.0}
3-arg group  {Engineering=215000, Sales=160000, Support=64000}  java.util.TreeMap

Four different value types out of one classifier, decided entirely by the downstream collector. The default really is Collectors.toList() — writing groupingBy(f) and groupingBy(f, Collectors.toList()) gives the same result.

partitioningBy is groupingBy restricted to a predicate, so the key type is Boolean and there are always exactly two keys:

STAFF.stream().collect(Collectors.partitioningBy(e -> e.salary() >= 90000,
                       Collectors.mapping(Employee::name, Collectors.toList())));
partitioning {false=[Carol, Dan, Erin], true=[Alice, Bob]}
part class   java.util.stream.Collectors$Partition

The "always exactly two" is the point, and it shows up on an empty stream:

part empty   {false=[], true=[]}
group empty  {}

groupingBy produced an empty map because no element ever supplied a key. partitioningBy produced both keys with empty lists, because its key set is fixed by the type rather than by the data. That difference is why partitioningBy(p).get(true) is safe and groupingBy(f).get(k) may be null.

The collectors worth knowing:

CollectorProducesNote
toList()Listmutable in practice, unspecified
toSet()Seta HashSet, unordered
toMap(k, v)Mapthrows on a duplicate key
toMap(k, v, merge)Mapmerge function resolves collisions
toMap(k, v, merge, supplier)Mapyou choose the map implementation
joining(sep, pre, suf)String[Alice, Bob, Carol, Dan, Erin]
counting()Longusually a downstream collector
summingInt(f)Integeralso summingLong, summingDouble
averagingDouble(f)Doublealways Double, even for int input
mapping(f, downstream)whatever downstream givestransform before collecting
groupingBy(f, [supplier,] downstream)Mapkey comes from f
partitioningBy(p, [downstream])Map with keys true and falseboth keys always present

Primitive streams: IntStream, LongStream and DoubleStream

Stream<Integer> and IntStream are different types, and the difference is not stylistic. A Stream<Integer> holds references, so every int that enters it has to be boxed into an Integer object. IntStream holds int values.

IntStream.rangeClosed(1, 10)                       // an IntStream
nums.stream().mapToInt(Integer::intValue)          // Stream<Integer> -> IntStream
IntStream.range(0, 5).boxed()                      // IntStream -> Stream<Integer>
IntStream.of(1, 2, 3).mapToObj(i -> "#" + i)       // IntStream -> Stream<String>
IntStream.of(1, 2, 3).asDoubleStream()             // IntStream -> DoubleStream
mapToInt sum   55
boxed          [0, 1, 2, 3, 4]
mapToObj       [#1, #2, #3]
asDoubleStream 6.0
LongStream sum 500000500000
IntStream max  9

The primitive streams carry arithmetic that Stream cannot offer: sum(), average(), max(), min() and summaryStatistics(), which computes all of them in one pass.

IntSummaryStatistics st = nums.stream().mapToInt(Integer::intValue).summaryStatistics();
stats          IntSummaryStatistics{count=10, sum=55, min=1, average=5.500000, max=10}
average        2.5
average empty  NaN

sum() returns a plain int or long, because summing nothing is 0. average() returns an OptionalDouble, because averaging nothing is not 0.

Why they exist, counted in bytes

The argument for primitive streams is allocation, and allocation can be counted exactly. Summing one million values two ways, with ThreadMXBean.getThreadAllocatedBytes reading the thread's allocation counter on either side:

IntStream.range(0, n).asLongStream().sum();                        // no boxing
IntStream.range(0, n).boxed().mapToLong(Integer::longValue).sum(); // one Integer per element
IntStream, no boxing   sum 499999500000   allocated 240 bytes
boxed() then unbox     sum 499999500000   allocated 15998280 bytes
bytes per element                            15.99828

The unboxed pipeline allocated 240 bytes in total — a handful of pipeline objects, and nothing per element. The boxed pipeline allocated just under 16 MB for the same answer, which is 16 bytes per element.

Sixteen is not a coincidence. On a 64-bit JVM with compressed object pointers, an Integer is a 12-byte object header plus a 4-byte int field. And the total accounts for itself exactly: Integer.valueOf caches the values from -128 to 127, so 128 of the million came from the cache for free, and 999,872 fresh Integer objects at 16 bytes each is 15,997,952 bytes — leaving 328 bytes of fixed pipeline overhead. Both runs of the program produced byte-identical numbers.

That is what mapToInt buys you: not a percentage, but one object per element that never gets created. Sum the salaries of five employees and it is irrelevant. Sum a million and it is a million objects for the garbage collector to find and free.

The traps

A stateful lambda

The javadoc warns that "stream pipeline results may be nondeterministic or incorrect if the behavioral parameters to the stream operations are stateful". The classic example is a counter used to number the elements:

List<String> letters = List.of("a", "b", "c", "d", "e", "f", "g", "h");

int[] i = {0};
List<String> seq = letters.stream().map(w -> (i[0]++) + ":" + w).toList();

int[] j = {0};
List<String> par = letters.parallelStream().map(w -> (j[0]++) + ":" + w).toList();
sequential [0:a, 1:b, 2:c, 3:d, 4:e, 5:f, 6:g, 7:h]
parallel   [4:a, 7:b, 6:c, 5:d, 3:e, 2:f, 1:g, 0:h]
run 0      [6:a, 5:b, 3:c, 1:d, 3:e, 2:f, 4:g, 0:h]  counter=7
run 1      [4:a, 6:b, 7:c, 5:d, 3:e, 2:f, 1:g, 0:h]  counter=8

Look at run 0: the index 3 appears twice, and the counter finished at 7 after eight elements. That is a lost update on j[0]++, which is not atomic. These parallel outputs are one run's values and will differ on your machine — that is the whole point. The sequential result happens to be correct, which is exactly why this bug ships.

The array-of-one trick that makes the counter compile at all is a hint that something is wrong. If you need an index, use IntStream.range over the indices instead of smuggling one into a lambda.

Modifying the source while the stream runs

Because elements are pulled from the source one at a time as the terminal operation runs, the source is live for the whole pipeline. Writing to it mid-flight fails the same way an enhanced for loop fails:

List<String> src = new ArrayList<>(List.of("a", "b", "c", "d"));
src.stream().map(s -> { if (s.equals("b")) src.add("x"); return s.toUpperCase(); }).toList();
java.util.ConcurrentModificationException
   at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1714)
   at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
   at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
   at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:575)
   at java.base/java.util.stream.AbstractPipeline.evaluateToArrayNode(AbstractPipeline.java:260)
   at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:616)
   at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:622)
   at java.base/java.util.stream.ReferencePipeline.toList(ReferencePipeline.java:627)
   at Traps.main(Traps.java:9)

The top frame is ArrayListSpliterator.forEachRemaining — the same modCount check that backs ArrayList's iterator, reached through the stream machinery. Note that the throw comes from toList(), the terminal operation, not from the map call that wrote to the list. Collect into a new list and replace the old one instead.

peek is for debugging, and may be skipped entirely

peek is an intermediate operation that runs an action on each element and passes it through unchanged. Its javadoc says it "exists mainly to support debugging", and the JDK is explicit that it is not guaranteed to run:

Stream.of("a", "b", "c").peek(s -> System.out.println("peek " + s)).count();
Stream.of("a", "b", "c").peek(s -> System.out.println("peek " + s)).filter(s -> true).count();
peek + count:
count 3
peek + filter + count:
peek a
peek b
peek c
count 3

The first pipeline printed nothing. count() on a sized source with no element-count-changing operation is computed from the source's size without traversing anything, and the javadoc says so directly: in that case "no source elements will be traversed and no intermediate operations will be evaluated". Insert a filter — which could change the count — and the optimisation no longer applies, so peek runs.

Use peek to look at a pipeline while you debug it. Never use it to do work.

findFirst versus findAny

Both return an Optional and both short-circuit. findFirst returns the first element in encounter order. findAny is free to return any matching element, which lets a parallel pipeline return whatever a worker finds first.

findFirst seq 3
findAny   seq 3
findAny   par 15 15 15 15 15 15 15 15
findFirst par 3 3 3 3 3 3 3 3

Sequentially both returned 3. In parallel, findAny returned 15 on all eight runs here and findFirst returned 3 on all eight. Do not read that stability as a guarantee: findAny is specified as "any", so a different JVM, a different source size, or a different day is allowed to give you something else. Use findFirst when order matters, findAny only when it genuinely does not.

parallelStream is a footgun, not a speed switch

parallelStream() is one method call, which makes it look like a free upgrade. It is not. Three things break the moment you type it, and all three appeared in the outputs above.

Ordering assumptions break. forEach gives no order guarantee in a parallel pipeline:

parallel forEach        0 3 6 7 4 9 2 1 5 8
parallel forEachOrdered 0 1 2 3 4 5 6 7 8 9
parallel map+toList     [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

forEachOrdered restores the order and gives up much of the parallelism to do it. Note that toList() stayed ordered — collecting preserves encounter order even in parallel — so the danger is specifically in side-effecting terminal operations.

Thread safety assumptions break. An unsynchronised collection written from a parallel forEach produces garbage, and not the same garbage twice:

List<Integer> sink = new ArrayList<>();
IntStream.range(0, 10000).parallel().forEach(sink::add);
sink threw java.lang.ArrayIndexOutOfBoundsException
sink size 2213 (expected 10000)
sink size 5358 (expected 10000)
sink size 6882 (expected 10000)
sink size 8376 (expected 10000)

Five runs of the same code: one threw, and four silently lost between 16% and 78% of the data. Nothing warned. The collect version of the same pipeline is safe, which is a large part of why collect exists.

Correctness assumptions break, as the non-associative reduce and the stateful lambda above both showed.

The performance question is the one to answer last, and honestly: parallelism costs work to split the source, hand chunks to the common ForkJoinPool, and merge results, and on a small or cheap-per-element pipeline that overhead can dominate. Whether it wins for your data on your hardware is a measurement, not a guess — and measuring it properly means JMH, with warm-up, forked JVMs and blackholes, because a naive timing loop on the JVM measures the JIT compiler as much as your code. There are no timings in this article for exactly that reason. Do not reach for parallelStream() without one.

When not to use a stream

Streams are a poor fit for three shapes of loop, and forcing them makes worse code than the loop you were avoiding.

A simple indexed loop. If you need the index, a for loop already has it. The stream version has to manufacture one:

String[] a = {"x", "y", "z"};
IntStream.range(0, a.length).mapToObj(i -> i + "=" + a[i]).toList();  // [0=x, 1=y, 2=z]

That is fine when you actually want a list of index-value strings. As a way of writing for (int i = 0; i < a.length; i++), it is longer, slower to read, and only works if a is effectively final.

A loop that mutates. Building up state by assignment is what a loop is for. A stream whose lambdas write to variables outside themselves is the stateful-lambda trap wearing a disguise.

A loop that breaks early with state you need afterwards. Streams short-circuit, but they cannot hand you the loop variables at the moment of the break:

int[] data = {4, 8, 15, 16, 23, 42};

int runningTotal = 0, stoppedAt = -1;
for (int i = 0; i < data.length; i++) {
    runningTotal += data[i];
    if (runningTotal > 40) { stoppedAt = i; break; }
}
loop total 43 index 3

Two values out of one traversal, with a condition that depends on the accumulated state. Every stream version of this is worse.

The rule that survives contact with real code: reach for a stream when the computation is a transformation from a sequence to a value or another sequence, and reach for a loop when it is a procedure with state. Mixing the two — a stream whose lambdas carry procedural state — gives you the drawbacks of both.

FAQ

Does a stream store its elements?

No. A stream holds no storage of its own; it records where to read from and what to do with each element. That is why creating a stream over a million-element list allocates almost nothing, and why the work only happens when a terminal operation pulls elements through.

Why does my stream throw IllegalStateException?

Because you used it twice. A stream is consumed by its terminal operation, and any further call gives java.lang.IllegalStateException: stream has already been operated upon or closed. This bites most often when an intermediate stream is stored in a variable and passed around. If you need two passes, store a Supplier that builds a fresh stream and call it twice.

What is the difference between Collectors.toList() and Stream.toList()?

Stream.toList(), added in Java 16, returns an unmodifiable list that does accept nulls. Collectors.toList() returns a list that happens to be a mutable ArrayList on this JDK, though the specification promises nothing about the class. Collectors.toUnmodifiableList() is unmodifiable and throws NullPointerException on a null element. Default to Stream.toList().

Why does nothing print when I put a println in map?

Because there is no terminal operation on the pipeline. Intermediate operations are lazy: map returns a new stream and calls your function zero times. Add collect, forEach, count or any other terminal operation and the whole chain runs.

Is reduce slower than a for loop?

That is the wrong question to answer with a number, and any number measured on a shared machine would be worthless. What is countable is allocation: Stream<Integer> boxes one Integer per element, measured above at 16 bytes each, while a primitive for loop or an IntStream boxes nothing. If a pipeline is hot, mapToInt before the arithmetic is the change that is guaranteed to help. Anything else needs a real benchmark under JMH.

Should I use parallelStream to make this faster?

Almost certainly not, and never without measuring. It breaks ordering in forEach, exposes any thread-unsafe collection you write to, and requires the accumulator of a reduce to be genuinely associative — all three failures are shown above with real output. Measuring it honestly requires JMH, not a timing loop.

Does filter remove elements from my list?

No. No stream operation writes back to its source; the original collection is unchanged after the pipeline runs. The one way to change your data through a stream is to hand map a function that mutates the object it receives, which you should not do. To remove elements from a collection in place, use removeIf.

Conclusion

A stream is a plan, not a container. It is built out of a source, zero or more lazy intermediate operations, and exactly one eager terminal operation, and until that terminal operation runs, nothing has happened at all. The interleaved trace in this article is the whole model in eight lines of output: each element travels the full pipeline before the next one is fetched, which is why one pass covers every stage, why limit and findFirst can leave elements untouched, and why an infinite source is usable at all.

Everything else follows from that. map and filter describe per-element work; reduce folds with an identity that must really be an identity and an accumulator that must really be associative; collect builds containers, with groupingBy and a downstream collector covering most of what people reach for SQL to do. IntStream exists so that a million numbers do not become a million objects. And peek, findAny and parallelStream are all sharper than they look, because each one is specified more loosely than its name suggests.

Streams lean on lambdas at every step, and this article deliberately taught only enough of them to read the code. The next article fixes that: article 15 covers lambda expressions properly — the forms, what a lambda captures from its enclosing scope, why the captured variable must be effectively final, how the compiler decides which interface a lambda becomes, and what this means inside one.

Related Posts

[Advanced Java] The Collections Utility Class: Algorithms, Wrappers and Factories

java.util.Collections on OpenJDK 21 organised by what its methods actually are: in-place algorithms including the binarySearch insertion point trick, nCopies returning one reference n times, the unmodifiable, synchronized and checked wrappers that are views rather than copies, checkedList catching heap pollution at insertion, and the immutable factories that replace half of them.

[Advanced Java] Functional Interfaces: Supplier, Consumer, Function and Predicate

Functional interfaces in java.util.function on OpenJDK 21: the shape grid behind all 43 of them, what @FunctionalInterface really checks, why an abstract equals does not break single-abstract-method status, andThen versus compose, the Predicate and Consumer combinators, the primitive specialisations and the boxing they remove, and how to write your own.

[Advanced Java] Set Implementations in Java: HashSet, LinkedHashSet and TreeSet

HashSet, LinkedHashSet and TreeSet on OpenJDK 21: what a Set actually guarantees, the HashMap hiding inside HashSet, NavigableSet lookups, the two different rules that decide a duplicate, compareTo that disagrees with equals, ClassCastException and null in a TreeSet, EnumSet, and why removeAll can go quadratic.

[Advanced Java] Advanced Map Implementations: TreeMap, LinkedHashMap, Hashtable and ConcurrentHashMap

The Map implementations beyond HashMap on OpenJDK 21 - TreeMap and the NavigableMap methods floorEntry, ceilingKey, headMap and subMap, LinkedHashMap access order and a five-line LRU cache with removeEldestEntry, why Hashtable is legacy, and what ConcurrentHashMap actually promises about locking, weakly consistent iterators and atomic compound operations.