Parallel Streams in Java
Introduction
In Java 8, the Stream API revolutionized how developers process collections of data. While sequential streams provide an elegant, declarative approach to data manipulation, Parallel Streams take this a step further by leveraging multi-core architectures to process data concurrently.
A parallel stream splits its underlying data into multiple chunks, processes those chunks independently across different threads, and then combines the results. When used correctly, it can drastically reduce the execution time of CPU-intensive operations on large datasets.
Core Concept: Deep Dive and Architecture
How It Works Internally
Under the hood, Java's parallel streams rely on the Fork/Join Framework introduced in Java 7.
- Splitting (Fork): The data source is partitioned using a
Spliterator(Splitable Iterator). The framework recursively divides the task into smaller subtasks until they are small enough to be processed sequentially. - Processing: These subtasks are assigned to a pool of worker threads. By default, parallel streams use the common
ForkJoinPool, which dynamically manages a number of threads equal to one less than the number of available CPU cores (Runtime.getRuntime().availableProcessors() - 1). - Combining (Join): The partial results computed by each thread are recursively combined to produce the final outcome.
When to Use Parallel Streams
Not all tasks benefit from parallelization. The "NQ Model" is a helpful heuristic:
- N: The number of data elements.
- Q: The cost (in CPU time) to process a single element.
Parallel streams are most effective when N * Q is large. If you have a massive collection (large N) or a computationally expensive operation per element (large Q), parallel streams can provide a significant speedup.
Code Examples
1. Basic Example: Sequential vs. Parallel
import java.util.stream.IntStream;
public class ParallelStreamDemo {
public static void main(String[] args) {
System.out.println("Sequential Stream:");
IntStream.range(1, 6).forEach(n ->
System.out.println(n + " processed by " + Thread.currentThread().getName())
);
System.out.println("\nParallel Stream:");
IntStream.range(1, 6).parallel().forEach(n ->
System.out.println(n + " processed by " + Thread.currentThread().getName())
);
}
}
Notice how the parallel stream output uses multiple ForkJoinPool.commonPool-worker threads, and the order of processing is non-deterministic.
2. Intermediate Example: CPU-Intensive Work
import java.util.List;
import java.util.ArrayList;
public class PrimeNumberCounter {
public static boolean isPrime(long n) {
if (n <= 1) return false;
for (long i = 2; i <= Math.sqrt(n); i++) {
if (n % i == 0) return false;
}
return true;
}
public static void main(String[] args) {
List<Long> numbers = new ArrayList<>();
for (long i = 1; i <= 1_000_000; i++) {
numbers.add(i);
}
long startSeq = System.currentTimeMillis();
long countSeq = numbers.stream().filter(PrimeNumberCounter::isPrime).count();
long endSeq = System.currentTimeMillis();
System.out.println("Sequential count: " + countSeq + " took " + (endSeq - startSeq) + " ms");
long startPar = System.currentTimeMillis();
long countPar = numbers.parallelStream().filter(PrimeNumberCounter::isPrime).count();
long endPar = System.currentTimeMillis();
System.out.println("Parallel count: " + countPar + " took " + (endPar - startPar) + " ms");
}
}
Common Pitfalls and Best Practices
1. Stateful Lambda Expressions
The Mistake: Using stateful lambda expressions that modify shared mutable state. This leads to race conditions and unpredictable results.
The Fix: Always use stateless, non-interfering lambdas. If you need to accumulate results, use thread-safe collectors like Collectors.toList() or Collectors.groupingByConcurrent().
// BAD: Modifying shared state
List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>());
numbers.parallelStream().forEach(synchronizedList::add); // Ordering is lost, overhead of synchronization
// GOOD: Using collectors
List<Integer> safeList = numbers.parallelStream().collect(Collectors.toList());
2. Blocking Operations (I/O)
The Mistake: Using parallel streams for network calls or file I/O. Since the common ForkJoinPool has a limited number of threads, blocking operations can quickly exhaust the pool, starving other parallel streams in the JVM.
The Fix: Use CompletableFuture with a custom ExecutorService for I/O-bound tasks.
3. Overhead on Small Datasets
For small collections or trivial operations (like simple arithmetic), the overhead of thread management and splitting the data often exceeds the benefits of parallelism, making the parallel stream slower than a sequential one.
Interview Q&A
Q1: How do you configure the thread pool used by parallel streams?
Answer: By default, all parallel streams share the ForkJoinPool.commonPool(). You can change its size globally using the JVM property -Djava.util.concurrent.ForkJoinPool.common.parallelism=N. Alternatively, you can execute a parallel stream inside a custom ForkJoinPool instance, though this is considered an advanced/hacky technique.
Q2: What is a Spliterator?
Answer: A Spliterator (splitable iterator) is an object used to traverse and partition elements of a source. The trySplit() method attempts to split the data structure into two parts, allowing them to be processed by separate threads. Arrays and ArrayLists have excellent spliterators because they can be split perfectly in half in time. LinkedLists have poor spliterators ( time).
Q3: Does forEachOrdered guarantee ordering in parallel streams?
Answer: Yes. If you use parallelStream().forEachOrdered(), the stream will process elements concurrently where possible but will wait to apply the provided action in the exact encounter order of the source. This introduces synchronization overhead but preserves order.