Layers of Logic

11.6

Parallel Streams and the Primitive Ones

One word splits your work across every core in the machine. The same word makes small jobs four times slower and can silently lose most of your data.

Core20 min read4 exercises
01

Previously on

Every stream so far has done one thing at a time, in order, on the thread you called it from.

Section 11.2 also left a measurement hanging. Function<Integer, Integer> took 204 ms where IntUnaryOperator took under 1 ms, all of it boxing. Stream<Integer> has the same problem, and this section closes both gaps.

02

The problem

You have ten million numbers and you want the sum of their squares.

long total = numbers.stream().mapToLong(i -> (long) i * i).sum();

One core does all of it. The machine has eight, and seven of them are idle while this runs.

Splitting it by hand is not a small job. Divide the list into eight pieces. Start eight threads. Give each one a piece. Wait for all eight to finish. Add the eight answers together. Handle the case where the list does not divide by eight. Handle a thread failing.

That is a day of work, it is easy to get wrong, and you would have to do it again for the next pipeline.

The other problem is smaller and constant. Look at what a Stream<Integer> is carrying:

List<Integer> numbers = ...;
numbers.stream().mapToInt(Integer::intValue).sum();

Every element is an Integer object. Generics cannot hold primitives, which Section 9.3 explained, so ten million numbers are ten million objects sitting on the heap. The sum is one addition per element and everything else is overhead.

03

The idea

One word runs the pipeline on every core.

long total = numbers.parallelStream().mapToLong(i -> (long) i * i).sum();

Nothing else changes. The same filter, the same map, the same terminal operation. Java splits the source, runs the pipeline on each piece on a different thread, and combines the answers.

You can also switch an existing stream:

numbers.stream().parallel()...    // same thing
someStream.sequential()           // and back again

Measured on an 8 core machine, ten million elements, once everything is warm:

HowTime
stream()one core37 ms
parallelStream()all of them7 ms

Now the same word on a small job. A thousand elements, twenty thousand times:

HowTime
stream()one core66 ms
parallelStream()all of them277 ms

Four times slower. Same machine, same operation, one word different.

Splitting is not free. The list has to be divided, tasks have to be handed to a pool, threads have to be woken, and the partial answers have to be combined. On a thousand elements that costs more than the work itself.

The primitive streams remove the boxing.

IntStream.rangeClosed(1, 10_000_000).sum();   //  9 ms
numbers.stream().mapToInt(Integer::intValue).sum();  // 34 ms

IntStream, LongStream and DoubleStream carry primitives the whole way. Nothing is allocated, and they gain methods that only make sense for numbers:

IntStream.of(3, 1, 4).sum();       // 8
IntStream.of(3, 1, 4).average();   // OptionalDouble[2.666...]
IntStream.of(3, 1, 4).max();       // OptionalInt[4]
IntStream.of(3, 1, 4).summaryStatistics();   // count, sum, min, average, max, in one pass

Moving between the two families:

list.stream().mapToInt(String::length)   // Stream<String> -> IntStream
intStream.boxed()                        // IntStream -> Stream<Integer>
intStream.mapToObj(i -> "n" + i)         // IntStream -> Stream<String>

average returning OptionalDouble is Section 11.5 again. An empty stream has no average, so the type says so.

04

Under the hood

Going deeper

Which threads run it? Not new ones. Parallel streams use a pool that already exists, called the common ForkJoinPool:

cores: 8
common pool parallelism: 7

Seven, not eight, and the missing one is not a bug. The thread that called the terminal operation joins in and does a share of the work, so eight threads are working in total.

That single shared pool matters more than it looks. Every parallel stream in your program uses it, so a slow one can hold up all the others.

How the work is split. The source is divided using a Spliterator, the splittable iterator mentioned in Section 11.3. Halves are split again until the pieces are small enough, each piece runs the whole pipeline, and the results are combined on the way back up.

Which is why the source matters:

SourceHow well it splits
ArrayList, arrays, IntStream.rangeknows its size, splits by indexevenly, cheaply
HashSet, HashMapsplits by bucketreasonably
LinkedListmust walk to find the middlebadly
Stream.iterateeach value needs the one beforecannot split at all

A parallel stream over a LinkedList can be slower than the serial one before any work happens, because finding where to split means walking the chain.

