Parallel Streams and Stream Best Practices
A parallel stream splits the work across threads. It helps less often than people expect, and it breaks silently when the rules are ignored.
- Creating a parallel stream
- When it helps
- The rules a parallel stream requires
- 1. No shared mutable state
- 2. The accumulator must be associative
- 3. The identity must be neutral
- 4. Do not modify the source
- Ordering
- The common pool is shared
- Measuring rather than guessing
- Stream best practices
- Closing an I/O backed stream
- Common mistakes
- Practice
- Conclusion
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
Creating a parallel stream
long count = notes.parallelStream().filter(Note::published).count();
long same = notes.stream().parallel().filter(Note::published).count();The pipeline is split into chunks processed by the common ForkJoin pool, and the partial results are merged. By default that pool has one thread per available processor, minus one.
When it helps
| Favourable | Unfavourable |
|---|---|
| Many elements, typically tens of thousands | A small collection |
| Expensive work per element | Trivial work per element |
A source that splits well: array, ArrayList, IntStream.range | LinkedList, Files.lines, iterators |
| Stateless, independent operations | Shared mutable state |
| A cheap combiner | Expensive merging |
| Nothing else competing for the pool | Blocking I/O inside the pipeline |
Splitting, scheduling and merging all cost time. For a list of a few hundred elements a parallel stream is reliably slower than a sequential one. Measure; do not assume.
The rules a parallel stream requires
1. No shared mutable state
// Broken: ArrayList is not thread safe, results are lost or corrupted
List<String> titles = new ArrayList<>();
notes.parallelStream().forEach(note -> titles.add(note.title()));
// Correct: collect handles the merging safely
List<String> titles = notes.parallelStream().map(Note::title).toList();2. The accumulator must be associative
// Associative: grouping does not change the answer
int sum = numbers.parallelStream().reduce(0, Integer::sum);
// Not associative: subtraction gives different results per split
int wrong = numbers.parallelStream().reduce(0, (a, b) -> a - b);3. The identity must be neutral
// Wrong: each parallel chunk starts from 10, so 10 is added several times
int total = numbers.parallelStream().reduce(10, Integer::sum);This one is insidious: the sequential version gives the expected answer and only the parallel version is wrong.
4. Do not modify the source
List<String> names = new ArrayList<>(List.of("a", "b"));
// names.parallelStream().forEach(name -> names.add(name)); // undefined behaviourOrdering
List<Integer> values = IntStream.rangeClosed(1, 10).boxed().toList();
values.parallelStream().forEach(System.out::print); // order unpredictable
values.parallelStream().forEachOrdered(System.out::print); // 12345678910
List<Integer> collected = values.parallelStream().map(n -> n * 2).toList();
// collected is always in order: collect preserves encounter orderCollecting preserves encounter order; forEach does not. findAny may return any matching element, while findFirst forces the first in encounter order and therefore costs more in parallel.
The common pool is shared
// A blocking call inside a parallel stream starves every other user of the pool
urls.parallelStream().map(this::httpGet).toList(); // avoid// Run it in a pool you control instead
ForkJoinPool pool = new ForkJoinPool(8);
try {
List<String> results = pool.submit(
() -> urls.parallelStream().map(this::httpGet).toList()).get();
} finally {
pool.shutdown();
}Every parallel stream in the JVM shares one common pool by default. Blocking work inside it delays everything else. For I/O bound work, an executor or virtual threads are a far better fit than parallel streams, which are designed for CPU bound work.
Measuring rather than guessing
long start = System.nanoTime();
long result = data.stream().filter(this::expensiveCheck).count();
long sequentialMs = (System.nanoTime() - start) / 1_000_000;
start = System.nanoTime();
long parallelResult = data.parallelStream().filter(this::expensiveCheck).count();
long parallelMs = (System.nanoTime() - start) / 1_000_000;This is a rough comparison, not a benchmark: JIT warm up and garbage collection both distort a single measurement. Treat the numbers as an indication, and use a proper benchmarking harness for decisions that matter.
Stream best practices
- Prefer a loop when it is clearer. A stream is not automatically better.
- Keep pipelines side effect free. Every lambda should depend only on its input.
- Filter early, sort late. Reduce the data before the expensive stages.
- Use primitive streams for numeric work to avoid boxing.
- Extract long lambdas into named methods and use method references.
- Do not reuse a stream. Create a new one from the source.
- Close I/O streams such as
Files.lineswith try with resources. - Do not nest deeply. A stream inside a stream inside a collector is usually a sign to extract a method.
- Go parallel only after measuring, and only for large, CPU bound, splittable work.
Closing an I/O backed stream
try (Stream<String> lines = Files.lines(path)) {
long blank = lines.filter(String::isBlank).count();
} // the file handle is released hereCommon mistakes
- Calling
parallelStreamon a small collection and losing performance. - Accumulating into a plain
ArrayListfrom a parallelforEach. - Using a non neutral identity, so only the parallel result is wrong.
- Doing blocking I/O inside a parallel stream and starving the common pool.
- Expecting
forEachto preserve order. - Using a
LinkedListas a parallel source, which splits poorly.
Practice
- Explain why
reduce(10, Integer::sum)gives a different answer in parallel. - Rewrite a parallel
forEachthat fills a list so that it is thread safe. - Compare sequential and parallel timings for a cheap operation over 1000 elements, and explain the result.
- Why is
findAnycheaper thanfindFirstin a parallel stream? - List three properties a source needs for parallelism to pay off.
Conclusion
Parallel streams are for large, CPU bound, cleanly splittable work with no shared state. Keep operations associative and side effect free, avoid blocking the common pool, and measure before and after rather than trusting intuition.