When is parallelStream() a mistake?

Asked oftenintermediate2–12 yrs11 min readJava 8LTSJava 21LTS

It runs on one ForkJoinPool shared by the whole JVM, so a slow task in one place starves parallel work everywhere else — including anything a library does. Add the usual problems of shared mutable state and non-associative reductions, and the honest default is not to use it until you have measured.

The Answer

Say this in the room. 45 seconds.

  • parallelStream() does not create threads. It submits to the common ForkJoinPool, one instance shared by the entire JVM.
  • Its parallelism is fixed at cores minus one, and the calling thread joins in as one of the workers.
  • So one slow or blocking task starves every other parallel stream in the process — including ones inside libraries you did not write.
  • It is for CPU-bound work on large data with a cheap split. Blocking IO is the wrong use, and always was.
  • Shared mutable state that a sequential stream tolerated becomes a race.
  • reduce needs an associative operator, or the answer changes with the number of cores.
  • The honest default: write it sequentially, measure, and only then consider parallel.

Understand It

There is one pool, and it belongs to everyone

The first thing to know is that you did not create the threads and you do not own them:

Compiled and run on this build
Set<String> threads = ConcurrentHashMap.newKeySet();
IntStream.range(0, 10_000).parallel()
    .forEach(i -> threads.add(Thread.currentThread().getName()));

System.out.println("  every worker from the common pool, or the caller? "
    + threads.stream().allMatch(n ->
        n.startsWith("ForkJoinPool.commonPool-worker-") || n.equals("main")));
System.out.println("  the calling thread did work too?                 "
    + threads.contains("main"));
Output
  every worker from the common pool, or the caller? true
  the calling thread did work too?                 true

Two facts in that output.

Every worker came from ForkJoinPool.commonPool. That pool is a static singleton. Your parallel stream, the parallel stream in the reporting job, and the one inside a third-party library are all queued on the same threads. Its parallelism is availableProcessors() - 1, so on an eight-core machine there are seven workers for the whole process — and on a container limited to one core, there are none, and everything runs on the caller.

The calling thread participates. parallel() does not hand work off and return; the caller helps and blocks until the whole stream is done. parallelStream() is not asynchronous, and calling it does not free the thread you are on.

The consequence is the one worth stating in an interview: a slow task on the common pool delays unrelated work elsewhere in the JVM. If one parallel stream calls a service that takes two seconds, the seven workers fill up with waiting, and every other parallel stream in the process queues behind it. Nothing in the API hints at this, and it does not show up in a benchmark of your code alone.

You can escape the common pool, and the way you do it is a trick

There is no argument for supplying your own pool. What there is instead is a documented side effect: a parallel stream evaluated inside a ForkJoinPool task uses that pool.

Compiled and run on this build
ForkJoinPool isolated = new ForkJoinPool(2);
Set<String> own = isolated.submit(() ->
    IntStream.range(0, 10_000).parallel()
        .mapToObj(i -> Thread.currentThread().getName())
        .collect(Collectors.toSet())).get();
isolated.shutdown();

System.out.println("  ran on the common pool at all? "
    + own.stream().anyMatch(n -> n.startsWith("ForkJoinPool.commonPool")));
System.out.println("  every thread from our own pool? "
    + own.stream().allMatch(n -> n.startsWith("ForkJoinPool-")));
Output
  ran on the common pool at all? false
  every thread from our own pool? true

Not one task touched the common pool. This is how you contain a parallel stream that must not affect the rest of the process — and it is worth being honest about what it is: a behaviour you rely on rather than an API you call. There is no parallelStream(pool) overload, and the isolation is a consequence of how ForkJoinTask finds its pool.

If you find yourself doing this, ask whether an ExecutorService and ordinary tasks would say what you mean more directly. Usually they would.

Shared mutable state, which sequential code was hiding

A sequential stream runs on one thread, so an unsynchronised accumulator works. Going parallel does not warn you that it no longer does:

Compiled and run on this build — output varies between runs
List<Integer> shared = new ArrayList<>();
AtomicInteger threw = new AtomicInteger();

IntStream.range(0, 100_000).parallel().forEach(i -> {
    try {
        shared.add(i);
    } catch (Throwable t) {
        threw.incrementAndGet();
    }
});

List<Integer> collected = IntStream.range(0, 100_000).parallel().boxed().toList();

