JAVA / STREAMS, LAMBDAS AND OPTIONAL
Parallel streams and when they slow things down
Decide when .parallel() actually helps by reasoning about how the source splits, how much CPU work each element carries, and what merging costs.
What you will learn
- Read a source's spliterator to see whether it splits in O(1) or must be walked first
- Weigh element count times per-element CPU work against split and merge overhead
- Spot order-sensitive stages like limit, findFirst and sorted that make merging costly
- Run blocking or long-running work in your own ForkJoinPool, not the common pool
Understanding Parallel streams and when they slow things down
A parallel stream does not hand your pipeline to threads element by element. The terminal operation asks the source's Spliterator to keep calling trySplit() until there are enough chunks for the common ForkJoinPool, each chunk is reduced independently on some worker, and the partial results are combined back up the tree. That adds three costs a sequential stream never pays: splitting the source, forking and stealing tasks, and merging partials. Parallelism wins only when element count times CPU work per element is large enough to hide those fixed costs.
The source decides whether the split step is nearly free. An array, an ArrayList, or IntStream.range knows its exact size and splits by halving an index range, so a chunk boundary costs a couple of integer assignments and the halves are balanced. A LinkedList, a Stream.iterate sequence, or anything built on an Iterator can only move forward one element at a time, so trySplit() has to walk the source and copy elements into an array before a worker can touch them, which means the calling thread performs the very traversal you wanted to parallelize. Boxed elements make it worse: chasing Integer references across a huge list is memory-bound, so extra threads compete for cache lines and bandwidth instead of adding compute.
The other half of the cost model is what happens on the way back. sum(), count(), max() and a primitive reduce merge in constant time, while collect(toList()) or toMap() copies or rehashes in proportion to the result size, and every order-sensitive stage such as limit, skip, findFirst, sorted or forEachOrdered forces buffering so the parallel answer still respects encounter order. Those are exactly the pipelines where parallel does strictly more work to produce the same answer. And because every parallel stream in the JVM shares one common pool by default, a lambda that blocks on I/O or nests another parallel stream stalls unrelated code elsewhere in the process.
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Spliterator;
public class SplitBudget {
static void inspect(String label, Spliterator<Integer> s) {
long before = s.estimateSize();
Spliterator<Integer> firstPart = s.trySplit();
long taken = (firstPart == null) ? 0 : firstPart.estimateSize();
System.out.println(label + ": estimate=" + before
+ " firstSplit=" + taken + " remaining=" + s.estimateSize());
}
public static void main(String[] args) {
List<Integer> arrayList = new ArrayList<>();
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 1000; i++) {
arrayList.add(i);
linkedList.add(i);
}
inspect("ArrayList", arrayList.spliterator());
inspect("LinkedList", linkedList.spliterator());
long sum = arrayList.parallelStream().mapToLong(Integer::longValue).sum();
System.out.println("parallel sum=" + sum);
}
}Parallel streams pay a fixed cost for splitting, scheduling and merging, so they only help when the source splits cheaply and each element carries real independent CPU work.
Worked examples
A source that cannot be divided
Shows why parallelising a Stream.iterate pipeline leaves the workers waiting on one producing thread.
import java.util.Spliterator;
import java.util.stream.Stream;
public class UnsplittableSource {
public static void main(String[] args) {
Spliterator<Integer> s = Stream.iterate(1, i -> i + 1).spliterator();
System.out.println("SIZED=" + s.hasCharacteristics(Spliterator.SIZED));
System.out.println("estimate=" + s.estimateSize());
Spliterator<Integer> batch = s.trySplit();
System.out.println("batch handed out=" + (batch != null));
System.out.println("estimate after split=" + s.estimateSize());
}
}Example explained
Line 1SIZED=false means the engine has no element count, so it cannot decide how many subtasks to create or how large they should be.
Line 2estimateSize() returns Long.MAX_VALUE, the documented answer for unknown size, so splitting heuristics treat the source as effectively infinite.
Line 3trySplit() does return a chunk, but only by calling tryAdvance repeatedly and copying elements into an array first, and that copying happens on one thread.
Line 4The remaining estimate is unchanged after the split, so the pool never learns how much work is left and keeps requesting sequential prefixes.
Keep the work off the shared common pool
Runs a parallel pipeline inside a private ForkJoinPool so it cannot stall other parallel streams in the JVM.
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.stream.IntStream;
public class OwnPool {
public static void main(String[] args) throws Exception {
Set<String> threads = ConcurrentHashMap.newKeySet();
ForkJoinPool pool = new ForkJoinPool(2);
try {
ForkJoinTask<Long> task = pool.submit(() -> IntStream.rangeClosed(1, 1000)
.parallel()
.peek(i -> threads.add(Thread.currentThread().getName()))
.asLongStream()
.sum());
long sum = task.get();
System.out.println("sum=" + sum);
System.out.println("touched common pool="
+ threads.stream().anyMatch(n -> n.contains("commonPool")));
} finally {
pool.shutdown();
}
}
}Example explained
Line 1pool.submit makes the pipeline start on a worker of that pool, and forked subtasks go to the pool of the thread that forks them, so the whole computation stays on those two workers.
Line 2peek records the name of the thread that handled each element, and none of the recorded names contain commonPool.
Line 3task.get() parks main until the reduction finishes, so main contributes no element processing of its own.
Line 4This isolation matters when the lambda blocks on HTTP or JDBC calls: on the common pool a blocked worker also delays every other parallel stream in the process.
The mode belongs to the pipeline, not the stage
Demonstrates that parallel() and sequential() flip one flag for the entire pipeline and the last call wins.
import java.util.stream.IntStream;
import java.util.stream.Stream;
public class ModeIsPipelineWide {
public static void main(String[] args) {
Stream<Integer> a = Stream.of(1, 2, 3).parallel().map(i -> i * 2);
System.out.println("a parallel? " + a.isParallel());
System.out.println("a sum=" + a.mapToInt(Integer::intValue).sum());
IntStream b = IntStream.range(0, 10).parallel().map(i -> i * 2).sequential();
System.out.println("b parallel? " + b.isParallel());
System.out.println("b sum=" + b.sum());
}
}Example explained
Line 1isParallel() reports the mode of the whole pipeline, so the map stage inherits it from the parallel() call placed before it.
Line 2sequential() at the end of chain b resets that same flag, so b runs entirely on the calling thread even though parallel() appears earlier.
Line 3There is no way to make only one stage parallel: whichever of parallel() or sequential() you call last decides the mode for every stage.
Line 4b sum=90 either way, because the mode changes only the cost of an associative reduction, never its result.
Important notes
The common pool's parallelism is availableProcessors() minus one (at least 1) plus the calling thread, so in a single-CPU container a parallel stream is extra bookkeeping on one thread.
Submitting a pipeline to your own ForkJoinPool relies on fork/join semantics rather than any guarantee in the Stream API; for genuinely blocking work prefer an ExecutorService or virtual threads over a parallel stream.
Common mistakes
Adding .parallel() to a Stream.iterate or BufferedReader.lines pipeline: the source can only hand out sequentially produced batches, so you pay for splitting and task handoff and the run gets slower while occupying every core.
Collecting into a shared ArrayList or bumping a counter field from forEach in a parallel stream: elements silently go missing or you get an ArrayIndexOutOfBoundsException, and wrapping the list in Collections.synchronizedList serializes the pipeline so parallel becomes pure overhead.
Comparing a single sequential run against a single parallel run with System.nanoTime in the same main method: JIT warmup and first-use pool startup dominate the numbers, so whichever mode runs second looks faster.
Try it yourself
Change, predict, then run
Fill an ArrayList and a LinkedList with 200,000 Integer values, then print the elapsed millis for five repetitions each of list.stream().mapToLong(Integer::longValue).sum() and list.parallelStream().mapToLong(Integer::longValue).sum(). Note which of the four combinations is fastest once the numbers stop shrinking, and whether the LinkedList ever gains anything from parallel.
Open the Java workspaceCheck your understanding
A pipeline reads 50,000 lines with BufferedReader.lines(), parses each line with a regex, and sums one numeric field. On an 8-core machine, adding .parallel() makes it consistently slower. What is the most likely reason?
- sum() is not associative, so the pool has to combine the partial results twice
- 50,000 elements is too few to keep eight workers busy, whatever the per-element work is
- The line source has unknown size and can only advance one element at a time, so splitting means one thread copies elements into batches while the others wait
- Regex matching is synchronized internally, so only one ForkJoinPool thread can match at a time
Show answer
BufferedReader.lines() is backed by an iterator with unknown size, so trySplit() can only pull a prefix and copy it into an array; element production stays serial and the handoff costs more than the parsing it enables. Option 1 is tempting but wrong, because regex parsing of 50,000 lines is ample CPU work to spread over eight cores, and addition of longs is associative, so the blocker is the shape of the source rather than the amount of work or the reduction.