Now the failure that should frighten you. A hundred thousand numbers into a plain ArrayList, in parallel:

List<Integer> unsafe = new ArrayList<>();
IntStream.range(0, 100_000).parallel().forEach(unsafe::add);

unsafe.size();   // 40787

Sixty thousand values gone. No exception. No warning. No sign that anything went wrong except a number that is quietly incorrect.

ArrayList.add writes a value into a slot and then increases the size. Several threads doing that at once read the same size, write to the same slot, and set the size to the same number. One write survives and the rest are lost.

Nothing detected it because nothing was watching. Every thread performed a correct sequence of steps, and the steps interleaved. This is a race condition, and Phase XIV is about it in full.

The fix is not a thread safe list. It is to stop writing into shared state:

List<Integer> safe = IntStream.range(0, 100_000).parallel().boxed().toList();
safe.size();   // 100000

collect and toList are built for this. Each thread gathers its own partial result and the pieces are combined at the end, so no two threads ever touch the same object.

Order costs money. forEach on a parallel stream gives you elements in whatever order they finish. forEachOrdered gives them in the original order and gives back much of the speed to do it. findFirst must respect order, findAny need not, which is why findAny exists at all.

05

What it costs

The honest summary is that parallelStream is a word you should almost never type without a measurement behind it.

The cost of splitting is real and constant. Dividing the source, handing tasks to a pool, waking threads, combining results: it is the same overhead whether the work is enormous or tiny. On a small collection it is all you are paying for, and 66 ms became 277 ms above with nothing else changed.

Sharing the pool is the part that surprises people. There is one common pool for the whole application, sized to your cores. A parallel stream doing slow work delays every other parallel stream in the process, including the ones inside libraries.

Correctness is where the real risk sits. Any lambda touching shared mutable state can lose data, and it does it without an exception, without a warning, and not on every run. A test over ten elements passes. Production over a million does not, and the failure looks like bad data rather than a crash.

Some pipelines also get slower for structural reasons rather than size. A LinkedList source has to be walked before it can be split. sorted and distinct need to see everything. A stateful lambda cannot be split safely at all.

The primitive streams have a smaller catch of their own. IntStream has no filter that returns objects, no map to another type without mapToObj, and no collect in the shape you are used to. Moving between the families is normal, and forgetting boxed() produces error messages that take a moment to read.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. When does `parallelStream` actually make things faster?

    Show the answer

    When there is enough work to pay for splitting it up, and the work is independent.

    Measured on an 8 core machine, summing squares: over ten million elements, serial took 37 ms and parallel took 7 ms. Over a thousand elements run twenty thousand times, serial took 66 ms and parallel took 277 ms.

    Same code, same machine. Four times faster in one case and four times slower in the other, and the only difference is how much work there was per split.

    The other half is the work itself. Splitting only helps if the pieces do not need to talk to each other. Anything touching shared state, or that has to happen in order, gets slower or wrong or both.

  2. A parallel stream adds 100,000 numbers to an `ArrayList` and the list ends up with 40,787. What happened, and why was there no error?

    Show the answer

    Several threads called add on the same ArrayList at the same time, and ArrayList was never built for that.

    Each add writes a value into a slot and then increases the size. Two threads reading the same size, writing to the same slot, and both setting size to the same number means one value is lost. Sixty thousand times over.

    There was no error because nothing detected anything. No lock was broken, no rule was checked. Each thread did a correct sequence of steps and the steps interleaved. This is the race condition Phase XIV is about, arriving early.

    The fix is not a thread safe list. It is to stop writing into shared state at all and let collect gather the results, which is what it is designed for.

  3. Why is `IntStream` worth having when `Stream<Integer>` already works?

    Show the answer

    Because Stream<Integer> holds objects, and every number in it is an Integer on the heap.

    Generics cannot hold primitives, from Section 9.3, so ten million numbers means ten million small objects to make, chase and collect. Measured: 34 ms against 9 ms for the same sum.

    IntStream carries int values the whole way, so nothing is allocated. It also gets methods that only make sense for numbers: sum, average, max, and summaryStatistics.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises110 pointsabout 105 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Find Where Parallel Starts Winning

Real work·30 min·25 points

ex-11-6-a

The word parallel costs nothing to type and can cost you four times your run time.