System.out.println("  shared ArrayList : " + shared.size() + " of 100000, threw: " + threw.get());
System.out.println("  collect()        : " + collected.size() + " of 100000");
System.out.println("  order preserved  : "
    + IntStream.range(0, collected.size()).allMatch(i -> collected.get(i) == i));
Output
  shared ArrayList : 41532 of 100000, threw: 3
  collect()        : 100000 of 100000
  order preserved  : true

The count is different on every run, which is why this block is verified for shape. Roughly 60 percent of the elements vanished, and on some runs ArrayList also threw from inside add as two threads grew the backing array at once — the same class of corruption as a raced HashMap.

The collector loses nothing, and the last line is the part people do not expect: collect preserves encounter order even in parallel. Each worker accumulates into its own container and the results are combined in order. Order is not what parallelism costs you here — forEach is unordered, but the collectors are not.

So the rule is simple: in a parallel stream, accumulate with a collector, never into a variable you brought with you.

The reduction has to be associative

reduce splits the stream, reduces the parts independently and combines them. That is only equivalent to a left-to-right fold when the operator is associative — (a op b) op c equals a op (b op c).

Addition, multiplication, min, max and string concatenation are associative. Subtraction and division are not:

Stream.of(1, 2, 3, 4, 5).reduce(0, (a, b) -> a - b);              // -15
Stream.of(1, 2, 3, 4, 5).parallel().reduce(0, (a, b) -> a - b);   // depends on the split

The parallel answer depends on how the stream was split, which depends on the number of cores — so it can be right on your laptop and wrong in production, or right in production and wrong after someone resizes the container. There is no exception and no warning.

The identity has to be a real identity too: reduce(1, Integer::sum) gives a different answer sequentially and in parallel, because the identity is applied once per partition.

When it is actually worth it

Parallel streams are a good tool in a narrow case. All of these need to hold:

  • CPU-bound work. No IO, no database, no remote call. Blocking a common-pool worker is the failure mode above.
  • Enough data. Splitting, scheduling and merging cost real time. A few thousand elements of cheap work is slower in parallel.
  • A cheap, even split. ArrayList and arrays split by index and are ideal. LinkedList has to be walked. A Stream.iterate source cannot be split usefully at all.
  • No shared mutable state, and an associative reduction.
  • Spare cores. In a container limited to one or two cores, or a server already saturated by request threads, there is no idle capacity to use — you are taking it from something else.

For blocking IO, the answer since Java 21 is virtual threads, which is what people were really reaching for. For anything long-running, an ExecutorService you own is clearer, sizeable, and cannot starve someone else's work.


Reference

When it is worth it, how to contain it, and the alternatives. Copy from here.

The checklist — all of these, not some

[ ] CPU-bound work, no IO, no locks, no remote calls
[ ] Enough elements that the split pays for itself (thousands, not tens)
[ ] A cheaply splittable source: array, ArrayList, IntStream.range
[ ] No shared mutable state — accumulate with a collector
[ ] An associative reduction, with a true identity
[ ] Spare cores: not inside a request thread on a saturated service
[ ] Measured, sequentially first, on representative data

Fail any one and the sequential version is the right answer.

Sources, by how well they split

SourceSplits
array, IntStream.range, ArrayListperfectly, by index
HashMap, HashSetwell
LinkedList, Stream.iterate, BufferedReader.linesbadly or not at all

Containing it

// A parallel stream inside a ForkJoinPool task uses THAT pool.
// There is no parallelStream(pool) overload — this is the only way.
ForkJoinPool pool = new ForkJoinPool(4);
try {
    List<Result> results = pool.submit(() ->
        items.parallelStream().map(this::compute).toList()).get();
} finally {
    pool.shutdown();
}

// For blocking work, this is the wrong tool entirely. Java 21:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Report>> futures = ids.stream()
        .map(id -> executor.submit(() -> fetch(id)))
        .toList();
    for (var f : futures) results.add(f.get());
}

Correct and incorrect reductions

// Correct: summing is associative, and 0 is a true identity.
int total = list.parallelStream().mapToInt(Item::qty).sum();

// Correct: collectors accumulate per worker, then merge — and keep order.
List<String> names = list.parallelStream().map(Item::name).toList();

// WRONG: subtraction is not associative — the answer depends on the split.
list.parallelStream().reduce(0, (a, b) -> a - b);

// WRONG: 1 is not an identity for sum — applied once per partition.
list.parallelStream().reduce(1, Integer::sum);

