Parallel Stream
With the introduction of the Stream API in Java 8, developers gained a powerful, declarative way to process collections of data. While sequential streams process elements one by one in a single thread, Parallel Streams allow for the distribution of workload across multiple CPU cores, potentially significantly reducing execution time for compute-intensive tasks.
At its core, a parallel stream decomposes a data source into multiple chunks, processes these chunks concurrently across different threads, and finally merges the results into a single output.
How to Create Parallel Streams
Java provides several intuitive ways to transition from sequential processing to parallel execution. The most common methods include:
- From Collections: Using the
.parallelStream()method available on any class implementing theCollectioninterface (e.g.,List,Set). - Converting an Existing Stream: Calling the
.parallel()method on any existingStreamobject. - Primitive Streams: Utilizing specialized streams like
IntStream.range(1, 1_000_000).parallel()for high-performance numeric processing. - From Arrays: Using
Arrays.stream(array).parallel()to process array elements concurrently.
It is important to note that the mode of the stream is determined by the last call to either .parallel() or .sequential(). This allows developers to switch back to sequential processing if a specific part of the pipeline requires strict ordering or thread safety.
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
long count = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.count();
The Underlying Architecture: Fork/Join and Spliterator
The magic behind parallel streams is not a simple loop over threads, but a sophisticated implementation of the Fork/Join framework. This framework employs a "divide and conquer" strategy to maximize CPU utilization.
The Spliterator
The process begins with the Spliterator (a portmanteau of "split" and "iterator"). Unlike a standard iterator, a Spliterator can partition a data source into smaller fragments using the trySplit() method. This allows the stream to recursively divide the data until the chunks are small enough to be processed efficiently by a single thread.
The Execution Lifecycle
- Split: The data source is partitioned into sub-tasks via the
Spliterator. - Execute: These sub-tasks are submitted to the
ForkJoinPool, where worker threads execute them in parallel. - Combine: Once the individual tasks are complete, the framework merges the partial results back together to produce the final output.
The Common Pool
By default, parallel streams utilize the ForkJoinPool.commonPool(). The default parallelism level is typically set to the number of available processors minus one. While this is sufficient for most applications, you can tune this globally using the following system property:
-Djava.util.concurrent.ForkJoinPool.common.parallelism=8
When to Use (and Avoid) Parallel Streams
A common misconception is that parallel streams are always faster than sequential ones. In reality, parallelism introduces overhead—specifically the cost of splitting the source and merging the results.
Ideal Use Cases
Parallel streams shine when the following conditions are met:
- Large Datasets: The overhead of splitting is negligible compared to the total processing time.
- Compute-Intensive Tasks: Each element requires significant CPU cycles to process.
- Independent Operations: The processing of one element does not depend on the result of another.
Potential Pitfalls
Using parallel streams indiscriminately can lead to performance degradation or bugs:
- Data Structure Efficiency: Not all sources split equally.
ArrayList, arrays, andIntStream.rangeare highly efficient because they support random access. Conversely,LinkedListorStream.iterateare costly to split and may actually perform slower in parallel. - Thread Safety: Parallel streams should be used with stateless lambda expressions. Modifying shared mutable state from within a parallel stream can lead to race conditions and unpredictable results.
- Ordering Issues: Operations like
forEach()do not guarantee order in a parallel stream. If the sequence of processing is critical,forEachOrdered()must be used, though this may reduce the performance gains of parallelism.
By understanding the trade-offs between sequential and parallel execution, developers can write Java code that is not only concise but optimized for modern multi-core hardware.