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.
![]()
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:
| Shape | Written | Equivalent lambda |
|---|---|---|
| Static method | Integer::parseInt | s -> Integer.parseInt(s) |
| Method on a specific object | "hello"::toUpperCase | () -> "hello".toUpperCase() |
| Method on the parameter | String::length | s -> s.length() |
| Constructor | StringBuilder::new | s -> 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.

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.

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:
mapchanges the type of each element and never changes the count.nelements in,nelements out.filterchanges the count and never changes the type.nelements in, at mostnout, 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 operation | Returns | Note |
|---|---|---|
collect(collector) | whatever the collector builds | the general case, covered below |
toList() | List | unmodifiable, Java 16+ |
forEach(action) | nothing | no order guarantee in parallel |
count() | long | may skip the pipeline entirely |
reduce(...) | a value or an Optional | covered below |
min / max(comparator) | Optional | empty stream gives an empty Optional |
findFirst / findAny | Optional | short-circuiting |
anyMatch / allMatch / noneMatch | boolean | short-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.

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
| mutable | accepts null | since | |
|---|---|---|---|
Collectors.toList() | yes in practice, unspecified | yes | Java 8 |
Stream.toList() | no | yes | Java 16 |
Collectors.toUnmodifiableList() | no | no, throws | Java 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:
| Collector | Produces | Note |
|---|---|---|
toList() | List | mutable in practice, unspecified |
toSet() | Set | a HashSet, unordered |
toMap(k, v) | Map | throws on a duplicate key |
toMap(k, v, merge) | Map | merge function resolves collisions |
toMap(k, v, merge, supplier) | Map | you choose the map implementation |
joining(sep, pre, suf) | String | [Alice, Bob, Carol, Dan, Erin] |
counting() | Long | usually a downstream collector |
summingInt(f) | Integer | also summingLong, summingDouble |
averagingDouble(f) | Double | always Double, even for int input |
mapping(f, downstream) | whatever downstream gives | transform before collecting |
groupingBy(f, [supplier,] downstream) | Map | key comes from f |
partitioningBy(p, [downstream]) | Map with keys true and false | both 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.