Measure at four sizes and you will see both ends of it. Somewhere in between is a crossover, and finding roughly where it sits on your machine is worth more than any rule you could be given.

Warm the code up first. The very first parallel run pays for starting the thread pool, and the JIT has not compiled anything yet, so an unwarmed measurement can point exactly the wrong way. Getting that wrong is the most common way people conclude parallel streams are useless.

What your program must do

  • Time both versions at four different sizes, three runs each
  • Warm the code up before recording anything, and say why that matters
  • Find roughly where the crossover is on your machine
  • Run the small size many times in a loop and record how much slower parallel is
Crossover.java
import java.util.*;
import java.util.stream.*;

public class Crossover {
    static long time(Runnable r) {
        long t = System.nanoTime();
        r.run();
        return (System.nanoTime() - t) / 1_000_000;
    }

    public static void main(String[] args) {
        System.out.println("cores: " + Runtime.getRuntime().availableProcessors());

        // TODO: sum of squares, serial and parallel, at sizes
        //       1_000, 100_000, 1_000_000, 10_000_000
        // TODO: warm both up before timing. Run each size three times.
        // TODO: find roughly where parallel starts winning on YOUR machine
        // TODO: do the small size 20_000 times in a loop and compare again
    }
}
Hint 1
Run the whole comparison a few times before recording. The first parallel run pays for starting the pool, and the first of anything pays for the JIT, so early numbers can point the wrong way entirely.
Hint 2
On an 8 core machine, ten million elements went from 37 ms serial to 7 ms parallel. A thousand elements run twenty thousand times went from 66 ms serial to 277 ms parallel.
Hint 3almost the answer
The crossover is not a number you can look up. It depends on your cores, your data and how much work each element costs, which is exactly why the honest answer is always to measure.
What this is really testing

Whether parallelStream is a speed switch in your head or a trade. There is a size below which it loses, and you should know roughly where it is on your own machine.

B

Lose Sixty Thousand Numbers

Hard·25 min·30 points

ex-11-6-b

Run it five times and write down all five numbers. They will differ.

That is the part worth sitting with. Not that it is wrong, but that it is wrong by a different amount each time, and that nothing anywhere reports a problem. No exception, no warning, no failed assertion. Just a list with most of your data missing.

Every bug in Phase XIV looks like this. The synchronized list at the end is the obvious fix and the wrong one, and timing it against collect shows you why.

What your program must do

  • Add to a shared ArrayList from a parallel stream and record the size five times
  • Do the same serially and compare
  • Fix it using collect or toList
  • Try a synchronized list as well, and time it against the collect version
Lost.java
import java.util.*;
import java.util.stream.*;

public class Lost {
    public static void main(String[] args) {
        // TODO: add 100_000 numbers to a plain ArrayList from a PARALLEL stream
        // TODO: print the size. Run it five times. Write down all five numbers.
        // TODO: do the same with a serial stream. How many now?
        // TODO: fix it with collect or toList, and confirm you get 100000
        // TODO: try Collections.synchronizedList. Does the count come right?
        //       Time it against the collect version.
    }
}
Hint 1
IntStream.range(0, 100_000).parallel().forEach(list::add). Expect a number well under 100000, and a different one each run.
Hint 2
ArrayList.add writes into a slot and then increases the size. Two threads reading the same size write to the same slot, and one of the two values is gone. Nothing checks for this, so nothing reports it.
Hint 3almost the answer
A synchronized list gives the right count and gives back most of the speed, because every thread now queues at the same lock. collect is faster because each thread gathers its own results and they are combined at the end, so nothing is shared at all.
What this is really testing

Whether you can accept a wrong answer with no error attached. This is the failure mode of concurrency, and meeting it once here is worth a whole phase of warnings later.

C

Boxing, One More Time

Real work·25 min·25 points

ex-11-6-c

Same arithmetic, two families, and one of them allocates ten million objects to do it.

The gap here is the one you already measured in Section 11.2 with Function<Integer, Integer>. It is worth measuring again in its stream form, because this is where you will actually meet it.

Pay attention to the return types as you go. average handing back an OptionalDouble rather than a double is not fussiness. It is Section 11.5 doing its job in a place you might not have expected it.

What your program must do

  • Time the same sum with Stream of Integer and with IntStream
  • Use average, max and summaryStatistics, and write down what each returns
  • Convert in both directions with boxed, mapToInt and mapToObj
  • Say what average on an empty stream gives back and why it is not zero
