Java 8 Parallel Streams Tutorial with Examples

Introduction

This tutorial explains parallel streams in Java 8: how to create them, how they split work across threads, and - just as important - when they make code faster and when they make it slower or wrong. We will start with what a parallel stream is and how it uses the fork/join framework behind the scenes. We will then run an example showing results that are the same in both modes, the encounter order, the classic shared-mutable-state bug, and a reduce() identity mistake that only shows up in parallel. Next, we will cover Java 8's parallel array methods, and lastly a checklist for deciding whether to use parallel() at all.

(This tutorial assumes you know the basics of the Java 8 Streams APIClick to read: Java 8 Streams API.)

What is a parallel stream

A normal - sequential - stream processes its elements one after another on the calling thread. A parallel stream runs the same pipeline on several threads at once: the source is split into chunks, each chunk is processed by a different thread, and the partial results are combined into the final result. The code of the pipeline does not change; only the way it is executed does.

There are two ways to get a parallel stream, and two methods to check or change the mode -
  • collection.parallelStream(): a parallel stream straight from any Collection.
  • stream.parallel(): turns any stream, such as IntStream.range() or Arrays.stream(), into a parallel one.
  • stream.sequential(): switches back. The last call wins for the whole pipeline - a stream cannot be partly parallel.
  • stream.isParallel(): reports the current mode.

How it works - split, process, combine

Three pieces of the JDK work together. The source's Spliterator (a "splittable iterator", new in Java 8) divides the elements into chunks, splitting them again and again while there is enough work. The chunks run as tasks in ForkJoinPool.commonPool(), a pool shared by the whole JVM with one thread less than the number of CPU cores - the calling thread joins in as well. Finally, the terminal operation combines the partial results: adding partial sums, merging partial lists, and so on.
How a Java 8 parallel stream works - the Spliterator splits the source, ForkJoinPool threads process chunks, and partial results are combined
This model explains everything that follows: splitting and combining have a cost, so small inputs get slower; chunks are processed at the same time, so lambdas must not share mutable state; and partial results are combined, so reduce() and collectors must be able to combine correctly.

Java 8 code example of parallel streams

The program below runs six small experiments. Some of their output is the same on every run; part 3 deliberately shows a bug whose output changes from run to run.
Java 8 code showing parallel streams, encounter order, shared state, reduce identity and parallel array methods
//ParallelStreams.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.LongStream;

public class ParallelStreams {

  public static void main(String[] args) {
    // 1. A stateless pipeline gives the same answer sequentially and in parallel
    long sequential = LongStream.rangeClosed(1, 1_000_000)
        .filter(n -> n % 3 == 0).map(n -> n * 2).sum();
    long parallel = LongStream.rangeClosed(1, 1_000_000).parallel()
        .filter(n -> n % 3 == 0).map(n -> n * 2).sum();
    System.out.println("1. sequential sum = " + sequential + ", parallel sum = " + parallel);

    // 2. Encounter order is kept by collect() and forEachOrdered()
    List<Integer> ids = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
    List<Integer> collected = ids.parallelStream()
        .map(n -> n * 10)
        .collect(Collectors.toList());
    StringBuilder ordered = new StringBuilder();
    ids.parallelStream().map(n -> n * 10).forEachOrdered(n -> ordered.append(n).append(' '));
    System.out.println("2. collect: " + collected);
    System.out.println("   forEachOrdered: " + ordered.toString().trim());

    // 3. Shared mutable state: adding to an ArrayList from many threads
    List<Integer> unsafe = new ArrayList<>();
    String outcome;
    try {
      IntStream.range(0, 100_000).parallel().forEach(unsafe::add);
      outcome = unsafe.size() + " elements";
    } catch (RuntimeException e) {
      outcome = e.getClass().getSimpleName();
    }
    System.out.println("3. unsafe forEach + add: " + outcome + " (expected 100000 elements)");
    List<Integer> safe = IntStream.range(0, 100_000).parallel().boxed()
        .collect(Collectors.toList());
    System.out.println("   collect instead:      " + safe.size() + " elements");

    // 4. reduce() needs a true identity value
    int wrongSequential = ids.stream().reduce(10, Integer::sum);
    int wrongParallel = ids.parallelStream().reduce(10, Integer::sum);
    int right = ids.parallelStream().reduce(0, Integer::sum) + 10;
    System.out.println("4. reduce(10, sum): sequential " + wrongSequential
        + ", parallel " + wrongParallel + "; reduce(0, sum) + 10: " + right);

    // 5. Java 8's parallel array methods
    int[] salaries = {5200, 4100, 6300, 5800, 4700};
    Arrays.parallelSort(salaries);
    long[] squares = new long[6];
    Arrays.setAll(squares, i -> (long) i * i);
    int[] runningTotal = {100, 200, 300, 400};
    Arrays.parallelPrefix(runningTotal, Integer::sum);
    System.out.println("5. parallelSort   " + Arrays.toString(salaries));
    System.out.println("   setAll         " + Arrays.toString(squares));
    System.out.println("   parallelPrefix " + Arrays.toString(runningTotal));

    // 6. Checking and switching mode
    System.out.println("6. isParallel: " + ids.parallelStream().isParallel()
        + ", after sequential(): " + ids.parallelStream().sequential().isParallel());
  }
}
 OUTPUT of the above code
