skip to content

How do you implement a custom Collector, and what are the roles of supplier, accumulator, combiner, finisher, and characteristics?

level: seniorimportance: should knowfreq 38%

answer

  1. supplier -> new mutable container
  2. accumulator -> fold one element in (mutate)
  3. combiner -> merge two partials (parallel only)
  4. finisher -> container to result
  5. characteristics: IDENTITY_FINISH / UNORDERED / CONCURRENT

basics

~20 s

A Collector has four functions plus a set of flags. The supplier makes a fresh mutable container, the accumulator adds one element into it, the combiner merges two containers (for parallel streams), the finisher turns the container into the final result, and characteristics tell the runtime if it can skip the finisher, run unordered, or run concurrently. You build one with Collector.of(...).

solid answer

~50 s

A custom Collector<T,A,R> is defined by four pieces and a characteristics set. supplier (Supplier<A>) creates a new mutable accumulation container. accumulator (BiConsumer<A,T>) folds one element into the container. combiner (BinaryOperator<A>) merges two partial containers and is invoked only for parallel streams to combine sub-results. finisher (Function<A,R>) transforms the final container into the result type; if A and R are the same it can be the identity. characteristics is a Set of: IDENTITY_FINISH (the finisher is identity, so the runtime can skip it and cast A to R), UNORDERED (order doesn't affect the result), and CONCURRENT (a single accumulator can be shared across threads, requiring a thread-safe container). You usually create one with Collector.of(supplier, accumulator, combiner, finisher, characteristics...). Correctness rules: the container must be mutable, the combiner must be associative, and CONCURRENT requires a thread-safe supplier/accumulator and is meaningful only with UNORDERED.

code

java · 17 lines
java
// Custom collector: join strings as "a, b, c" (illustrative; joining() exists)
Collector<String, StringJoiner, String> commaJoin = Collector.of(
    () -> new StringJoiner(", "),          // supplier: new mutable container
    StringJoiner::add,                      // accumulator: add one element
    (left, right) -> left.merge(right),     // combiner: merge two partials (parallel)
    StringJoiner::toString                  // finisher: container -> result
    // no characteristics: A=StringJoiner, R=String, so NOT identity-finish
);

String out = Stream.of("a", "b", "c").collect(commaJoin); // "a, b, c"

// Identity-finish example: collect into an ArrayList (A == R == List)
Collector<Integer, ?, List<Integer>> toArrayList = Collector.of(
    ArrayList::new,
    List::add,
    (a, b) -> { a.addAll(b); return a; },
    Collector.Characteristics.IDENTITY_FINISH); // finisher skipped

go deeper

for a junior

Can name the four functions of a Collector at a high level.

for a middle

Implements a simple custom Collector with Collector.of and explains supplier/accumulator/combiner/finisher roles.

for a senior

Reasons about characteristics (identity-finish, unordered, concurrent), correctly notes the combiner runs only in parallel, and respects mutability/associativity rules.

for a principal

Designs collectors with correct parallel semantics, decides CONCURRENT vs. combiner-merge trade-offs, and reviews custom collectors for associativity/thread-safety pitfalls.

## Why custom collectors Most aggregations are covered by built-in `Collectors`, but occasionally you need behavior none of them provide (a bespoke container, a special merge, an unusual finishing transform). The `Collector` interface lets you define exactly how a stream is folded. ## The type parameters: Collector<T, A, R> - **T** — the input element type (what the stream contains). - **A** — the *mutable accumulation* type, the intermediate container used while collecting (often hidden/internal). - **R** — the final result type returned to the caller. ## The four functions 1. **supplier — `Supplier<A>`**: produces a **new, empty mutable container** each time collecting starts (and once per thread in parallel). E.g. `ArrayList::new`, `() -> new int[1]`, `StringBuilder::new`. 2. **accumulator — `BiConsumer<A, T>`**: folds **one element** into the container by mutating it. E.g. `List::add`, `(sb, s) -> sb.append(s)`. It mutates in place and returns nothing. 3. **combiner — `BinaryOperator<A>`**: takes **two partial containers and merges them into one**, returning the merged container. It is invoked **only for parallel streams**, where the data is split, each chunk accumulated into its own container, and the partials combined. E.g. `(a, b) -> { a.addAll(b); return a; }`. 4. **finisher — `Function<A, R>`**: performs the **final transform** from the accumulation container to the result. If `A` and `R` are identical, the finisher is `Function.identity()` (or `i -> i`). ## characteristics — `Set<Collector.Characteristics>` Flags that let the runtime optimize: - **`IDENTITY_FINISH`**: the finisher is the identity function, so the runtime may **skip calling it** and just cast `A` to `R`. Only set this if `A` really equals `R`. - **`UNORDERED`**: the result does **not** depend on encounter order (e.g. a `Set`, a sum). Lets the runtime relax ordering for speed. - **`CONCURRENT`**: a **single accumulation container may be shared across threads**, so the accumulator must be thread-safe and the combiner is effectively unused. Concurrent collecting is only used when the stream is also `UNORDERED` (or the source is unordered) — otherwise the runtime falls back to per-thread containers + combiner. ## Building one Use the factory `Collector.of(supplier, accumulator, combiner)` (identity finisher, no special characteristics) or `Collector.of(supplier, accumulator, combiner, finisher, characteristics...)`. Implementing the interface directly is rarely necessary. ## How the pieces interact (sequential) ``` A container = supplier.get(); for (T e : elements) accumulator.accept(container, e); R result = finisher.apply(container); ``` ## How they interact (parallel, non-concurrent) The stream is split; each chunk runs the sequential loop into its **own** container; the partials are merged pairwise with the **combiner**; the merged container is finished. This is why the combiner must be **associative** and consistent with the accumulator: combining must yield the same result as accumulating all elements into one container. ## Correctness rules (interview gold) - The container **must be mutable** (you accumulate by mutation). - accumulator and combiner must be **consistent**: combine(acc(empty,a), acc(empty,b)) must equal acc(acc(empty,a), b) up to result equality. - The combiner must be **associative**. - Set `IDENTITY_FINISH` **only** when `A == R`. - Set `CONCURRENT` only with a **thread-safe** container and accept the unordered constraint. ## Worked example: a collector to an immutable comma list See the code example — supplier `StringJoiner::new` (with delimiter), accumulator appends, combiner merges two joiners, finisher `toString`. (Of course `joining` already does this; it's illustrative.) ## Term glossary - **Supplier<A>**: zero-arg factory returning a new `A`. - **BiConsumer<A,T>**: takes an `A` and a `T`, returns nothing, used for side-effecting accumulation. - **BinaryOperator<A>**: takes two `A`s, returns an `A`. - **Associative**: `(x∘y)∘z == x∘(y∘z)`; required so parallel merge order doesn't matter.

  • When is the combiner actually invoked?
    Only for parallel streams, to merge the partial containers produced by each thread; sequential collection never calls it.
  • What does the CONCURRENT characteristic require, and when is it used?
    A thread-safe container shared across threads (accumulator must be safe), and it is used only when the stream is also unordered; otherwise the runtime uses per-thread containers + combiner.

saying these in an interview costs you the question

  • Setting IDENTITY_FINISH when A != R
  • Thinking the combiner runs in sequential streams (it runs only in parallel)
  • Using a non-associative combiner or non-thread-safe container with CONCURRENT
  • Accumulating into an immutable container
  • Confusing CONCURRENT (shared container) with just parallel (per-thread containers + combiner)

context