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.

Syntaxlist.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.iterate and iterator-based sources such as BufferedReader.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 ArrayList or HashMap from forEach; the result loses elements or throws. Let toList() or collect build the result.
  • Use an associative operation and a true identity in reduce, or the answer depends on how the data was split.
  • forEach runs in any order. Use forEachOrdered, or a terminal operation such as toList() or joining, which keep encounter order.
  • Code that uses ThreadLocal context, such as a request id or a transaction, does not see it on the pool threads.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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
// ABCDEF

Collect 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.

Example · java
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 odd

A 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.

Example · java
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

  • Be the first to comment on this lesson.