Primitives.java
import java.util.*;
import java.util.stream.*;

public class Primitives {
    public static void main(String[] args) {
        // TODO: sum 1 to 10_000_000 two ways: Stream<Integer> and IntStream. Time both.
        // TODO: average, max and summaryStatistics on an IntStream. Note the return types.
        // TODO: convert between the two families: boxed, mapToInt, mapToObj
        // TODO: try collect(Collectors.toList()) on an IntStream. Read the error.
        // TODO: average on an EMPTY IntStream. What comes back, and why not zero?
    }
}
Hint 1
Boxing turned 9 ms into 34 ms for ten million numbers. The addition is the same. Everything else is ten million Integer objects being made and collected.
Hint 2
average() returns OptionalDouble, not double. An empty stream has no average, and Section 11.5 is why the type says so instead of inventing zero.
Hint 3almost the answer
summaryStatistics() gives count, sum, min, average and max from a single pass. Doing those five separately means five passes, and the stream cannot be reused anyway.
What this is really testing

Whether you reach for IntStream by habit yet. The gap is the same one from Section 11.2, and it turns up wherever generics meet numbers.

D

Sources That Refuse to Split

Hard·25 min·30 points

ex-11-6-d

Four sources, and the answer is decided before any of your code runs.

Splitting is the first thing a parallel stream does, and how cheap that is depends entirely on where the data came from. An array knows where its middle is. A chain of nodes has to be walked to find out.

The last one is the interesting case. Stream.iterate builds each value out of the previous one, which means the work has an order baked into it. No amount of asking will make that divisible, and the parallel() call is pure overhead.

What your program must do

  • Run the same parallel sum over an ArrayList and a LinkedList and compare
  • Print with forEach on a parallel stream and describe the order
  • Compare forEach against forEachOrdered
  • Say why Stream.iterate gains nothing from being parallel
Sources.java
import java.util.*;
import java.util.stream.*;

public class Sources {
    static long time(Runnable r) {
        long t = System.nanoTime();
        r.run();
        return (System.nanoTime() - t) / 1_000_000;
    }

    public static void main(String[] args) {
        int n = 2_000_000;
        List<Integer> arrayList  = new ArrayList<>();
        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < n; i++) { arrayList.add(i); linkedList.add(i); }

        // TODO: same parallel sum over both. Predict which wins and by how much.
        // TODO: forEach on a parallel stream. Is the order what you expected?
        // TODO: forEachOrdered. Time it against forEach.
        // TODO: Stream.iterate(1, x -> x + 1).parallel().limit(1_000_000).sum().
        //       Predict whether parallel helps here at all.
    }
}
Hint 1
An ArrayList knows its size and can split by index instantly. A LinkedList has to be walked to find its middle, and that walk happens before any of your work starts.
Hint 2
forEach on a parallel stream gives you elements as threads finish them, in no useful order. forEachOrdered puts them back in order and gives up much of the speed to do it.
Hint 3almost the answer
Stream.iterate makes each value from the one before it, so there is no way to know the millionth value without making the 999,999 before it. A source like that cannot be divided, whatever you ask for.
What this is really testing

Whether you can predict a parallel stream's fate from where its data came from. Some sources split for free, some have to be walked first, and one cannot be split at all.

08

After the credits

Phase XI is finished, and it did what it promised: almost no new ideas. Interfaces from Phase VIII, anonymous classes from Phase VII, generics from Phase IX, and the collections from Phase X, with the noise removed.

One thing in this section was not explained, only shown.

IntStream.range(0, 100_000).parallel().forEach(unsafe::add);   // 40787 of 100000

Nothing threw. Nothing warned. Data was lost, and the number was different every run.

Phase XIV is where that gets taken apart properly: what a thread is, why two of them writing to one field loses a write, and the several different tools Java gives you for stopping it. ForkJoinPool, the pool quietly running every parallel stream you have written today, is in the last section of it.

Before that, two shorter phases you need first. Phase XII is exceptions, because a thread that fails needs somewhere for the failure to go. Phase XIII is memory and the garbage collector. “Ten million Integer objects on the heap” has been an argument three times now, and it is worth knowing exactly what that costs.

Threads you opened in this section