Parallel Streams
A parallel stream can split work across multiple CPU cores automatically, but it only pays off when the workload, the source, and the operations are a good fit.
Any Stream obtained from a collection or Stream.of can be turned into a parallel stream with .parallel(), or a collection can hand one out directly via Collection.parallelStream(). Internally, a parallel stream splits its source into chunks, processes those chunks concurrently on the common ForkJoinPool, and combines the partial results. This can be a significant speedup for large, CPU-heavy, easily-splittable workloads — and a significant slowdown, or a source of subtle bugs, for everything else.
Creating a Parallel Stream
There are two ways to get a parallel stream: call .parallelStream() on a Collection, or call .parallel() on any existing sequential stream (this returns a new stream view, it does not mutate the original pipeline in place). .sequential() switches back. Whichever call appears last before the terminal operation wins — the property is decided once, at the point the pipeline actually executes.
Both parallelStream() on the List and IntStream.rangeClosed(...).parallel() produce a stream that may process elements across multiple threads before the terminal sum() combines the results.
import java.util.List;
import java.util.stream.IntStream;
public class ParallelBasics {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);
long sum = numbers.parallelStream()
.mapToLong(Integer::longValue)
.sum();
System.out.println(sum);
long total = IntStream.rangeClosed(1, 1_000_000)
.parallel()
.asLongStream()
.sum();
System.out.println(total);
}
}Relationship to the Common ForkJoinPool
Parallel streams do not create their own dedicated thread pool. By default, they submit work to ForkJoinPool.commonPool() — a JVM-wide, lazily-initialized pool shared by every parallel stream (and by any other code using CompletableFuture's default executor or ForkJoinPool.commonPool() directly) in the process. Its default parallelism level is Runtime.getRuntime().availableProcessors() - 1 worker threads, JVM-dependent and tunable only via the java.util.concurrent.ForkJoinPool.common.parallelism system property.
Because the common pool is process-wide and shared, a parallel stream running inside, say, a servlet request can end up competing for the same worker threads as other unrelated parallel streams or ForkJoinTasks running elsewhere in the same JVM. This shared-resource contention is a common source of unpredictable throughput in server applications that use parallel streams heavily.
It is possible to run a parallel stream inside a custom ForkJoinPool by submitting the pipeline as a task to that pool, which isolates it from the common pool — but this is an advanced, somewhat fragile technique (it relies on internal fork/join submission behavior) and is not part of the public Stream API contract.
How Splitting Works: the Spliterator
A parallel stream's source is decomposed using a Spliterator ("splitting iterator"), which repeatedly divides the data into roughly equal-sized chunks via trySplit() until the chunks are small enough (or the source resists further splitting), and then processes each chunk, combining results as sibling tasks finish — the classic fork/join divide-and-conquer pattern.
How well a source splits matters enormously. ArrayList, arrays, and IntStream.range sources split cheaply and evenly because they support fast, balanced random-access division. LinkedList, Stream.iterate, and I/O-backed sources (like BufferedReader.lines()) split poorly or not at all, because the spliterator has to walk the structure sequentially to find split points — parallelizing such a source often yields no benefit, or is even slower than sequential due to overhead.
Good parallel sources (cheap, balanced splitting):
ArrayList, arrays, IntStream.range/rangeClosed, HashSet
Poor parallel sources (expensive or unbalanced splitting):
LinkedList, Stream.iterate, Files.lines() in some cases,
sources with unknown/unpredictable sizeReduction Requirements
Terminal operations that combine results across chunks (reduce, collect, sum, count, and similar) require the combining function to satisfy certain properties for a parallel run to produce the same answer a sequential run would:
The accumulator and combiner must be associative — grouping order must not change the result — because chunks are combined pairwise in an unspecified tree order. They should also be stateless and non-interfering (not depend on or mutate shared mutable state outside the stream), since chunks may run concurrently on different threads.
Integer::sum is associative, so parallel reduction always yields 15 regardless of how chunks were split and recombined. Subtraction is not associative, so its parallel result is unreliable even though it may look consistent on a small list.
For collect(...), the supplier, accumulator, and combiner of a Collector have the same requirements; most built-in Collectors (toList, toSet, groupingBy, summingInt, etc.) are written to satisfy them, but a hand-written Collector must be designed carefully to be parallel-safe.
import java.util.List;
public class ReductionRequirement {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// Associative: (a + b) + c == a + (b + c) -- safe for parallel reduce
int sum = nums.parallelStream().reduce(0, Integer::sum);
// NOT associative in general: subtraction depends on grouping order
int suspicious = nums.parallelStream().reduce(0, (a, b) -> a - b);
System.out.println(sum); // always 15
System.out.println(suspicious); // result depends on internal split/combine order
}
}Ordering in Parallel Streams
An ordered stream (one from a List, an array, or Stream.of) still preserves encounter order for order-sensitive operations even when run in parallel — forEachOrdered, collect(toList()), findFirst(), and limit()/skip() all respect it, at the cost of extra bookkeeping to reassemble chunks in the original order. forEach (unordered variant) makes no such promise and may be faster because it skips that reassembly.
Calling .unordered() on a stream explicitly tells the pipeline that encounter order does not matter, which can let certain operations (distinct, limit) run more efficiently in parallel because they no longer need to preserve or track order.
Side Effects Are Dangerous
Lambdas passed to a parallel stream (the mapper, the predicate, the accumulator) may run concurrently on different worker threads. Any lambda that mutates shared, non-thread-safe state outside the stream is a data race — the outcome is undefined and may vary from run to run, even though it might "happen to work" during casual testing.
ArrayList.add is not thread-safe. Concurrent calls from multiple parallel-stream worker threads can lose elements, throw ArrayIndexOutOfBoundsException, or leave the list in a corrupted state. Use collect(Collectors.toList()) instead, which handles combination safely.
The fix is almost always to stop using forEach-with-a-side-effect and instead express the computation declaratively with map/filter/collect/reduce, which the Streams API itself parallelizes safely.
import java.util.ArrayList;
import java.util.List;
public class UnsafeSideEffect {
public static void main(String[] args) {
List<Integer> source = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> results = new ArrayList<>(); // not thread-safe
source.parallelStream().forEach(results::add); // DATA RACE
System.out.println(results.size()); // may print < 10, may throw, may corrupt internal state
}
}When Parallel Streams Hurt Performance
Parallelism adds fixed overhead: splitting the source, scheduling fork/join tasks, and merging partial results. That overhead only pays for itself when there is enough total work per element and enough elements to spread across cores. Parallel streams commonly perform worse than sequential streams when:
The dataset is small (thousands of elements or fewer, for cheap per-element work) — splitting overhead dwarfs the actual computation. The per-element operation is cheap (simple arithmetic, a short string comparison) rather than CPU-intensive. The source splits poorly (LinkedList, Stream.iterate, I/O-backed sources). The operation involves I/O, blocking, or synchronization, which stalls worker threads that are meant to be shared, CPU-bound resources — this can also starve other unrelated code using the common pool. Autoboxing-heavy pipelines add overhead that a sequential stream also pays, but which becomes more visible relative to smaller parallel gains.
Rule of thumb: don't reach for .parallel() by default. Measure with a real benchmark (e.g. JMH) on realistic data sizes before and after — intuition about which pipelines benefit from parallelism is frequently wrong.
Practical Usage
Good candidates for parallel streams: large in-memory numeric or CPU-bound transformations over arrays or ArrayLists (image processing, mathematical simulations, bulk validation), where element count is large (commonly tens of thousands or more) and per-element work is non-trivial.
A large, cheaply-splittable IntStream range combined with a genuinely CPU-heavy per-element predicate (primality testing) is exactly the profile where parallel streams tend to help.
import java.util.stream.IntStream;
public class GoodCandidate {
static boolean isPrime(int n) {
if (n < 2) return false;
for (int i = 2; (long) i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
public static void main(String[] args) {
long primeCount = IntStream.rangeClosed(2, 2_000_000)
.parallel()
.filter(GoodCandidate::isPrime)
.count();
System.out.println(primeCount);
}
}Edge Cases
findAny() on a parallel stream may return a different element on different runs (it returns whichever matching element some worker finds first), unlike findFirst() which is deterministic for an ordered source. Use findAny() only when any match is acceptable.
Exceptions thrown inside a parallel stream's lambda propagate to the calling thread once other in-flight subtasks are accounted for, but which particular element's exception surfaces first is not guaranteed when multiple elements would throw.
Nesting parallel streams (a parallel stream whose element-processing itself launches another parallel stream) shares the same common pool and can lead to pool starvation or deadlock-like stalls in pathological cases, because a blocked outer task can hold a worker thread the inner stream needs.
Mental Model
Think of a parallel stream as handing your data to a work crew: a foreman (the spliterator) divides the pile into smaller piles, workers (ForkJoinPool threads) each process one pile independently, and a final combiner stitches the partial results back together in whatever order the combining rule allows. The crew is worth hiring only when there is enough pile to divide and enough work per item — for a handful of items or trivial work, calling the foreman costs more than just doing it yourself.
Quick Reference
collection.parallelStream() : parallel stream from a Collection
stream.parallel() / .sequential(): switch mode (decided at terminal op)
Backed by : ForkJoinPool.commonPool() by default
Good sources : ArrayList, arrays, IntStream.range
Poor sources : LinkedList, Stream.iterate, some I/O
Reduction requirement : associative, stateless combiner
Ordering : preserved for ordered sources unless .unordered()
Avoid : shared mutable state in lambdas, I/O/blocking ops
Use when : large data + expensive per-element CPU work, measuredKey Takeaways
A parallel stream splits its source via a Spliterator and runs chunks on the shared ForkJoinPool.commonPool() by default.
Reduction and collection operations require an associative, stateless combining function to give correct, reproducible results in parallel.
Ordered sources still preserve encounter order for order-sensitive operations unless the stream is explicitly marked unordered.
Mutating shared, non-thread-safe state from a parallel stream's lambdas is a data race — express computations declaratively instead.
Parallel streams help only for large, cheaply-splittable sources with genuinely expensive per-element CPU work; small data, cheap operations, or poorly-splitting sources usually make them slower — always measure.
List<Integer> out = new ArrayList<>(); nums.parallelStream().forEach(out::add);
List<Integer> out = nums.parallelStream() .collect(Collectors.toList());
Adding to a plain ArrayList from multiple parallel-stream threads is a data race. collect(...) uses a parallel-safe combining strategy internally.
smallList.parallelStream().map(x -> x * 2).forEach(System.out::println);
smallList.stream().map(x -> x * 2).forEach(System.out::println);
For a small collection and cheap per-element work, the overhead of splitting and coordinating threads outweighs any benefit -- sequential is simpler and usually faster.
new ArrayList<>(linkedListOfMillions).parallelStream().mapToInt(...).sum();
LinkedList splits poorly because finding a midpoint requires sequential traversal. Converting to an ArrayList (or an array) first gives the spliterator cheap, balanced splits.
int result = nums.parallelStream().reduce(0, (a, b) -> a - b);
int result = nums.stream().reduce(0, (a, b) -> a - b); // or restructure with an associative op
Subtraction is not associative, so a parallel reduce with it can produce different, order-dependent results. Only use reduce with an associative accumulator in parallel.
What thread pool do parallel streams use by default, and what determines its parallelism level?
ForkJoinPool.commonPool(), a JVM-wide shared pool; its default worker count is Runtime.getRuntime().availableProcessors() - 1, JVM-dependent and adjustable via the java.util.concurrent.ForkJoinPool.common.parallelism system property.
Why must the combiner passed to reduce or collect be associative for parallel use?
Because a parallel stream splits the source into chunks and combines partial results pairwise in an unspecified tree order (not necessarily left-to-right); a non-associative combiner produces results that depend on that unspecified grouping, giving inconsistent answers.
Is it safe to accumulate results into a plain ArrayList inside a parallelStream().forEach() call? Why or why not?
No. ArrayList is not thread-safe, and forEach's lambda may run concurrently on multiple worker threads, so concurrent add calls are a data race that can lose elements, throw exceptions, or corrupt the list. Use collect(Collectors.toList()) instead.
Why does LinkedList make a poor source for a parallel stream compared to ArrayList?
ArrayList supports fast, balanced random-access splitting (indexing straight to a midpoint), while LinkedList's spliterator must traverse nodes sequentially to find a split point, making splitting expensive and often unbalanced, which erodes or eliminates any parallel speedup.
Give two concrete conditions under which switching a stream from sequential to parallel is likely to make it slower.
Small element counts (overhead of splitting/scheduling/merging dominates), and/or cheap per-element work (arithmetic, simple comparisons) where the fixed cost of coordinating threads outweighs the actual computation; also poorly-splitting sources or I/O-bound/blocking operations.
What is the difference between findFirst() and findAny() on a parallel stream, and when would you prefer each?
findFirst() is deterministic and always returns the first matching element in encounter order (at the cost of extra coordination in parallel); findAny() returns whichever matching element some worker finds first, which can vary between runs but is cheaper. Prefer findAny() when any match is acceptable and order doesn't matter; findFirst() when a specific, reproducible element is required.
Why can nested parallel streams (a parallel stream whose per-element work launches another parallel stream) be risky?
Both levels share the same common ForkJoinPool; if an outer task blocks waiting on an inner parallel stream that needs worker threads the pool doesn't have free (because the outer task itself is occupying one), it can lead to pool starvation or deadlock-like stalls.
Write a method that computes the sum of squares of a large int array using a parallel stream, safely and correctly.
public class SumOfSquares { static long sumOfSquares(int[] data) { return java.util.stream.IntStream.of(data) .parallel() .mapToLong(x -> (long) x * x) .sum(); } public static void main(String[] args) { int[] data = new int[1_000_000]; for (int i = 0; i < data.length; i++) data[i] = i; System.out.println(sumOfSquares(data)); } }
IntStream.of(data).parallel().mapToLong(x -> (long) x * x).sum() splits the array (a good parallel source), maps each element to its square as a long (avoiding overflow), and sums with an associative, side-effect-free reduction -- safe and correct for parallel execution.
What is wrong with this code, and what might happen when it runs?
out is a plain, non-thread-safe ArrayList being mutated concurrently by multiple parallel-stream worker threads via add(). This is a data race: the output may sometimes print 5, but can also print a smaller number, throw an ArrayIndexOutOfBoundsException, or otherwise misbehave, especially with larger inputs -- results are undefined and not reliably reproducible. The fix is to use nums.parallelStream().map(n -> n * n).collect(Collectors.toList()).
Write a program that counts prime numbers from 2 to 3,000,000 using a parallel IntStream, and explain why this is a good candidate for parallelism.
public class ParallelPrimeCount { static boolean isPrime(int n) { if (n < 2) return false; for (int i = 2; (long) i * i <= n; i++) { if (n % i == 0) return false; } return true; } public static void main(String[] args) { long count = java.util.stream.IntStream.rangeClosed(2, 3_000_000) .parallel() .filter(ParallelPrimeCount::isPrime) .count(); System.out.println(count); } }
This is a good candidate because IntStream.rangeClosed splits cheaply and evenly, the dataset is large (millions of elements), and isPrime is genuinely CPU-intensive per element with no shared state or side effects -- exactly the profile where the coordination overhead of parallelism is paid back by real computational savings.
Why can this line produce a different result depending on internal split/combine order, and is that a bug in the JVM?
Subtraction is not associative -- (a - b) - c is not generally equal to a - (b - c) -- so grouping the elements differently during the parallel combine phase changes the answer. This is not a JVM bug; it is a misuse of reduce, which requires an associative accumulator/combiner for parallel (and even sequential-vs-parallel-consistent) correctness. The fix is to only use reduce with associative operations like addition, multiplication, min, or max.
List<Integer> nums = List.of(1, 2, 3, 4, 5);
List<Integer> out = new ArrayList<>();
nums.parallelStream().forEach(n -> out.add(n * n));
System.out.println(out.size());int result = List.of(10, 3, 2).parallelStream().reduce(100, (a, b) -> a - b);