Streams Performance & Parallel Streams
How a stream pipeline really executes, Spliterators, when parallelStream() helps or hurts, and a benchmarked before/after example.
How a stream pipeline actually executes
The collections-streams page established that streams are lazy — nothing runs until a terminal operation is called. What that description leaves out is how the pipeline runs once it does: a stream doesn't run each intermediate operation as a separate pass over the whole collection (filter everything, then map everything). It runs one element at a time, all the way through every stage, before moving on to the next element. This is sometimes called "vertical" execution, and it's easy to see directly:
import java.util.List;
public class PipelineOrder {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4, 5);
nums.stream()
.peek(n -> System.out.println("checking: " + n))
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("squaring: " + n))
.map(n -> n * n)
.forEach(n -> System.out.println("result: " + n));
}
}
checking: 1
checking: 2
squaring: 2
result: 4
checking: 3
checking: 4
squaring: 4
result: 16
checking: 5
Element 1 runs through filter and gets discarded before element 2 is even looked at — the pipeline never builds an intermediate "all filtered values" list in memory. Internally, each intermediate operation wraps the previous one in a chain of Sink objects, and the terminal operation pulls elements from the source one at a time, pushing each through the whole chain. This is also what makes short-circuiting operations like findFirst(), anyMatch(), and limit() genuinely stop early instead of processing the entire source — with an infinite stream (Stream.iterate(0, n -> n + 1)), .limit(5).forEach(...) terminates; without laziness, it couldn't.
Spliterator: what makes a stream parallelizable at all
A stream's ability to run in parallel comes from its underlying Spliterator ("splittable iterator"). Where a plain Iterator only walks forward one element at a time, a Spliterator can also trySplit() — hand back a second Spliterator covering roughly half of the remaining elements, so two threads can each walk one half. The ForkJoinPool that backs parallel streams calls trySplit() recursively until the chunks are small enough to process directly, then merges the partial results back together (via the stream's combiner, e.g. summing partial sums).
How well a source splits determines how much a parallel stream can actually help:
| Source | Splits... | Why |
|---|---|---|
ArrayList, arrays (int[], Arrays.asList(...)) |
Cheaply, evenly | Known size, random access — splitting is just an index midpoint |
HashSet, HashMap |
Reasonably well | Backed by an array of buckets, splittable along bucket boundaries |
LinkedList |
Poorly | No random access — finding the "midpoint" requires walking every node first |
Stream.iterate(...) (unbounded) |
Not at all without a limit |
Unknown size, strictly sequential by definition |
A BufferedReader.lines() stream |
Poorly | Backed by sequential I/O, no meaningful way to jump ahead |
This is why parallelizing a LinkedList-backed stream routinely disappoints: the JVM pays the coordination cost of a parallel stream while getting almost none of the benefit, because splitting the source itself dominates the work.
When parallelStream() actually helps — and when it hurts
parallelStream() (or .stream().parallel()) hands the pipeline to the common ForkJoinPool — a single pool shared by every parallel stream and every CompletableFuture in the entire JVM process, sized by default to Runtime.getRuntime().availableProcessors() - 1. That sharing has real consequences:
- It genuinely helps when there's substantial CPU-bound work per element, enough elements to amortize the cost of splitting/coordinating/merging, and a source that splits well (see the table above).
- It hurts — makes things slower — when the per-element work is cheap (the coordination overhead exceeds the work being parallelized), the dataset is small, the source splits poorly, or the operation blocks on I/O. A blocking parallel stream ties up threads in the shared pool, which can starve unrelated parallel streams or
CompletableFuturechains running elsewhere in the same application at the same time — a subtle, hard-to-diagnose form of cross-feature contention.
A benchmark-style before/after example
import java.util.Arrays;
import java.util.stream.LongStream;
public class StreamBenchmark {
public static void main(String[] args) {
long[] data = LongStream.rangeClosed(1, 20_000_000).toArray();
long start = System.nanoTime();
long sequentialCount = Arrays.stream(data).filter(StreamBenchmark::isPrimeLike).count();
long sequentialMs = (System.nanoTime() - start) / 1_000_000;
start = System.nanoTime();
long parallelCount = Arrays.stream(data).parallel().filter(StreamBenchmark::isPrimeLike).count();
long parallelMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("Sequential: " + sequentialCount + " matches in " + sequentialMs + "ms");
System.out.println("Parallel: " + parallelCount + " matches in " + parallelMs + "ms");
}
// A deliberately CPU-heavy per-element check, so the parallel version has
// real work to divide across cores instead of a trivial comparison.
private static boolean isPrimeLike(long n) {
if (n < 2) return false;
for (long i = 2; i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
}
Illustrative results on an 8-core machine (real numbers vary by hardware — the point is the shape of the trade-off, not the exact milliseconds):
| Dataset size | Per-element cost | Sequential | Parallel | Winner |
|---|---|---|---|---|
| 1,000 | trivial (n % 2 == 0) |
0.05 ms | ~4 ms | Sequential — fork/join setup costs more than the whole job |
| 20,000,000 | trivial (n % 2 == 0) |
~40 ms | ~38 ms | Roughly a wash — too little work per element to gain much |
| 20,000,000 | expensive (isPrimeLike above) |
~1,900 ms | ~340 ms | Parallel — real CPU work per element to spread across cores |
The pattern is the whole lesson: parallel streams pay off specifically when there's meaningful CPU-bound work per element, multiplied across enough elements — not merely "a lot of data." A one-off System.nanoTime() measurement like this is fine for building intuition, but is skewed by JIT warm-up and should never be the basis for a real production decision — use JMH (the Java Microbenchmark Harness) for anything that actually needs a trustworthy number, since it handles warm-up iterations and dead-code elimination correctly.
Shared mutable state: the silent correctness trap
Parallel streams tempt people into "just accumulate into a list" patterns that are safe sequentially but a race condition in parallel:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
// BROKEN: ArrayList is not thread-safe — concurrent add() calls from
// multiple threads can corrupt internal state or silently lose elements.
List<Integer> broken = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(broken::add);
System.out.println(broken.size()); // often prints something less than 10000
// CORRECT: let the stream's own collector handle thread-safe accumulation.
List<Integer> correct = IntStream.range(0, 10_000)
.parallel()
.boxed()
.collect(java.util.stream.Collectors.toList());
System.out.println(correct.size()); // always 10000
Collectors.toList() (and the other built-in collectors) are specifically designed to accumulate correctly under parallel execution, either by using a thread-confined intermediate container per split and merging at the end, or an explicitly concurrent one — that correctness guarantee disappears the moment a forEach lambda reaches out and mutates some shared, non-thread-safe object itself.
Comparison: stream() vs parallelStream()
stream() |
parallelStream() |
|
|---|---|---|
| Runs on | The calling thread | The common ForkJoinPool, shared JVM-wide |
| Best source | Any | Array/ArrayList-backed, sized, splits evenly |
| Ordering | Natural encounter order | Preserved for ordered sources, but forEachOrdered re-adds coordination cost that plain forEach avoids |
| Overhead | None | Splitting, thread hand-off, merging partial results |
| Good fit | Small/medium data, I/O-bound work, order-sensitive logic | Large data and genuinely CPU-heavy, stateless, associative operations |
| Extra danger | None beyond ordinary streams | Shared mutable state races; blocking calls can starve unrelated parallel work elsewhere in the app |
Common mistakes
- Reaching for
parallelStream()by default "because parallel sounds faster" — for small collections or cheap per-element work, it's reliably slower than the sequential version. - Running blocking I/O (a network call, a database query) inside a parallel stream's lambda — it ties up threads in the shared common
ForkJoinPool, which can starve unrelated parallel streams orCompletableFuturework happening elsewhere in the same process at the same time. - Mutating a plain, non-thread-safe collection (
ArrayList::add,HashMap::put) from inside a parallel stream'sforEachinstead of using a proper collector — a race condition that often doesn't crash, just silently loses data. - Parallelizing a stream backed by a source that splits poorly (
LinkedList, an unboundedStream.iterate, a file-backed line stream) and being surprised there's no speedup. - Drawing conclusions from a single
System.nanoTime()run without JIT warm-up — use JMH for any benchmark result that actually needs to be trusted.
Interview questions
Q: Why is a stream pipeline described as "vertical" rather than "horizontal" execution?
Each element flows through every stage of the pipeline (filter, then map, then the terminal operation) before the next element starts, rather than the pipeline filtering the entire source first and then mapping the entire filtered result. This is also what makes short-circuiting operations like limit() and findFirst() able to stop early, even on an infinite source.
Q: What determines whether parallelStream() will actually speed up a given pipeline?
Whether there's substantial CPU-bound work per element, enough elements to amortize the coordination cost, and a source (array/ArrayList-backed, sized) that splits efficiently via its Spliterator. Cheap per-element work, small datasets, poorly-splitting sources, or blocking I/O all tend to make parallel streams slower, not faster.
Q: Why does IntStream.range(0, n).parallel().forEach(list::add) on a plain ArrayList produce inconsistent results?
ArrayList isn't thread-safe, and a parallel stream's forEach can invoke that lambda from multiple threads concurrently, causing lost updates or internal corruption. The fix is to let a proper collector (Collectors.toList()) handle accumulation, since collectors are specifically designed to merge partial results correctly under parallel execution.