Java Parallel Streams
Java Parallel Streams
A parallel stream takes the same pipeline you would write for a regular stream and runs it across multiple threads instead of one, splitting the source data into chunks that get processed concurrently before the partial results are merged back together. It exists to use the multiple CPU cores almost every machine already has — cores a sequential stream leaves completely idle no matter how large the dataset gets. The filter, map, and collect calls you already know stay identical; only the machinery producing elements behind the scenes changes.
What Is a Parallel Stream?
A parallel stream is not a separate type from a regular stream. java.util.stream.Stream carries an internal flag that decides whether its operations run sequentially on the calling thread or concurrently across a pool of worker threads, and every stream method works the same way regardless of which mode that flag is set to. There are two ways to get a stream into parallel mode: calling parallelStream() directly on a Collection, or calling .parallel() on an existing sequential stream. Both end up flipping the same internal flag, and isParallel() lets code check which mode a given stream is currently in.
Why Parallel Streams Were Introduced
Splitting a CPU-heavy computation across multiple threads used to mean creating an ExecutorService, dividing the input manually, submitting a Callable per chunk, and combining every Future's result by hand.
1// File: BeforeParallelStreams.java
2import java.util.*;
3import java.util.concurrent.*;
4
5public class BeforeParallelStreams {
6 public static void main(String[] args) throws Exception {
7 List<Integer> quantities = List.of(120, 340, 95, 480, 210, 60, 305, 150);
8
9 ExecutorService executor = Executors.newFixedThreadPool(4);
10 List<Future<Integer>> futures = new ArrayList<>();
11
12 int chunkSize = 4;
13 for (int i = 0; i < quantities.size(); i += chunkSize) {
14 List<Integer> chunk = quantities.subList(i, Math.min(i + chunkSize, quantities.size()));
15 futures.add(executor.submit(() -> chunk.stream().mapToInt(Integer::intValue).sum()));
16 }
17
18 int total = 0;
19 for (Future<Integer> future : futures) {
20 total += future.get();
21 }
22 executor.shutdown();
23
24 System.out.println("Total quantity: " + total);
25 }
26}Output:
Total quantity: 1760
parallelStream() collapses that entire submit-and-merge dance into one method call, with the JVM handling the splitting, thread assignment, and merging on its own.
1// File: AfterParallelStreams.java
2import java.util.*;
3
4public class AfterParallelStreams {
5 public static void main(String[] args) {
6 List<Integer> quantities = List.of(120, 340, 95, 480, 210, 60, 305, 150);
7
8 int total = quantities.parallelStream()
9 .mapToInt(Integer::intValue)
10 .sum();
11
12 System.out.println("Total quantity: " + total);
13 }
14}Output:
Total quantity: 1760
Both versions land on the identical total. The parallel stream version has no ExecutorService lifecycle for a reader to manage, and no manual bookkeeping of which Future belongs to which chunk.
Syntax
There are only two entry points into parallel execution, plus a way to step back out of it mid-pipeline.
1// File: ParallelStreamEntryPoint.java
2import java.util.*;
3
4public class ParallelStreamEntryPoint {
5 public static void main(String[] args) {
6 List<String> cities = List.of("Bengaluru", "Pune", "Hyderabad", "Chennai");
7
8 // Entry point 1 - directly from a Collection
9 long countA = cities.parallelStream().count();
10
11 // Entry point 2 - from an existing sequential Stream
12 long countB = cities.stream().parallel().count();
13
14 System.out.println("Count A: " + countA);
15 System.out.println("Count B: " + countB);
16 }
17}Output:
Count A: 4
Count B: 4
1// File: ParallelStreamModeSwitch.java
2import java.util.*;
3
4public class ParallelStreamModeSwitch {
5 public static void main(String[] args) {
6 List<Integer> ids = List.of(1, 2, 3, 4, 5);
7
8 boolean isParallelBeforeSwitch = ids.parallelStream().isParallel();
9
10 // sequential() flips the same internal flag back the other way
11 boolean isParallelAfterSwitch = ids.parallelStream()
12 .sequential()
13 .isParallel();
14
15 System.out.println("Before sequential(): " + isParallelBeforeSwitch);
16 System.out.println("After sequential(): " + isParallelAfterSwitch);
17 }
18}Output:
Before sequential(): true
After sequential(): false
Only the last call to .parallel() or .sequential() in a pipeline actually matters — whichever one appears closest to the terminal operation decides the mode the entire pipeline runs in.
How Parallel Streams Work Internally
A parallel stream's source is wrapped in a Spliterator, an object whose entire job is splitting a data source into smaller pieces that can be processed independently. When a terminal operation runs, the JVM recursively calls the spliterator's trySplit() method to divide the source into chunks, submits each chunk as a task to a shared thread pool, processes each chunk's pipeline independently on its assigned worker thread, and finally combines every chunk's partial result into the one final answer.
That shared thread pool is ForkJoinPool.commonPool(), sized by default to Runtime.getRuntime().availableProcessors() - 1 worker threads, plus the calling thread itself, which also does a share of the work rather than sitting idle waiting for workers to finish. This pool is not created fresh for each parallel stream — it is a single JVM-wide pool shared by every parallel stream, and by CompletableFuture's default async methods too.
One sentence before the diagram: a source collection gets divided recursively until each piece is small enough to process directly, and the results climb back up the same tree they were split down.
Source Collection
|
spliterator.trySplit()
|
+-------------+-------------+
| |
Chunk A Chunk B
| |
trySplit() again trySplit() again
| |
+-----+-----+ +-----+-----+
| | | |
Worker 1 Worker 2 Worker 3 Worker 4
(process) (process) (process) (process)
| | | |
+-----+-----+ +-----+-----+
| |
combine() combine()
| |
+-------------+-------------+
|
combine()
|
Final Result
Each level of the tree splits the work in half, processes the leaves independently on separate worker threads, and the combine step — the same combiner a three-argument reduce() or a Collector supplies — merges results back up one level at a time until a single answer remains.
How well a source splits depends entirely on its Spliterator implementation. An ArrayList or an array has an indexed spliterator that splits cleanly and cheaply into even halves. A LinkedList has to walk its nodes one by one to find a split point, making the split itself expensive enough that it can erase whatever benefit parallelism was supposed to add.
Common Use Cases
Verifying a Parallel Stream Actually Uses Multiple Threads
Checking whether more than one distinct thread name appears while processing confirms that a pipeline genuinely ran across the worker pool rather than staying on the calling thread alone, which is what happens automatically for very small inputs.
1// File: VerifyParallelExecutionExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class VerifyParallelExecutionExample {
6 public static void main(String[] args) {
7 long distinctThreadCount = IntStream.rangeClosed(1, 200_000)
8 .parallel()
9 .mapToObj(value -> Thread.currentThread().getName())
10 .distinct()
11 .count();
12
13 System.out.println("Used more than one thread: " + (distinctThreadCount > 1));
14 }
15}Output:
Used more than one thread: true
The exact thread names and their count depend on how many cores the running machine has, so the code checks only whether parallelism actually kicked in rather than printing the names themselves.
Parallel Aggregation Over a Large Numeric Range
Summing a large range of numbers is pure, stateless, and associative — exactly the shape of computation parallel streams are built for.
1// File: ParallelSumOfSquaresExample.java
2import java.util.stream.*;
3
4public class ParallelSumOfSquaresExample {
5 public static void main(String[] args) {
6 long sumOfSquares = LongStream.rangeClosed(1, 1_000_000)
7 .parallel()
8 .map(value -> value * value)
9 .sum();
10
11 System.out.println("Sum of squares: " + sumOfSquares);
12 }
13}Output:
Sum of squares: 333333833333500000
Parallel filter() and collect() Together
filter() and collect() work identically in a parallel pipeline as in a sequential one — the Collector supplied to collect() already carries the combiner needed to merge partial groups produced by different worker threads.
1// File: ParallelFilterCollectExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class ParallelFilterCollectExample {
6 public static void main(String[] args) {
7 List<Integer> orderAmounts = IntStream.rangeClosed(1, 50)
8 .boxed()
9 .map(value -> value * 25)
10 .toList();
11
12 List<Integer> highValueOrders = orderAmounts.parallelStream()
13 .filter(amount -> amount > 1000)
14 .collect(Collectors.toList());
15
16 System.out.println("High value order count: " + highValueOrders.size());
17 }
18}Output:
High value order count: 10
Restoring Order With forEachOrdered()
forEach() on a parallel stream does not guarantee encounter order — elements print in whatever order their worker thread finishes. forEachOrdered() gives up some of that parallelism to guarantee the same order a sequential stream would produce.
1// File: ForEachOrderedExample.java
2import java.util.*;
3
4public class ForEachOrderedExample {
5 public static void main(String[] args) {
6 List<String> stepNames = List.of("Validate", "Reserve stock", "Charge payment", "Confirm order");
7
8 StringBuilder orderedSteps = new StringBuilder();
9 stepNames.parallelStream()
10 .forEachOrdered(step -> orderedSteps.append(step).append(" -> "));
11
12 System.out.println(orderedSteps);
13 }
14}Output:
Validate -> Reserve stock -> Charge payment -> Confirm order ->
Real-World Example
A nightly batch job recalculating GST-inclusive final prices across a large product catalog is exactly the kind of workload parallel streams were built for — the calculation for each product is pure, touches no shared state, and depends on nothing outside that one product record. Running it across catalogs with hundreds of thousands of SKUs on a single thread wastes every core beyond the first one sitting idle.
1// File: Product.java
2
3public record Product(String sku, double basePrice) {}1// File: PricingEngine.java
2import java.util.*;
3
4public class PricingEngine {
5
6 private static final double GST_RATE = 0.18;
7
8 public List<Double> calculateFinalPrices(List<Product> catalog) {
9 return catalog.parallelStream()
10 .map(product -> product.basePrice() * (1 + GST_RATE))
11 .toList();
12 }
13}1// File: PricingEngineDemo.java
2import java.util.*;
3import java.util.stream.*;
4
5public class PricingEngineDemo {
6 public static void main(String[] args) {
7 List<Product> catalog = IntStream.rangeClosed(1, 100_000)
8 .mapToObj(id -> new Product("SKU-" + id, 199.0 + (id % 500)))
9 .toList();
10
11 PricingEngine pricingEngine = new PricingEngine();
12
13 List<Double> finalPrices = pricingEngine.calculateFinalPrices(catalog);
14 double totalCatalogValue = finalPrices.stream().mapToDouble(Double::doubleValue).sum();
15
16 System.out.println("Products priced: " + finalPrices.size());
17 System.out.printf("Total catalog value: %.2f%n", totalCatalogValue);
18 }
19}Output:
Products priced: 100000
Total catalog value: 52923000.00
Switching PricingEngine.calculateFinalPrices() between .stream() and .parallelStream() changes nothing about the numbers produced — the totals stay identical either way, since GST calculation on one product never depends on another. That is precisely the property that makes a computation a safe candidate for parallelism in the first place, and it is the first thing worth checking before reaching for parallelStream() on any existing pipeline.
Performance Considerations
Parallel streams do not change the asymptotic complexity of an operation — summing a million numbers is still an O(n) operation whether it runs on one thread or eight. What changes is the wall-clock time, and only when the per-element work is expensive enough, and the collection large enough, that splitting and merging overhead is small next to the actual computation being parallelized.
For small collections or cheap per-element operations, that overhead frequently costs more than a sequential pass would have taken in the first place — a parallel stream over a list of ten elements almost always runs slower than a sequential one over the same list. ArrayList and arrays split efficiently because their spliterators support fast, indexed splitting; LinkedList and I/O-backed streams split poorly, since finding a split point means walking nodes one at a time.
ForkJoinPool.commonPool() is shared across the entire JVM process, not created fresh per parallel stream. Blocking I/O called from inside a parallel stream's pipeline occupies a worker thread for the duration of that block, which can starve unrelated parallel streams and CompletableFuture tasks elsewhere in the same application that are also waiting on that same shared pool.
Best Practices
Reach for a parallel stream only when the operation is CPU-bound, stateless, and the collection is large enough that splitting overhead is genuinely small next to the actual work — the pricing engine example above fits all three conditions.
Keep every lambda passed into a parallel pipeline free of shared mutable state. Each worker thread runs the same lambda independently, and any variable the lambda writes to outside its own scope becomes a race condition the moment more than one thread touches it concurrently.
Measure before trusting intuition about whether parallel is faster for a specific workload — hardware core count, JIT warm-up, and the actual cost of the per-element operation all affect the answer, and guessing wrong in either direction is common even among experienced developers.
Avoid running long blocking I/O calls inside a parallel stream's pipeline. Since it shares the same common pool the rest of the JVM relies on, a blocked worker thread there has effects reaching well beyond that one stream.
Common Mistakes
Assuming parallelStream() is a free performance upgrade over stream() is the single most common mistake beginners make with it. On small collections, or where each element's operation is cheap, the fixed cost of splitting the source and merging results back together typically outweighs whatever time was saved by spreading the work across threads.
1// File: SmallCollectionParallelMistake.java
2import java.util.*;
3
4public class SmallCollectionParallelMistake {
5 public static void main(String[] args) {
6 List<Integer> fewNumbers = List.of(1, 2, 3, 4, 5);
7
8 // A five-element list gains nothing from parallelStream() - the
9 // splitting and thread coordination overhead costs more than a
10 // sequential pass over five elements ever would
11 int total = fewNumbers.parallelStream()
12 .mapToInt(Integer::intValue)
13 .sum();
14
15 System.out.println("Total: " + total);
16 System.out.println("Correct result, but parallelStream added pure overhead here");
17 }
18}Output:
Total: 15
Correct result, but parallelStream added pure overhead here
Writing to a plain, non-thread-safe collection like an ArrayList from inside a parallel forEach() is a mistake that appears often in fresher pull requests, since it looks harmless in isolation and often even runs without an obvious error during casual testing. ArrayList.add() is not safe to call from multiple threads at once — concurrent calls can silently drop elements, corrupt internal state, or throw an exception, and which of those happens is not something the code controls or can rely on.
1// File: SharedMutableStateMistake.java
2import java.util.*;
3
4public class SharedMutableStateMistake {
5
6 public List<Integer> collectSquaresUnsafely(List<Integer> numbers) {
7 List<Integer> results = new ArrayList<>();
8 // Multiple worker threads call add() on the same ArrayList here at
9 // the same time - ArrayList makes no thread-safety guarantee at all,
10 // so results can end up missing entries or throw at runtime
11 numbers.parallelStream().forEach(number -> results.add(number * number));
12 return results;
13 }
14}The fix is never to synchronize the ArrayList by hand — it is to let collect(Collectors.toList()) do the accumulation instead, since its combiner already handles merging partial results from every worker thread correctly.
Interview Questions
Q1. What is a parallel stream, and how do you create one?
A parallel stream is a regular stream running in a mode where its pipeline executes across multiple threads instead of one, splitting the source into chunks processed concurrently before merging results back together. It is created either by calling parallelStream() directly on a Collection, or by calling .parallel() on an already-existing sequential Stream.
Q2. How does a parallel stream actually split and execute work internally?
The source is wrapped in a Spliterator, whose trySplit() method recursively divides it into smaller chunks. Each chunk is submitted as a task to ForkJoinPool.commonPool(), processed independently by a worker thread, and the partial results are merged back together using the combiner supplied by the terminal operation — the third argument of a three-argument reduce(), or a Collector's combiner. Product-based interviewers commonly probe whether a candidate knows this is ForkJoinPool.commonPool() specifically, and that it is shared JVM-wide rather than created fresh per stream.
Q3. When would you avoid using a parallel stream?
Whenever the collection is small, the per-element operation is cheap, the source is a poorly-splitting structure like a LinkedList, or the operation involves blocking I/O. In each case, the overhead of splitting and coordinating threads costs more than whatever time parallel execution could have saved, and blocking I/O on the shared common pool risks starving unrelated work elsewhere in the same JVM.
Q4. Why must the accumulator and combiner used with a parallel stream be associative?
Because a parallel stream is free to split elements into arbitrary groups, reduce each group independently on a different thread, and merge partial results afterward — and that only produces a consistent answer if the grouping chosen does not change the final result. A non-associative operation, like subtraction, can produce a different answer purely depending on how the elements happened to be grouped across threads.
Q5. What thread pool do parallel streams use by default, and why does that matter?
ForkJoinPool.commonPool(), a single pool shared across the entire JVM process, also used by default by CompletableFuture's async methods. Because it is shared, a parallel stream that blocks a worker thread on I/O can starve unrelated code elsewhere in the application that is also relying on that same pool — a subtlety that separates candidates who have only used parallel streams in isolation from those who have reasoned about them in a larger running application.
Q6. Does a parallel stream guarantee faster execution than a sequential one?
No. It only tends to be faster when the collection is large enough and the per-element work expensive enough that the actual computation dominates the fixed cost of splitting the source and merging results. For small collections or cheap operations, a parallel stream commonly runs slower than the equivalent sequential one, which is why measuring on the real workload matters more than assuming parallel is automatically better.
Q7. How do you safely accumulate results from a parallel stream into a collection?
Use collect() with a Collector such as Collectors.toList() rather than writing to a shared, plain collection like an ArrayList from inside forEach(). Collector implementations are built with a combiner specifically designed to merge partial results from separate worker threads correctly, while a plain ArrayList offers no thread-safety guarantee at all for concurrent writes.
FAQs
Is parallelStream() always faster than stream()?
No. It depends entirely on collection size, how expensive each element's operation is, and how well the source splits. For small collections or cheap operations, parallelStream() frequently runs slower than stream() because of splitting and merging overhead.
How many threads does a parallel stream use by default?
Runtime.getRuntime().availableProcessors() - 1 worker threads from ForkJoinPool.commonPool(), plus the calling thread itself, which also processes a share of the work rather than waiting idle.
Can I control how many threads a parallel stream uses?
Not directly on the stream itself, but the pipeline can be submitted to a custom ForkJoinPool with a chosen parallelism level instead of relying on the shared common pool, which is the standard way to isolate a parallel stream's thread usage from the rest of the application.
Is forEach() ordered on a parallel stream?
No, not by default. Elements are processed and printed in whatever order their assigned worker thread happens to finish. forEachOrdered() restores the same encounter order a sequential stream would use, trading away some of the parallelism to do it.
Do parallel streams work with LinkedList the same way they work with ArrayList?
They run without error on both, but performance differs significantly. ArrayList's indexed spliterator splits cheaply and evenly, while LinkedList has to walk its nodes to find a split point, making that split itself expensive enough to erase much of the benefit parallelism was meant to add.
Is it safe to modify a shared ArrayList from inside a parallel stream's forEach()?
No. ArrayList provides no thread-safety guarantee, and multiple worker threads calling add() on it concurrently can silently drop elements, corrupt its internal state, or throw an exception. Use collect(Collectors.toList()) instead, which merges partial results from each worker thread correctly.
Should I use parallel streams inside a Spring Boot REST controller method?
Generally no, for typical request-handling workloads. Most controller logic is small in scale and often involves I/O like database or network calls, both situations where a parallel stream's overhead outweighs any benefit, and where blocking the shared common pool can affect unrelated requests elsewhere in the same application.
Summary
A parallel stream is the same pipeline you already know how to write, running across the ForkJoinPool's worker threads instead of just the calling thread — the mental model to keep is source, split, process, and combine, exactly as the diagram earlier in this article laid out. It earns its keep specifically on large, CPU-bound, stateless computations like the pricing engine example, and it costs more than it saves on small collections, cheap operations, or anything involving blocking I/O.
The one habit worth carrying forward from here is checking whether an operation is genuinely associative and free of shared mutable state before reaching for parallelStream() on it — that single check is what separates a safe performance win from a subtle, hard-to-reproduce concurrency bug.
What to Read Next
See how a normal Stream and a parallel Stream are different.