Writing a Collector properly
Supplier, accumulator, combiner, finisher - and what the characteristics promise.
Open this lesson in the learning hubKey points
- A
Collectoris four functions. supplier creates a fresh container, accumulator folds one element in, combiner merges two containers, and finisher converts the container to the result. - The combiner is only used in parallel. A collector with a wrong or lazy combiner works perfectly in every sequential test and produces wrong answers only under parallel execution.
IDENTITY_FINISHdeclares that the container is already the result, letting the pipeline skip the finisher entirely. Claiming it while returning a different type is a ClassCastException at runtime.CONCURRENTis a much stronger claim: it says a single container may be shared by all threads, so the accumulator itself must be thread-safe. Combined withUNORDEREDit lets the pipeline skip merging.UNORDEREDdeclares the result does not depend on encounter order. It permits real optimisations, and it is wrong for anything building an ordered list.- Before writing one, check
Collectors.teeing,collectingAndThenandmapping- most custom collectors people write are a composition of existing ones.
Example
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class CustomCollector {
/** Collects into an immutable list, tracking min and max as it goes. */
record Summary(List<Integer> values, int min, int max) { }
static class Acc {
final List<Integer> items = new ArrayList<>();
int min = Integer.MAX_VALUE, max = Integer.MIN_VALUE;
}
static Collector<Integer, Acc, Summary> summarising() {
Supplier<Acc> supplier = Acc::new;
BiConsumer<Acc, Integer> accumulator = (a, v) -> {
a.items.add(v);
a.min = Math.min(a.min, v);
a.max = Math.max(a.max, v);
};
// ONLY used in parallel. A wrong combiner passes every sequential test.
BinaryOperator<Acc> combiner = (l, r) -> {
l.items.addAll(r.items);
l.min = Math.min(l.min, r.min);
l.max = Math.max(l.max, r.max);
return l;
};
Function<Acc, Summary> finisher = a ->
new Summary(Collections.unmodifiableList(a.items), a.min, a.max);
// NOT IDENTITY_FINISH: the container is Acc, the result is Summary.
Set<Collector.Characteristics> characteristics = EnumSet.noneOf(Collector.Characteristics.class);
return Collector.of(supplier, accumulator, combiner, finisher,
characteristics.toArray(new Collector.Characteristics[0]));
}
public static void main(String[] args) {
List<Integer> data = IntStream.rangeClosed(1, 10_000).boxed().collect(Collectors.toList());
Summary seq = data.stream().collect(summarising());
System.out.println("sequential: size=" + seq.values().size()
+ " min=" + seq.min() + " max=" + seq.max());
// The combiner is exercised only here.
Summary par = data.parallelStream().collect(summarising());
System.out.println("parallel : size=" + par.values().size()
+ " min=" + par.min() + " max=" + par.max());
System.out.println("agree : " + (seq.values().size() == par.values().size()
&& seq.min() == par.min() && seq.max() == par.max()));
// The result really is immutable.
try {
seq.values().add(1);
} catch (UnsupportedOperationException e) {
System.out.println("finisher produced an immutable list: yes");
}
// Most "custom" collectors are a composition of existing ones.
var minMax = data.stream().collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.maxBy(Integer::compare),
(lo, hi) -> lo.orElseThrow() + ".." + hi.orElseThrow()));
System.out.println("teeing gave the same range: " + minMax);
}
}
The combiner runs only in parallel, so a wrong one passes every sequential test - and the characteristics are promises the runtime trusts.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Streams course, and every lesson in it is listed on the Streams contents page.