// WRONG: a shared accumulator is a race, whatever the collection type.
List<Item> out = new ArrayList<>();
list.parallelStream().forEach(out::add);

Ordering

OperationOrder
collect, toListencounter order preserved
forEachOrderedencounter order, at a cost
forEachunordered
findFirstfirst in encounter order — more expensive in parallel
findAnywhatever finishes first

Diagnostics

ForkJoinPool.getCommonPoolParallelism();          // cores - 1
Thread.currentThread().getName();                 // ForkJoinPool.commonPool-worker-N
-Djava.util.concurrent.ForkJoinPool.common.parallelism=8   // JVM-wide, use with care

Scenarios

Real situations, with the decision and the argument.

1. An endpoint that never touches the reporting job starts timing out during it.

The reporting job uses parallelStream() over calls to a remote service. Those calls occupy every common-pool worker for their duration, and the endpoint's own parallel stream — or one inside a library it uses — queues behind them.

The fix is not a bigger pool. It is that blocking IO does not belong on the common pool at all: give the job its own executor sized for the remote service, or virtual threads on 21. The concurrency limit then becomes a decision someone made rather than a side effect of the core count.

2. parallelStream() makes a batch job twice as fast in a benchmark and no faster in production.

The benchmark had the machine to itself. In production the service is already using every core to serve requests, so there is no idle capacity to parallelise into — the work is just redistributed, with splitting and merging overhead added.

This is the case for measuring on a loaded system. It is also the argument for keeping heavy batch work off the request path entirely, at which point the parallel question becomes much less interesting.

3. A running total is slightly different every run and nobody can find the bug.

A non-associative reduction — pairwise averaging, subtraction, or a custom combine that is not associative. In parallel the answer depends on how the stream split, which depends on the core count, so it is stable on one machine and different on another.

Test for it directly: compute the same reduction sequentially and in parallel and assert they match. If they do not, the operator is the bug — not the parallelism, which is only exposing it.

4. A container is limited to one CPU and the parallel code behaves oddly.

At one core the common pool has a parallelism of one, so parallelStream() runs largely on the calling thread. Nothing breaks, but every assumption about concurrency evaporates — including any bug you were relying on tests to catch.

The practical consequence: code that is racy under parallel streams will pass in a constrained CI container and fail on a bigger production node. Check ForkJoinPool.getCommonPoolParallelism() when a concurrency test behaves suspiciously well.

5. A colleague proposes adding .parallel() to a hot path "since it can only help".

It can hurt three ways, and all three are worth naming: it can be slower for small collections, it can starve unrelated parallel work in the same JVM, and it can be incorrect if there is a shared accumulator or a non-associative reduction.

The reasonable counter is not a refusal but a measurement — sequential first, on representative data, on a loaded machine. If the gain is real and the checklist passes, take it. Adding it speculatively across a codebase is how a service acquires a class of bug it cannot reproduce.


Interviewer's Next Move

The question after the question. This is what the round is actually testing.

1. "Which pool does a parallel stream use?" The common ForkJoinPool — a JVM-wide singleton, with parallelism of availableProcessors() - 1. You did not create it and you do not own it, and the calling thread also participates.

2. "Why is that a problem?" Because everything shares it. One slow or blocking task fills the workers and every other parallel stream in the process queues behind it, including ones inside libraries. It does not show up when you benchmark your code in isolation.

3. "Is parallelStream() asynchronous?" No. The calling thread joins the work and blocks until the whole pipeline finishes. If you wanted to hand work off and continue, this is the wrong tool.

4. "How do you make a parallel stream use a different pool?" Evaluate it inside a task submitted to your own ForkJoinPool — the stream picks up the pool of the task it runs in. There is no overload that takes a pool, so this is a documented behaviour rather than an API, and often a sign an ExecutorService would be clearer.

5. "What breaks when you add .parallel() to working code?" Shared mutable accumulators become races, non-associative reductions start depending on core count, and any ordering assumption in forEach disappears. None of them produce an error.

6. "Does a parallel stream preserve order?" collect and toList do — each worker accumulates separately and results are combined in encounter order. forEach does not; use forEachOrdered if you need it, which gives up much of the parallelism.

7. "Why does reduce need an associative operator?" Because the stream is split, reduced independently and combined. Subtraction gives a different answer depending on the split, so the result depends on the number of cores — correct on one machine and wrong on another.

