Java Parallel Streams: When They Help and When They Hurt
Java parallel streams split the source across the common ForkJoinPool. The rules a parallel pipeline must keep, what ordering costs, and when it is faster.
- Course: Java study plan
- Module: Streams
- Kind: Lesson
- Reading time: 13 min
- Runtime: OpenJDK 18
How do parallel streams work in Java?
A parallel stream in Java splits its source into chunks with a Spliterator, processes the chunks as tasks on the common ForkJoinPool, and merges the partial results with a combiner. Calling parallel() or parallelStream() switches the whole pipeline. It is correct only with stateless, side-effect-free lambdas and associative reductions, and faster only for large, CPU-bound work on a source that splits well.
Lesson
.parallel() is one method call that splits a pipeline across every core in the machine. It is also the easiest way in Java to make a correct program wrong, or a fast program slower, so it deserves a lesson of its own: how the work is split, what the pipeline must promise in return, when it actually pays, and how streams compare to loops when the clock is running.
How a parallel stream runs
long evens = IntStream.range(0, 10_000_000).parallel().filter(i -> i % 2 == 0).count();
The source is split with a Spliterator into chunks; the chunks are processed as tasks on the common ForkJoinPool (size = cores − 1, plus the calling thread); partial results are merged with the combiner. Every core busy, one line of code. parallelStream() on a collection and parallel() on a stream do the same thing; sequential() switches back — the last call wins for the whole pipeline.
Splitting quality depends on the source. ArrayList, arrays and IntStream.range split perfectly (known size, random access). HashSet and TreeMap split adequately. LinkedList, Stream.iterate and BufferedReader.lines() split badly — the spliterator has to walk to find the middle — and often run slower in parallel.
What the pipeline must promise
Parallel execution is only correct when the lambdas keep the rules from the first lesson, and two more:
- No shared mutable state.
list.parallelStream().forEach(x -> results.add(x))corrupts theArrayList— lost elements,ArrayIndexOutOfBoundsException, or silent garbage. Usecollect, whose combiner exists for exactly this. reduceidentity and associativity. The identity must be a real identity and the operation associative ((a op b) op c == a op (b op c)); subtraction and string prefixing are not, and the parallel answer differs run to run.- Stateless lambdas. A counter captured from outside, a
Randomshared between threads, aSimpleDateFormat— all break. - No blocking. The common pool is shared by every parallel stream and
CompletableFuturein the JVM. A lambda that waits on I/O parks a pool thread and starves everyone else; parallel streams are for CPU work.
Ordering
A stream from a List has an encounter order, and most operations respect it even in parallel: toList, forEachOrdered, findFirst, limit, skip, sorted. Respecting order costs — limit(10) in parallel must buffer to be sure it kept the first ten. Three ways to pay less:
list.parallelStream().forEach(...) // no order promised — usually what you wanted from forEach anyway
list.parallelStream().unordered().distinct() // distinct without preserving first-seen order: much cheaper
list.parallelStream().findAny() // any survivor, not the first
A HashSet source has no encounter order to begin with, so ordering is free (and meaningless) there.
When parallel actually helps
The rule of thumb from the JDK's own authors: N × Q > 10 000, where N is the element count and Q the cost per element in "simple operations". Ten million cheap integers, or ten thousand expensive image transforms, might benefit; a hundred strings never will. Even then:
- The work must be CPU-bound and independent per element.
- The source must split well (array,
ArrayList, range). - The machine must have idle cores — on a busy server the pool is already saturated.
- The terminal must merge cheaply —
count,sum,toListdo;groupingByinto aHashMapmerges maps and can cost more than it saved (groupingByConcurrenthelps when order does not matter).
Measure before and after. If you cannot measure it, do not parallelise it.
Streams versus loops: performance, not just taste
For small collections a stream costs more than the loop: the pipeline objects, the lambdas, the iterator, and a cold JIT. On a million-element int[], IntStream.sum() and a for loop are within noise once warmed up — the JIT inlines the lambdas — while Stream<Integer> with boxing is several times slower. The rules that follow:
- Boxing is the main cost, not the stream.
mapToIntearly. sorted()is O(n log n) and buffers everything; sorting to take one element is the classic waste —min/maxinstead.distinct()builds aHashSet; on a sorted source, hand-written adjacent-duplicate removal is cheaper.- Multiple passes over the same source (
count()thensum()thenmax()) are three iterations;summaryStatistics()orteeingis one. - In interviews the difference rarely matters; the ability to explain it does. "A stream here is the same complexity; I would switch to a loop if profiling showed the pipeline overhead" is the right sentence.
Debugging and safety checklist
Before shipping .parallel():
- Every lambda stateless and side-effect free? Results collected with
collect, not an outside list? reducewith a true identity and an associative operation?- Nothing blocking (no I/O, no locks, no
Thread.sleep) inside the pipeline? - Source splits well? Enough elements and per-element work to matter?
- Measured, on data of production size, with the JIT warmed?
If any answer is "not sure", stay sequential. A sequential stream is never the bug.
Interview angle
- "How does a parallel stream execute?" Spliterator chunks the source; the common
ForkJoinPoolruns the chunks; combiners merge. - "When does
parallel()make code slower?" Small data, cheap operations, badly-splitting sources (LinkedList,iterate), order-preserving operations, expensive merges, blocking lambdas. - "What goes wrong with
forEach(list::add)in parallel?" A race on an unsynchronisedArrayList; usecollect. - "Why must a
reducebe associative?" Chunks are combined in an unpredictable tree; only associativity guarantees the same result. - "Which pool runs parallel streams?" The common
ForkJoinPool, shared JVM-wide — a blocking lambda starves other users.
Key takeaways
parallel()splits the source and runs on the common ForkJoinPool; the lastparallel/sequentialcall wins.- Correctness needs stateless, non-interfering, side-effect-free lambdas and associative reductions with true identities; collect instead of mutating.
- Order costs:
unordered(),findAny, plainforEachwhen order does not matter. - Helps only for large, CPU-bound, well-splitting work on idle cores — and only if you measured.
- Boxing,
sortedand repeated passes are the real stream costs; the loop is the fallback, not the enemy.
Common questions
When should you use parallel streams in Java?
Only for large, CPU-bound, independent work on a source that splits well — an array, an ArrayList or an IntStream.range — on a machine with idle cores, and only after measuring. The JDK authors' rule of thumb is element count times per-element cost above 10 000.
Why is my parallel stream slower than the sequential one?
Common causes are small data or cheap operations, a source that splits badly (LinkedList, Stream.iterate, BufferedReader.lines()), order-preserving operations such as limit and findFirst, costly merges such as groupingBy into a HashMap, and lambdas that block on I/O.
What thread pool do Java parallel streams use?
The common ForkJoinPool, sized to the number of cores minus one, plus the calling thread. It is shared by every parallel stream and CompletableFuture in the JVM, so a lambda that blocks on I/O starves everything else using it.
Why does forEach(list::add) on a parallel stream lose elements?
Several threads add to the same unsynchronised ArrayList at once — a race that loses elements, throws ArrayIndexOutOfBoundsException or leaves silent garbage. Use collect or toList(), whose combiner merges per-thread results safely.
Why must a parallel reduce be associative?
Chunks are combined in an unpredictable order and grouping, so only an associative operation, where (a op b) op c equals a op (b op c), gives the same result every run. Subtraction is not associative, so a parallel subtraction reduce varies from run to run.
Exercises
Parallel, but correct
Read an integer n, n integers, and a threshold t. Compute every answer on a parallel stream, choosing operations whose results do not depend on scheduling:
sum=viareduce(0, Integer::sum)(a true identity, an associative operation).squares=the squares in input order, collected withtoList()— encounter order is preserved even in parallel.over=how many values exceedt.firstOver=the first value in input order that exceedst(findFirst, notfindAny), ornone.
Example: 5 then 4 9 2 7 5 then 6 →
sum=27
squares=[16, 81, 4, 49, 25]
over=2
firstOver=9How a combiner thinks
Simulate the way a parallel reduction splits work. Read an integer n, n integers, and a chunk size c. Split the input into consecutive chunks of c (the last may be shorter). Using IntStream.range over the chunk indices and Arrays.stream(a, from, to) for each chunk, print one line per chunk chunk <i>: <sum>, then combined=<sum of the chunk sums>, then direct=<sum of the whole array> — the two totals agree because addition is associative with identity 0.
Example: 5 then 1 2 3 4 5 then 2 →
chunk 0: 3
chunk 1: 7
chunk 2: 5
combined=15
direct=15In this module: Streams
- Streams — pipelines over data
- Intermediate operations — map, filter, flatMap and friends
- Terminal operations — reduce, match, find, count
- Collectors — toMap, joining, groupingBy, partitioningBy
- Optional — a return type, not a null replacement
- Primitive streams — IntStream, LongStream, DoubleStream
- Parallel streams, ordering and performance (this lesson)
- Checkpoint — Streams
← Primitive streams — IntStream, LongStream, DoubleStream · Checkpoint — Streams →