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.
the terminal operation starts on the calling thread, main, with the whole range as one task. the pool's workers are idle.
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.
.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.
| helps | hurts |
|---|---|
arrays, ArrayList, IntStream.range, HashMap (split cleanly) | LinkedList, Stream.iterate, BufferedReader.lines() (split badly) |
| primitive streams | boxing (memory bandwidth, not CPU, becomes the limit) |
stateless ops: map, filter | order-dependent ops: limit, skip, findFirst, sorted |
cheap combine: sum, count | expensive combine: merging big maps in groupingBy |
| CPU-bound work | blocking 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:
List<Integer> out = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(out::add);
IO.println(out.size()); // one run printed 4458nulls 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:
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());
}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.
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());
}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.
IO.println(IntStream.range(0, 1000).parallel()
.boxed()
.limit(3)
.toList());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.
.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.