Parallel Streams
Know when parallel() speeds up CPU-heavy work on large data, why it rarely helps inside a web request, and how to keep results correct and ordered.
list.parallelStream().map(fn).toList()
IntStream.range(0, n).parallel().filter(p).count()
stream.parallel().forEachOrdered(action)Calling parallel() on a stream, or parallelStream() on a collection, splits the source into chunks, processes them on several threads and combines the results. The code looks the same as a sequential pipeline, which makes it tempting to add everywhere. It is a tool for a narrow job.
Where the threads come from
Parallel streams run on the common ForkJoinPool, shared by the whole JVM. Its parallelism defaults to one less than Runtime.getRuntime().availableProcessors(), and the calling thread also does work. In a container that number follows the CPU limit, so a service with two CPUs gets very little extra.
When it helps
- The work per element is CPU-bound: hashing, parsing, number crunching.
- There is a lot of it: thousands of elements with real work each, not a list of twenty DTOs.
- The source splits cheaply: arrays,
ArrayList,IntStream.range.LinkedList,Stream.iterateand iterator-based sources such asBufferedReader.lines()split poorly.
Measure before and after. Splitting, thread hand-off and merging have a cost that often outweighs the gain.
Why it rarely belongs in a request handler
A web server already runs many requests at once, so the CPUs are busy with other requests. Worse, the common pool is shared: a parallel stream that makes blocking HTTP or database calls ties up its threads, and every other parallel stream in the JVM waits. For I/O, use virtual threads or Gatherers.mapConcurrent. Keep parallel streams for batch jobs, reports and start-up work.
Keeping results correct
- Never add to a shared
ArrayListorHashMapfromforEach; the result loses elements or throws. LettoList()orcollectbuild the result. - Use an associative operation and a true identity in
reduce, or the answer depends on how the data was split. forEachruns in any order. UseforEachOrdered, or a terminal operation such astoList()orjoining, which keep encounter order.- Code that uses
ThreadLocalcontext, such as a request id or a transaction, does not see it on the pool threads.
Example
When to use it
- Recompute checksums for a large batch of stored files in a nightly job using all available cores.
- Score thousands of product records for a search index rebuild where each score is CPU-bound.
- Avoid parallel streams in a request handler that calls other services, and use virtual threads instead.
More examples
Keeping encounter order
joining and forEachOrdered both respect the list's order even though the work ran on several threads. Plain forEach would print the letters in whatever order the threads finished.
import java.util.List;
import java.util.stream.Collectors;
void main() {
List<String> ids = List.of("a", "b", "c", "d", "e", "f");
String joined = ids.parallelStream()
.map(String::toUpperCase)
.collect(Collectors.joining(","));
IO.println(joined);
ids.parallelStream()
.map(String::toUpperCase)
.forEachOrdered(IO::print);
IO.println();
}
// Output:
// A,B,C,D,E,F
// ABCDEFCollect instead of sharing a list
Each thread builds part of the result and the stream merges them safely. Calling add on a shared ArrayList from forEach instead would lose elements or throw under contention.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
void main() {
List<Integer> multiplesOfThree = IntStream.range(0, 10_000)
.parallel()
.filter(n -> n % 3 == 0)
.boxed()
.toList();
IO.println(multiplesOfThree.size());
Map<Boolean, Long> evenOdd = IntStream.range(0, 10_000)
.parallel()
.boxed()
.collect(Collectors.groupingByConcurrent(n -> n % 2 == 0, Collectors.counting()));
IO.println(evenOdd.get(true) + " even, " + evenOdd.get(false) + " odd");
}
// Output:
// 3334
// 5000 even, 5000 oddA wrong identity in reduce
10 is not an identity for addition, so every chunk adds it again; here the four elements were split into four chunks. Use 0 as the identity and add any starting value once, outside the stream.
import java.util.stream.IntStream;
void main() {
int sequential = IntStream.rangeClosed(1, 4).reduce(10, Integer::sum);
int parallel = IntStream.rangeClosed(1, 4).parallel().reduce(10, Integer::sum);
IO.println("sequential " + sequential);
IO.println("parallel " + parallel);
IO.println("correct " + (10 + IntStream.rangeClosed(1, 4).parallel().sum()));
}
// Output:
// sequential 20
// parallel 50
// correct 20
Discussion