reduction
reduce with identity, accumulator, combiner. and when to use collect instead.
- chapter
- chapter 07.07
- reading time
- 6 min read
reduce folds a stream into one value by repeatedly combining two things into one: reduce(0, Integer::sum).
it takes an identity (the starting value, which must change nothing: x + 0 == x), an accumulator, and for parallel streams a combiner. all of them must be associative, because a parallel stream will regroup the work. and reduce is for immutable values: to build up a list or a map, use collect.
the picture
you know the classic loop: int total = 0; for (int x : xs) total += x;. that's a reduction. reduce is the same loop, except the stream decides how to run it, and in parallel it splits the loop into pieces, runs them separately, and stitches the answers together.
think of counting votes in an election. one person can go through every ballot in order (sequential). or each polling station counts its own pile and head office adds up the totals (parallel). both work, as long as every station starts its tally at zero. if every station starts at 10 "as a buffer", the national total is off by 10 times the number of stations.
the stream splits in halves until each chunk is a single element. on a 14-core machine that's 8 chunks, run by different worker threads.
the three forms
import java.util.*;
import java.util.stream.*;
void main() {
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);
// 1. identity + accumulator: always has an answer
IO.println(nums.stream().reduce(0, Integer::sum));
// 2. no identity: an empty stream has no answer, so you get an Optional
IO.println(nums.stream().reduce(Integer::sum));
IO.println(Stream.<Integer>empty().reduce(Integer::sum));
// for an empty stream, the identity IS the answer
IO.println(Stream.<Integer>empty().reduce(0, Integer::sum));
// 3. identity + accumulator + combiner:
// for when the result type differs from the element type
List<String> words = List.of("fig", "kiwi", "plum");
IO.println(words.stream().reduce(0,
(total, w) -> total + w.length(), // (Integer, String) -> Integer
Integer::sum)); // (Integer, Integer) -> Integer
}36 Optional[36] Optional.empty 0 11
the third form looks odd until you picture the parallel tree: each chunk turns strings into a running int with the accumulator, then the chunks' ints get merged with the combiner. two different jobs, two different functions. (honestly, words.stream().mapToInt(String::length).sum() says the same thing more clearly. see primitive streams.)
the rules, and what happens when you break them
reduce makes three promises to itself, and it's your job to keep them:
- identity
op(identity, x) == xfor everyx. 0 for+, 1 for*,""for string concatenation,Integer.MIN_VALUEformax.- associative
op(op(a, b), c) == op(a, op(b, c)). grouping doesn't matter.+,*,max,min, string concat: yes.-,/, averaging: no.- stateless
- the functions don't read or write anything outside their arguments.
the nasty part: on a sequential stream you can break the first two and get away with it. reduce(10, Integer::sum) gives 46, which is at least predictable. it only goes wrong when someone adds .parallel() later:
import java.util.*;
void main() {
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);
IO.println("sequential " + nums.stream().reduce(10, Integer::sum));
IO.println("parallel " + nums.parallelStream().reduce(10, Integer::sum));
IO.println("sequential " + nums.stream().reduce(0, (a, b) -> a - b));
IO.println("parallel " + nums.parallelStream().reduce(0, (a, b) -> a - b));
}sequential 46 parallel 116 sequential -36 parallel 0
IO.println(Stream.of(3, 9, 1).reduce(Math::max));reduce vs collect
reduce is built for values that don't change: numbers, strings, records. every step makes a new value from two old ones. if you try to use it to fill a mutable container, you'll share that container between every chunk:
// one list, the identity of every chunk
var out = Stream.of(1, 2, 3, 4).parallel()
.reduce(new ArrayList<Integer>(),
(l, x) -> { l.add(x); return l; },
(a, b) -> { a.addAll(b); return a; });// a supplier: a new list per chunk
var out = Stream.of(1, 2, 3, 4).parallel()
.collect(ArrayList::new,
ArrayList::add,
ArrayList::addAll);the bad version passes every sequential test (it prints [1, 2, 3, 4]). in parallel, four threads add to the same unsynchronized ArrayList, and then the combiner does a.addAll(b) where a and b are the same list, doubling it. here's what 1000 parallel runs of it gave on jdk 25:
OutOfMemoryError.)collect exists for exactly this. its first argument is a Supplier, so every chunk gets its own fresh container, mutates it freely (nobody else can see it), and the combiner merges two different containers at the end. people call it a mutable reduction. it's what every Collector does under the hood (previous chapter).
reduce(identity, op): start at the identity, fold every element in withop.- the identity must change nothing. in parallel it's used once per chunk, not once.
- the operation must be associative.
-and/aren't, so they break in parallel. - no identity →
Optional, because an empty stream has no answer. - building a list or map? that's
collect, notreduce.