One of the easiest ways to make Java code run in parallel is to replace:
|
1 2 |
stream() |
with:
|
1 2 |
parallelStream() |
For example:
|
1 2 3 4 |
long total = numbers.stream() .mapToLong(this::calculateScore) .sum(); |
can be changed to:
|
1 2 3 4 |
long total = numbers.parallelStream() .mapToLong(this::calculateScore) .sum(); |
At first glance, this looks like an obvious performance improvement. Java can now process multiple elements concurrently using multiple CPU cores.
But there is an important catch:
Parallelism is not free.
In many situations, simply changing stream() to parallelStream() can actually make your application slower.
To understand why, we need to look at what Java actually does when executing a parallel stream.
Understanding a Sequential Stream
Let’s start with a simple example:
|
1 2 3 |
List<Integer> numbers = List.of(10, 20, 30, 40, 50); |
Suppose we want to calculate a score for every number and then add all the scores together.
We could write:
|
1 2 3 4 |
long total = numbers.stream() .mapToLong(this::calculateScore) .sum(); |
And our calculation method is:
|
1 2 3 4 |
private long calculateScore(int number) { return number * 10L; } |
Let’s break this down.
numbers.stream()
This creates a sequential stream from the list.
Sequential means that one thread processes the elements one after another.
mapToLong()
For every element, Java calls:
|
1 2 |
calculateScore() |
For example:
|
1 2 3 4 5 6 |
10 → 100 20 → 200 30 → 300 40 → 400 50 → 500 |
sum()
Finally, Java adds all the calculated values:
|
1 2 |
100 + 200 + 300 + 400 + 500 = 1500 |
Conceptually, the execution looks like this:
|
1 2 3 4 5 6 7 8 |
10 → calculateScore() → 100 20 → calculateScore() → 200 30 → calculateScore() → 300 40 → calculateScore() → 400 50 → calculateScore() → 500 Total = 1500 |
One thread performs all of this work.
What Changes With parallelStream()?
Now let’s make one small change:
|
1 2 3 4 |
long total = numbers.parallelStream() .mapToLong(this::calculateScore) .sum(); |
The result is still:
|
1 2 |
1500 |
But internally, the execution is different.
Java attempts to divide the collection into smaller pieces.
For example, conceptually we might have:
|
1 2 3 4 |
Worker 1 → 10, 20 Worker 2 → 30 Worker 3 → 40, 50 |
Each worker calculates a partial result:
|
1 2 3 4 |
Worker 1 → 100 + 200 = 300 Worker 2 → 300 Worker 3 → 400 + 500 = 900 |
Java then combines those results:
|
1 2 |
300 + 300 + 900 = 1500 |
That’s the basic idea behind parallel streams.
However, Java doesn’t create a brand-new thread for every element.
Instead, the stream source is divided using a Spliterator. The resulting work is represented as fork/join tasks and is generally executed through Java’s common ForkJoinPool.
The pool maintains a limited number of worker threads and distributes tasks among them.
If one worker finishes early, it can take work from another worker’s queue. This mechanism is called work stealing.
This helps keep CPU cores busy.
But all of this introduces additional work that doesn’t exist in the sequential version.
The Cost of Parallel Processing
Before Java can process elements in parallel, it has to do more work.
It may need to:
- Split the data
- Create tasks
- Schedule tasks
- Coordinate workers
- Combine partial results
Conceptually:
|
1 2 3 4 5 6 7 8 |
Sequential Data ↓ Process ↓ Result |
Whereas parallel execution looks more like:
|
1 2 3 4 5 6 |
┌─ Worker 1 ─┐ │ │ Data → Split ────┼─ Worker 2 ──┼→ Combine → Result │ │ └─ Worker 3 ─┘ |
The key question is therefore not:
“Can this operation run in parallel?”
The better question is:
“Is there enough work to justify the cost of parallelism?”
Parallel processing is an investment.
Java spends additional time organizing the parallel work, hoping that using multiple CPU cores will save more time than that overhead costs.
If the savings are smaller than the overhead, the parallel stream becomes slower.
Example 1: A Cheap Operation
Consider this:
|
1 2 3 4 |
long total = numbers.stream() .mapToLong(number -> number * 2L) .sum(); |
Multiplying a number by two is extremely cheap.
The CPU can perform this operation very quickly.
Now compare it with:
|
1 2 3 4 |
long total = numbers.parallelStream() .mapToLong(number -> number * 2L) .sum(); |
The parallel version has additional work to perform:
|
1 2 3 4 5 6 7 8 9 10 |
Split data ↓ Create tasks ↓ Schedule tasks ↓ Execute workers ↓ Combine results |
But the actual computation is simply:
|
1 2 |
number × 2 |
The multiplication may take less time than the overhead of managing the parallel execution.
That’s why parallel streams can perform worse for small collections and cheap operations.
The problem isn’t that parallelism failed.
The problem is that there wasn’t enough useful work to justify the overhead.
Example 2: An Expensive Operation
Now consider a more computationally expensive operation.
Suppose we want to determine how many numbers are prime:
|
1 2 3 4 |
long primeCount = numbers.stream() .filter(this::isPrime) .count(); |
Our isPrime() method could look like this:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private boolean isPrime(long number) { if (number < 2) { return false; } for (long divisor = 2; divisor <= number / divisor; divisor++) { if (number % divisor == 0) { return false; } } return true; } |
Checking whether a large number is prime requires considerably more CPU work than multiplying a number by two.
More importantly, each number can be checked independently.
The result of checking one number doesn’t depend on the result of checking another number.
That makes the operation a much better candidate for parallel execution.
We can write:
|
1 2 3 4 |
long primeCount = numbers.parallelStream() .filter(this::isPrime) .count(); |
Now different CPU cores can potentially check different numbers at the same time.
If the collection is sufficiently large and each operation requires meaningful computation, the time saved through parallel execution can be greater than the overhead.
In that situation, the parallel stream may be faster.
It’s Not Just About the Number of Elements
A common misconception is:
“If I have a large collection, I should use
parallelStream().”
That’s not necessarily true.
Two factors are particularly important:
|
1 2 3 4 |
1. How many elements do I have? 2. How expensive is the work performed for each element? |
Consider these two scenarios.
Scenario A
|
1 2 3 |
1,000,000 elements Very cheap operation |
Scenario B
|
1 2 3 |
10,000 elements CPU-intensive operation |
The second scenario may benefit more from parallelism.
There is no universal rule such as:
“Use parallel streams whenever the collection contains more than 10,000 elements.”
The break-even point depends on factors such as:
- The operation being performed
- Collection size
- Hardware
- JVM state
- Data source
- Runtime environment
The right approach is to measure the actual workload.
The Data Source Also Matters
Parallel processing works best when Java can divide the source into reasonably balanced chunks.
For example:
|
1 2 3 4 5 |
LongStream.rangeClosed(1, 10_000_000) .parallel() .filter(this::isPrime) .count(); |
A numeric range is relatively easy to partition.
Conceptually, it could be divided into:
|
1 2 |
1 → 5,000,000 |
and:
|
1 2 |
5,000,001 → 10,000,000 |
Those ranges can then be divided again into smaller ranges.
An ArrayList is also relatively easy to divide because its elements are stored using an indexed backing array.
But not every source splits equally well.
A LinkedList, for example, consists of connected nodes rather than an array-backed structure.
Some stream sources, such as Stream.iterate(), can also be less convenient to partition efficiently.
If splitting the source is expensive or produces unbalanced tasks, some workers may finish early while others continue processing much larger chunks.
That means the available CPU cores aren’t being used efficiently.
Stateful and Ordered Operations
The stream pipeline itself also affects performance.
Some operations are naturally independent:
|
1 2 3 |
map() filter() |
But operations such as:
|
1 2 3 4 5 |
sorted() distinct() limit() forEachOrdered() |
may require additional coordination, buffering, or encounter-order handling depending on the pipeline and source.
Consider:
|
1 2 3 4 |
numbers.parallelStream() .map(this::calculateScore) .forEachOrdered(System.out::println); |
The calculations may happen in parallel.
But forEachOrdered() must preserve the original encounter order when producing the output.
That requirement can limit some of the performance benefits of parallel execution.
This doesn’t mean these operations should never be used with parallel streams.
It means you need to evaluate the complete pipeline, rather than looking at individual operations in isolation.
The Shared Mutable State Problem
Parallel streams can also introduce correctness problems.
Consider:
|
1 2 3 4 5 6 |
List<Result> results = new ArrayList<>(); items.parallelStream() .forEach(item -> results.add(process(item))); |
Multiple worker threads may call:
|
1 2 |
results.add(...) |
at the same time.
But ArrayList is not thread-safe.
This can result in:
- Missing elements
- Incorrect data
- Unpredictable behavior
You could use a synchronized collection:
|
1 2 3 4 |
List<Result> results = Collections.synchronizedList( new ArrayList<>()); |
This makes the modification thread-safe.
But there’s another problem.
Multiple threads may now contend for the same synchronization mechanism.
That contention can reduce or even eliminate the performance benefit of parallel execution.
A more stream-oriented approach is:
|
1 2 3 4 |
List<Result> results = items.parallelStream() .map(this::process) .toList(); |
Here, the stream framework manages the result collection rather than having multiple threads manually modify one shared ArrayList.
As a general principle, parallel stream operations should ideally be:
- Independent
- Stateless
- Free from shared mutable state
If multiple elements need to update the same shared object, parallel execution becomes both more complicated and potentially slower.
Blocking I/O Is Another Trap
Now consider this:
|
1 2 3 4 5 |
List<UserDetails> users = userIds.parallelStream() .map(this::callRemoteService) .toList(); |
At first glance, this looks reasonable.
We want to call a remote service for multiple users concurrently.
But a remote service call isn’t primarily CPU work.
Most of the time is spent waiting for network I/O.
A fork/join worker calling the remote service may remain blocked while waiting for the response.
If many workers become blocked, they aren’t available to process other tasks that use the common pool.
The same concern applies to:
|
1 2 3 4 5 |
Database queries File operations Remote API calls Slow network requests |
This is one reason parallel streams are generally a better fit for CPU-bound computation than arbitrary blocking I/O.
If you need controlled concurrency for blocking operations, consider an approach that gives you explicit control over the executor and concurrency level.
Modern Java virtual threads can also be useful for applications with large numbers of blocking I/O operations.
However, there’s an important distinction:
Virtual threads improve the scalability of blocking tasks. They don’t make CPU-bound calculations execute faster than the available CPU cores.
So When Should You Use parallelStream()?
Before replacing stream() with parallelStream(), ask yourself a few questions.
1. Is the dataset sufficiently large?
A small collection may not contain enough work to justify parallelization overhead.
2. Is the work CPU-intensive?
Expensive calculations are generally better candidates than trivial operations.
3. Can each element be processed independently?
Independent tasks are easier to parallelize efficiently.
4. Can the source be divided efficiently?
Sources that split into balanced chunks are generally better suited for parallel processing.
5. Does the machine have spare CPU capacity?
If your application is already using most of the available CPU, adding more parallel work may simply increase contention.
6. Is the reduction operation safe and associative?
Operations that combine partial results need to behave correctly regardless of how the work is partitioned and combined.
7. Have you measured it?
This is perhaps the most important question.
Don’t assume that parallelism is faster.
Measure it under realistic workloads.
A Practical Mental Model
A useful way to think about parallelStream() is:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
Parallel Stream ↓ Split the workload ↓ Create tasks ↓ Schedule workers ↓ Process in parallel ↓ Combine the results ↓ Result |
All of those steps have a cost.
Parallelism only makes sense when:
|
1 2 3 4 |
Time saved by parallel execution > Cost of parallel execution |
If that’s not true, parallelStream() can make your code slower.
Final Takeaway
parallelStream() isn’t a magic performance switch.
Changing:
|
1 2 |
stream() |
to:
|
1 2 |
parallelStream() |
doesn’t automatically make your application faster.
The benefit depends on the relationship between:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Dataset size + Cost per element + Ability to split the source + Independence of the work + Pipeline characteristics + Available CPU + Parallelization overhead |
Parallel streams are often worth considering when you have large amounts of CPU-bound, independent work that can be efficiently partitioned.
They are much less attractive when:
- The collection is small
- The operation is cheap
- The source splits poorly
- The pipeline requires significant coordination
- The operation performs blocking I/O
- Shared mutable state introduces contention
- The application is already under heavy CPU load
The most important lesson is simple:
Don’t use parallelism because it looks faster. Use it when measurement shows that it is faster.
And that’s the real reason parallelStream() can sometimes make Java code slower.
Why ParallelStream() Can Make Java Code Slower Video Tutorial