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.
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.
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.
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 againMeasured on an 8 core machine, ten million elements, once everything is warm:
| How | Time | |
|---|---|---|
| stream() | one core | 37 ms |
| parallelStream() | all of them | 7 ms |
Now the same word on a small job. A thousand elements, twenty thousand times:
| How | Time | |
|---|---|---|
| stream() | one core | 66 ms |
| parallelStream() | all of them | 277 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 msIntStream, 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 passMoving 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.
Under the hood
Going deeperWhich threads run it? Not new ones. Parallel streams use a pool that already exists, called the common ForkJoinPool:
cores: 8
common pool parallelism: 7Seven, 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:
| Source | How well it splits | |
|---|---|---|
| ArrayList, arrays, IntStream.range | knows its size, splits by index | evenly, cheaply |
| HashSet, HashMap | splits by bucket | reasonably |
| LinkedList | must walk to find the middle | badly |
| Stream.iterate | each value needs the one before | cannot 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(); // 40787Sixty 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(); // 100000collect 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.
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.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
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.
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
addon the sameArrayListat the same time, andArrayListwas never built for that.Each
addwrites 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
collectgather the results, which is what it is designed for.Why is `IntStream` worth having when `Stream<Integer>` already works?
Show the answer
Because
Stream<Integer>holds objects, and every number in it is anIntegeron 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.
IntStreamcarriesintvalues the whole way, so nothing is allocated. It also gets methods that only make sense for numbers:sum,average,max, andsummaryStatistics.
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
Find Where Parallel Starts Winning
ex-11-6-aThe 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
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
Hint 2
Hint 3almost the answer
Lose Sixty Thousand Numbers
ex-11-6-bRun 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
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
collect is faster because each thread gathers its own results and they are combined at the end, so nothing is shared at all.Boxing, One More Time
ex-11-6-cSame 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
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
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.Sources That Refuse to Split
ex-11-6-dFour 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
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
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.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 100000Nothing 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
- Parallel streamThe lost writes here are a race condition. This is the phase that takes it apart.14.4 - Three Ways Two Threads Break One Field
Parallel stream will return in 14.11 - Futures That Do Not Block, and Threads That Are Nearly Free