skip to content
everything.functional.parallelStreams

parallel streams

spliterators, the common pool, and when parallel is slower.

chapter
chapter 07.10
reading time
7 min read

.parallel() splits the source into chunks with a spliterator, runs the chunks on the shared common ForkJoinPool (one worker per core, minus one, plus your own thread), and combines the results.

it's only faster when there's a lot of independent, CPU-heavy work on a source that splits cleanly. for small or cheap jobs it's slower, and the moment the lambdas touch shared mutable state, it's wrong.

the picture

you know how .parallel() is one word and looks like free speed? it isn't free. splitting the work, waking threads, handing chunks around and merging the answers all cost time, and you only come out ahead if the actual work is bigger than that overhead.

think of grading a pile of exams. with a hundred exams and five teaching assistants, you split the pile and finish five times sooner. with three exams, by the time you've split them up and collected them back, you'd have been done alone. and if every TA writes their tallies on the same whiteboard at the same time, the totals are garbage.

threadrunningits deque
main[0, 16)empty
w1idleempty
w2idleempty

the terminal operation starts on the calling thread, main, with the whole range as one task. the pool's workers are idle.

step 1/8
step through it. a parallel stream over 16 elements, simplified to 3 threads and chunks of 4 (the real pool on a 14-core mac has 13 workers and would split 16 elements all the way down to single elements). amber is running right now, dashed boxes are waiting in a thread's deque, green is done.

three things in that picture are worth remembering. your own thread works too, it doesn't just wait. splitting is done by the source's Spliterator.trySplit(), so the source decides how well this goes. and nobody hands out work: idle threads steal from the back of busy threads' deques, which keeps everyone busy even when chunks take different amounts of time. more in fork/join and work stealing.

when it's actually faster

rough numbers, so read the shape, not the decimals: best of 10 to 200 runs (after warm-up) timed with System.nanoTime on jdk 25, 14-core arm mac. not JMH.

IntStream.range(0, 1_000).sum()0.15 ×
LinkedList of 1M, mapToInt().sum()1.3 ×
ArrayList of 1M, mapToInt().sum()3.4 ×
LongStream.range(0, 10M).sum()8.4 ×
count primes below 2M8.8 ×
speedup from adding .parallel(): sequential time divided by parallel time. below 1 means parallel was slower. the 10M sum swung between 6.1× and 8.4× across two runs, which tells you how noisy this is.

see the pattern? the prime count does real work per element and wins big. summing a thousand ints takes 0.3 µs sequentially, so parallel's ~2 µs of setup makes it 7 times slower. and the LinkedList barely helps, because a linked list can't split in half without walking to the middle.

Doug Lea's rule of thumb (from his guidance on parallel streams, written with Brian Goetz and others): multiply N (number of elements) by Q (cost per element, roughly in operations). if N × Q isn't at least around 10,000, which is about 100 µs of sequential work, don't bother.

helpshurts
arrays, ArrayList, IntStream.range, HashMap (split cleanly)LinkedList, Stream.iterate, BufferedReader.lines() (split badly)
primitive streamsboxing (memory bandwidth, not CPU, becomes the limit)
stateless ops: map, filterorder-dependent ops: limit, skip, findFirst, sorted
cheap combine: sum, countexpensive combine: merging big maps in groupingBy
CPU-bound workblocking I/O (it starves the shared pool)

the bug: shared mutable state

this is the one that bites. the lambdas run on several threads at once, so anything they share has to be thread-safe, and ArrayList isn't:

java
List<Integer> out = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(out::add);
IO.println(out.size());   // one run printed 4458
10,000 (correct)111 runs
lost elements259 runs
lost elements + nulls277 runs
ArrayIndexOutOfBounds353 runs
log scale
1000 runs of that code on jdk 25. it gave the right answer about one time in nine. the rest lost elements, left nulls in the list (two threads wrote the same slot), or crashed while the list was resizing. a second batch had a different split, which is the nature of a race.

the fix isn't a synchronized list (that works, and serializes the whole thing, so why go parallel). the fix is to let the stream build the result, so each chunk builds its own piece and the pieces are merged:

Main.java
import java.util.*;
import java.util.stream.*;
 
void main() {
    List<Integer> good = IntStream.range(0, 10_000).parallel().boxed().toList();
    IO.println(good.size() + " " + good.getFirst() + " " + good.getLast());
}
output
10000 0 9999

same rule as reduction: describe the result, don't mutate your way to it. the race itself is explained frame by frame in race conditions.

order

a parallel stream still knows the encounter order of its source, and some operations promise to respect it. that promise costs coordination.

Main.java
import java.util.stream.*;
 
void main() {
    IntStream.range(0, 8).parallel().forEachOrdered(i -> IO.print(i + " "));
    IO.println();
    IO.println(IntStream.range(0, 100).parallel().filter(i -> i > 50).findFirst().getAsInt());
}
output
0 1 2 3 4 5 6 7 
51

forEach makes no promise: one run of IntStream.range(0, 8).parallel().forEach(...) printed 5 4 0 1 2 3 6 7, the next 5 7 6 1 4 2 3 0. forEachOrdered and findFirst give the sequential answer, but threads have to wait for each other to do it. if you don't care which element you get, findAny() is cheaper, and .unordered() tells limit, distinct and friends they can stop caring too.

predict the output
java
IO.println(IntStream.range(0, 1000).parallel()
        .boxed()
        .limit(3)
        .toList());
one level deeper:the custom pool trick, and why it's not a contract+

you'll see this on stack overflow: new ForkJoinPool(4).submit(() -> list.parallelStream()...).get(). it makes the stream run in that pool instead of the common one, because a fork/join task forks into whatever pool the current thread belongs to. it works on every JDK so far, but it's an implementation detail, not documented API, so treat it as a hack. and a container that limits your CPUs changes availableProcessors(), which changes the common pool's size. see the JVM in containers.

keylines
  • .parallel() = split with a spliterator, run on the common ForkJoinPool, combine.
  • your thread works too, and idle workers steal chunks from busy ones.
  • it pays off only for lots of CPU-heavy, independent work. N × Q ≥ ~10,000, then measure.
  • shared mutable state in the lambdas is a race. let the stream build the result.
  • every parallel stream shares one pool. never block in it.