8. "When would you actually use one?" CPU-bound work, a large array-backed source, no IO, no shared state, an associative reduction, and spare cores — after measuring. Otherwise it is a way to make code slower and occasionally wrong.

Code traps

Trap A — predict before you run:

List<Order> valid = new ArrayList<>();
orders.parallelStream()
      .filter(Order::isValid)
      .forEach(valid::add);
Answer

Elements are lost — typically most of them — and ArrayList may also throw from inside add when two threads resize the backing array together. Nothing is synchronised, and forEach on a parallel stream runs on many threads.

.collect(Collectors.toList()) is correct and faster, because each worker accumulates into its own list and the lists are merged. Bringing your own accumulator to a parallel stream is the mistake; the collector exists precisely to avoid it.

Trap B:

List<Report> reports = ids.parallelStream()
    .map(id -> httpClient.fetchReport(id))   // ~500ms each
    .toList();
Answer

It works, it looks faster, and it occupies every common-pool worker for the duration. Any other parallel stream in the JVM — including inside a library — stalls behind it, and on a two-core container there is exactly one worker to fill.

This is IO, not CPU work, so the pool is the wrong mechanism entirely. Use virtual threads on 21, or an ExecutorService sized for the remote service, where the concurrency limit is a decision you made rather than a side effect of the core count.

Trap C:

double average = salaries.parallelStream()
    .reduce(0.0, (a, b) -> (a + b) / 2);
Answer

Not an average, and not even a stable wrong answer. Averaging pairwise is not associative, so the result depends on how the stream was split and therefore on the number of cores — the same code gives different numbers on a laptop and in a container.

It is also wrong sequentially: pairwise averaging weights later elements far more heavily. average() or Collectors.averagingDouble is the answer, and the parallel version of those is correct because summing is associative.

Common wrong answers

Said in interviewsReality
"It creates threads for you."It submits to one JVM-wide common pool you do not own.
"It's asynchronous."The calling thread joins the work and blocks until it finishes.
"Use it for slow IO calls — that's what it's for."Blocking a common-pool worker starves every other parallel stream.
"You pass it an executor."There is no such overload. You run it inside your own pool's task.
"Parallel loses ordering."forEach is unordered; collect and toList preserve encounter order.
"More data is always better for parallel."Also needs CPU-bound work, a cheap split and spare cores.

Check Yourself

Q1. Two unrelated services in one JVM both call parallelStream(). What connects them?

AnswerThe common ForkJoinPool — one static instance for the whole JVM, with parallelism of cores minus one. A slow task in one starves the other, and neither piece of code mentions a pool.

Q2. You add .parallel() and the results are sometimes wrong, with no exception. Name two causes.

AnswerA shared mutable accumulator — collecting into a list or map you brought yourself instead of using a collector — and a non-associative reduction, whose answer then depends on how the stream was split and therefore on the core count.

Q3. Does going parallel lose encounter order?

AnswerNot for collect or toList, which combine per-worker results in order. forEach is unordered by design; forEachOrdered restores the order and gives up much of the benefit of parallelism.


Practice

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 8LTS

    parallelStream and Stream.parallel arrived, backed by the common ForkJoinPool that was added in the same release.

    Before Java 8: Splitting work meant an ExecutorService you created, sized and shut down yourself — which was more code and made the pool's ownership obvious.

  2. Java 21LTS

    Virtual threads give a better answer for the blocking-IO case that people were reaching for parallelStream to solve. Parallel streams remain a CPU-bound tool.

    Before Java 21: Blocking calls inside a parallel stream tied up common-pool threads, and the workaround was a separate ForkJoinPool or a ManagedBlocker.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Questions that lead here

  • Why does a stream with no terminal operation do nothing?

    Intermediate operations only build a pipeline — they record what to do and return immediately. Nothing traverses the source until a terminal operation asks for a result, and then elements go through the whole pipeline one at a time rather than stage by stage.

    Asked constantlyjunior1–10 yrs10 min readJava8
  • How do Collectors.groupingBy and toMap differ in failure modes?

    They build almost the same thing and fail completely differently. toMap throws IllegalStateException on a duplicate key and NullPointerException on a null value; groupingBy does neither, because a group is a list and a list can hold anything. Both defaults are choices, and toMap's are the ones that reach production.

    Asked oftenintermediate1–10 yrs10 min readJava8

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.