1. sequential sum = 333333666666, parallel sum = 333333666666
2. collect: [10, 20, 30, 40, 50, 60, 70, 80]
   forEachOrdered: 10 20 30 40 50 60 70 80
3. unsafe forEach + add: 17034 elements (expected 100000 elements)
   collect instead:      100000 elements
4. reduce(10, sum): sequential 46, parallel 116; reduce(0, sum) + 10: 46
5. parallelSort   [4100, 4700, 5200, 5800, 6300]
   setAll         [0, 1, 4, 9, 16, 25]
   parallelPrefix [100, 300, 600, 1000]
6. isParallel: true, after sequential(): false

Explanation of the code

  • Part 1: filter(), map() and sum() are stateless and associative, so the parallel pipeline gives exactly the sequential answer. This is the kind of pipeline parallel streams are made for.
  • Part 2: a list has an encounter order, and collect() keeps it even in parallel - the chunks are merged in the right order. forEachOrdered() also respects it. Plain forEach() does not: in parallel it runs the action in whatever order the threads happen to reach the elements, which is why the program does not print it.
  • Part 3: several threads call add() on one ArrayList, which is not thread-safe. Additions are lost, or the list throws an exception when two threads resize it at once. The output changes from run to run - a typical race condition. Letting the stream build the list with collect() always gives all 100,000 elements, because each thread fills its own partial list and the lists are merged at the end.
  • Part 4: reduce(identity, op) uses the identity as the starting value of every chunk, not just once. 10 is not an identity for addition, so the parallel result adds 10 once per chunk and is larger than the sequential one, which adds it only once. The fix is a true identity (0 for addition, 1 for multiplication), with any extra value added afterwards. See the reduce() tutorialClick to read: reduce() tutorial for the rules.
  • Part 5: Java 8 also added parallel helpers to java.util.Arrays. parallelSort() sorts using the fork/join pool (falling back to a normal sort for small arrays), setAll() fills each element from its index (parallelSetAll() is the parallel version), and parallelPrefix() replaces each element with the running result of an operation - here a running total.
  • Part 6: sequential() after parallelStream() turns the whole pipeline back into a sequential one.

When does parallel() make code faster

Parallel streams are not a free speed-up. Splitting the source, scheduling tasks and combining results all cost time, and the work must be large enough to pay for them. The checklist below sums up the main factors.
When to use Java 8 parallel streams - good fit and poor fit checklist covering data size, source, I/O, ordering and shared state
  • Size times cost: a common rule of thumb is that the number of elements multiplied by the cost per element must be large - tens of thousands of cheap operations, or a few hundred expensive ones.
  • The source matters: arrays, ArrayList and IntStream.range() split instantly into equal halves. A LinkedList has to be walked to split, and Stream.iterate() or lines read from a file cannot be split well at all.
  • Keep I/O out: a blocking call inside a parallel stream holds a thread of the common pool, which every parallel stream and many CompletableFutures in the JVM share. For I/O, use a CompletableFutureClick to read tutorial: Java 8 CompletableFuture Tutorial with Examples with your own executor instead.
  • Order costs: limit(), findFirst() and forEachOrdered() must respect the encounter order, which limits parallelism. If order does not matter, findAny() and unordered() remove that cost.
  • Measure: the only reliable answer comes from a benchmark of the real workload on the real hardware. A quick timing loop is easily misled by JIT warm-up; a harness such as JMH is the safe choice.

Rules for correct parallel streams

  • No shared mutable state: lambdas must not modify variables, collections or objects outside the stream. Use collect(), reduce() or sum() to build results - and keep the lambdas pureClick to read: pure.
  • Associative operations: the order in which partial results are combined must not matter. Addition and max() are fine; subtraction is not.
  • A true identity for reduce(), as part 4 showed.
  • Thread-safe collectors: the built-in collectors are safe in parallel. A custom collectorClick to read tutorial: Java 8 Custom Collector with Collector.of() Tutorial must provide a correct combiner.

Summary

In this tutorial we first saw that a parallel stream runs the same pipeline on several threads, and how to create one with parallelStream() or parallel(). Next we saw how the Spliterator splits the source, the common fork/join pool processes the chunks, and the partial results are combined. We then ran an example that gave the same sum in both modes, kept the encounter order with collect(), lost elements when threads shared an ArrayList, and gave a wrong total with a false reduce() identity, and we used Java 8's parallel array methods. Lastly, we listed when parallel streams pay off, and the rules for keeping them correct.