skip to content

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.

run =
identity =
op =
List.of(1, 2, 3, 4, 5, 6, 7, 8).parallelStream().reduce(10, (a, b) -> a + b)
10+110+210+310+410+510+610+710+8???????????????

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.

step 1/5
switch between sequential and parallel, then change the identity or the operation. the parallel tree is the real split jdk 25 does for 8 elements on a 14-core mac. in parallel, the identity is used once per chunk, which is why a fake one gives a different answer than sequential.

the three forms

Main.java
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
}
output
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) == x for every x. 0 for +, 1 for *, "" for string concatenation, Integer.MIN_VALUE for max.
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:

Main.java
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));
}
output
sequential 46
parallel   116
sequential -36
parallel   0
predict the output
java
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:

don'treduce into one shared list
java
// 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; });
docollect: a fresh container per chunk
java
// 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:

size 41
size 85
size 1223
size 1637
size 183
size 2091
size 24762
size 2868
size 329
AIOOBE1
log scale
4 elements in. how big the list came back, across 1000 runs. the right answer showed up once. a second batch of 1000 had a different spread, which is the point. (with 1000 elements instead of 4, the doubling ran the heap out: 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).

keylines
  • reduce(identity, op): start at the identity, fold every element in with op.
  • 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, not reduce.