🌊 Section 8 · Question #6

What is Collector and what built-in Collectors exist

A Collector is a specification (or recipe) for the terminal collect() operation. It defines how to instantiate an intermediate mutable accumulator container of type A,...


🟢 Junior Level

30-Second Summary

A Collector<T, A, R> is a specification (or recipe) for the terminal collect() operation. It defines how to instantiate an intermediate mutable accumulator container of type A, accumulate incoming stream elements of type T into it, merge containers during parallel evaluation, and optionally transform the accumulator into a final result of type R.

The java.util.stream.Collectors utility class provides dozens of pre-built, production-ready static factory methods organized into five primary families:

  1. Collection Sinks: toList(), toSet(), toCollection(...), toMap(...), toUnmodifiableList().
  2. Aggregations & Reductions: counting(), summingInt/Long/Double(), averagingInt(), summarizingDouble(), minBy(), maxBy().
  3. String Concatenation: joining(), joining(", "), joining(", ", "[", "]").
  4. Classification & Partitioning: groupingBy(...), partitioningBy(...).
  5. Composition & Transformation: collectingAndThen(...), mapping(...), filtering(...), flatMapping(...) (Java 9+), teeing(...) (Java 12+).

Basic Java 21 Examples

import java.util.*;
import java.util.stream.Collectors;

public class BuiltInCollectorsDemo {

    public record Product(String name, String category, double price, int stock) {}

    public static void main(String[] args) {
        List<Product> products = List.of(
            new Product("Laptop", "Electronics", 1200.0, 5),
            new Product("Smartphone", "Electronics", 800.0, 15),
            new Product("Desk", "Furniture", 350.0, 3),
            new Product("Chair", "Furniture", 150.0, 0),
            new Product("Pen", "Stationery", 2.5, 100)
        );

        // 1. Collect into an unmodifiable Set of unique categories
        Set<String> categories = products.stream()
            .map(Product::category)
            .collect(Collectors.toUnmodifiableSet());

        // 2. Joining product titles into a formatted string
        String catalog = products.stream()
            .map(Product::name)
            .collect(Collectors.joining(", ", "Catalog: [", "]"));
        System.out.println(catalog);
        // Catalog: [Laptop, Smartphone, Desk, Chair, Pen]

        // 3. Statistical summary: count, sum, min, average, max in one pass
        DoubleSummaryStatistics stats = products.stream()
            .collect(Collectors.summarizingDouble(Product::price));
        System.out.printf("Count: %d, Avg: %.2f, Max: %.2f%n",
            stats.getCount(), stats.getAverage(), stats.getMax());

        // 4. Partitioning by stock availability (always yields true and false keys)
        Map<Boolean, List<Product>> inStockPartition = products.stream()
            .collect(Collectors.partitioningBy(p -> p.stock() > 0));
        System.out.println("In stock count: " + inStockPartition.get(true).size());
        System.out.println("Out of stock count: " + inStockPartition.get(false).size());
    }
}

🟡 Middle Level

Anatomy of the Collector<T, A, R> Interface

The Collector interface is parameterised by three types:

  • T: Type of input stream elements.
  • A: Intermediate mutable accumulator type (often internal or erased with ?).
  • R: Final result type produced after completion.
public interface Collector<T, A, R> {
    Supplier<A> supplier();                 // Creates a fresh mutable accumulator container
    BiConsumer<A, T> accumulator();         // Folds a single stream element into the container
    BinaryOperator<A> combiner();           // Merges two containers during parallel tree reduction
    Function<A, R> finisher();              // Transforms the accumulator into the final result
    Set<Characteristics> characteristics(); // Runtime optimization metadata flags
}

Characteristics Flags:

  • IDENTITY_FINISH: Declares that finisher() is an identity function (Function.identity()). The runtime skips calling finisher() entirely and performs an unchecked direct cast (R) accumulator, eliminating function call and object allocation overhead.
  • UNORDERED: The reduction operation does not preserve or require the encounter order of the source stream.
  • CONCURRENT: The accumulator() is thread-safe and can be invoked concurrently by multiple threads on a single shared accumulator container, eliminating the need for combiner().

Advanced Composition: Downstream Collectors

Collectors can be nested hierarchically to build multi-dimensional in-memory aggregations without multiple stream passes:

import java.util.*;
import java.util.stream.Collectors;

public class AdvancedCollectorsDemo {

    public record Employee(String name, String department, double salary, List<String> skills) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Alice", "Engineering", 120_000, List.of("Java", "Docker")),
            new Employee("Bob", "Engineering", 95_000, List.of("Java", "Kubernetes")),
            new Employee("Charlie", "HR", 70_000, List.of("Recruiting")),
            new Employee("Diana", "HR", 80_000, List.of("Talent", "Communication"))
        );

        // 1. Department -> Average Salary
        Map<String, Double> avgSalaryByDept = staff.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.averagingDouble(Employee::salary)
            ));

        // 2. Department -> Set of Employee Names
        Map<String, Set<String>> namesByDept = staff.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.toSet())
            ));

        // 3. Department -> Unique flattened skills (Java 9+ flatMapping)
        Map<String, Set<String>> skillsByDept = staff.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.flatMapping(e -> e.skills().stream(), Collectors.toSet())
            ));

        // 4. Bi-Collector Fusion: Collectors.teeing() (Java 12+)
        // Evaluates two independent collectors concurrently in a single traversal
        record SalarySpread(double min, double max, double spread) {}
        SalarySpread spread = staff.stream()
            .collect(Collectors.teeing(
                Collectors.minBy(Comparator.comparingDouble(Employee::salary)),
                Collectors.maxBy(Comparator.comparingDouble(Employee::salary)),
                (minOpt, maxOpt) -> {
                    double min = minOpt.map(Employee::salary).orElse(0.0);
                    double max = maxOpt.map(Employee::salary).orElse(0.0);
                    return new SalarySpread(min, max, max - min);
                }
            ));

        System.out.println("Salary Spread: " + spread);
    }
}

🔴 Senior Level

Building a Custom Collector

When built-in collectors do not satisfy custom domain requirements, we can construct one using Collector.of(...) or by implementing Collector<T, A, R>.

Here is a high-performance BatchCollector that partitions a stream into fixed-size batches List<List<T>>:

import java.util.*;
import java.util.stream.Collector;
import java.util.stream.IntStream;

public final class BatchCollector<T> implements Collector<T, List<List<T>>, List<List<T>>> {

    private final int batchSize;

    public BatchCollector(int batchSize) {
        if (batchSize <= 0) {
            throw new IllegalArgumentException("Batch size must be greater than 0");
        }
        this.batchSize = batchSize;
    }

    public static <T> Collector<T, ?, List<List<T>>> toBatches(int batchSize) {
        return new BatchCollector<>(batchSize);
    }

    @Override
    public java.util.function.Supplier<List<List<T>>> supplier() {
        return ArrayList::new;
    }

    @Override
    public java.util.function.BiConsumer<List<List<T>>, T> accumulator() {
        return (batches, item) -> {
            if (batches.isEmpty() || batches.get(batches.size() - 1).size() >= batchSize) {
                List<T> newBatch = new ArrayList<>(batchSize);
                newBatch.add(item);
                batches.add(newBatch);
            } else {
                batches.get(batches.size() - 1).add(item);
            }
        };
    }

    @Override
    public java.util.function.BinaryOperator<List<List<T>>> combiner() {
        return (left, right) -> {
            if (left.isEmpty()) return right;
            if (right.isEmpty()) return left;

            List<List<T>> merged = new ArrayList<>(left);
            for (List<T> batch : right) {
                for (T item : batch) {
                    if (merged.isEmpty() || merged.get(merged.size() - 1).size() >= batchSize) {
                        List<T> newBatch = new ArrayList<>(batchSize);
                        newBatch.add(item);
                        merged.add(newBatch);
                    } else {
                        merged.get(merged.size() - 1).add(item);
                    }
                }
            }
            return merged;
        };
    }

    @Override
    public java.util.function.Function<List<List<T>>, List<List<T>>> finisher() {
        return Collections::unmodifiableList;
    }

    @Override
    public Set<Characteristics> characteristics() {
        // Preserves encounter order, finisher is not identity, accumulator is not thread-safe
        return Collections.emptySet();
    }

    public static void main(String[] args) {
        List<List<Integer>> batches = IntStream.rangeClosed(1, 10)
            .boxed()
            .collect(BatchCollector.toBatches(3));

        System.out.println(batches); 
        // [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
    }
}

Parallel Collection Mechanics: Fold-Tree Merge vs Concurrent Reduction

In parallel streams (parallelStream()), the runtime engine (ReduceOps) chooses between two distinct execution models:

1. Standard Tree-Fold Reduction (Default):
   Thread 1 (Chunk 1) ---> Accumulator 1 \
                                           ---> Combiner(Acc1, Acc2) \
   Thread 2 (Chunk 2) ---> Accumulator 2 /                            ---> Finisher ---> Result
                                                                     /
   Thread 3 (Chunk 3) ---> Accumulator 3 \                          /
                                           ---> Combiner(Acc3, Acc4)
   Thread 4 (Chunk 4) ---> Accumulator 4 /

2. Concurrent Reduction (CONCURRENT + UNORDERED):
   Thread 1 ----\
   Thread 2 -------> Shared Thread-Safe Accumulator (ConcurrentHashMap) ---> Finisher ---> Result
   Thread 3 ----/
   Thread 4 --/
   (Combiner is completely bypassed; synchronization managed by container CAS/locks)
  1. Tree-Fold Merge Strategy (Default):
    • Each worker thread in ForkJoinPool allocates its own private accumulator instance via supplier().
    • Threads accumulate elements locally without locks or synchronization.
    • Finished branches are recursively merged using combiner().
    • Trade-offs: Zero lock contention and guaranteed encounter order preservation, but incurs intermediate container allocations and $O(N)$ merge copying overhead.
  2. Concurrent Reduction (CONCURRENT + UNORDERED):
    • supplier() is invoked exactly once for the entire stream pipeline.
    • All worker threads concurrently invoke accumulator() on a single, shared container instance (e.g., ConcurrentHashMap).
    • The combiner() function is never called.
    • Trade-offs: Eliminates merge copying and container allocations, but introduces hardware cache-line invalidation (false sharing) and memory bus contention.

4 Tricky Questions

1. What are the semantic and runtime differences between Collectors.toList(), Collectors.toUnmodifiableList(), and Stream.toList() (Java 16+)?

Answer:

  1. Collectors.toList(): Returns a mutable List implementation (usually ArrayList). The specification makes no guarantee on exact type, mutability, or thread-safety. Permissively accepts null elements.
  2. Collectors.toUnmodifiableList() (Java 10+): Returns an unmodifiable list backed by Collections.unmodifiableList or internal immutable collections. Strictly rejects null elements, throwing NullPointerException if any stream element is null.
  3. Stream.toList() (Java 16+): Dedicated terminal method on Stream. Returns an unmodifiable compact list (ImmutableCollections.ListN/List12) implementing RandomAccess. Unlike toUnmodifiableList(), it fully supports null elements and directly populates an internal array without allocating an intermediate CollectorImpl or accumulator wrapper.

2. How does Collectors.teeing() (Java 12+) execute internally: does it traverse the stream twice?

Answer: No, Collectors.teeing() traverses the stream strictly once. Internally, teeing(col1, col2, merger) creates a composite accumulator record containing state for both collectors:

class Pair<A1, A2> { A1 first; A2 second; }

As elements arrive from the stream, the composite accumulator feeds each element sequentially to both underlying accumulators:

col1.accumulator().accept(pair.first, element);
col2.accumulator().accept(pair.second, element);

During completion, it executes both finishers (col1.finisher() and col2.finisher()) and passes their independent outputs into the user-supplied merger function:

return merger.apply(col1.finisher().apply(pair.first), col2.finisher().apply(pair.second));

This enables dual aggregation (e.g., min and max, or sum and count) without buffering stream elements in memory.


3. Why might Collectors.groupingByConcurrent() actually run slower than standard Collectors.groupingBy() in a parallel stream?

Answer: In groupingByConcurrent(), all parallel worker threads share a single ConcurrentHashMap. When processing high-throughput streams:

  • Worker threads compete for the same hash table bins, triggering CAS retry loops and synchronized monitor locks on hash tree/list nodes.
  • Cache-line bouncing occurs: frequent writes to adjacent heap addresses invalidate L1/L2 CPU caches across processor cores.
  • If the stream is ordered, the JVM must also discard encounter ordering or incur ordering enforcement penalties.

In contrast, standard Collectors.groupingBy() gives each worker thread its own private HashMap. There is zero thread contention, zero cache-line invalidation, and zero locking during accumulation. The final tree-reduction merge (combiner()) is often faster than the cumulative synchronization overhead of a shared ConcurrentHashMap, especially when the number of grouped keys is modest.


4. How does Characteristics.IDENTITY_FINISH optimize pipeline execution, and what happens if a custom collector falsely declares it?

Answer: When Characteristics.IDENTITY_FINISH is present, the stream pipeline engine skips calling Collector.finisher(). Instead, it uses an unchecked bytecode cast (R) accumulator. This saves a megamorphic virtual method call and avoids allocating wrapper objects (e.g., when the accumulator is already the desired result type like ArrayList).

If a collector author declares IDENTITY_FINISH when the accumulator A is not assignable to result type R (for instance, an accumulator of type StringBuilder with a declared result type of String), the runtime throws a ClassCastException at the moment collect() returns:

java.lang.ClassCastException: class java.lang.StringBuilder cannot be cast to class java.lang.String

🎯 Interview Cheat Sheet

Core Concepts Matrix

| Collector Family | Key Methods | Result Type | Mutability | Characteristics | | :— | :— | :— | :— | :— | | List/Set Sinks | toList(), toSet() | List<T>, Set<T> | Mutable | IDENTITY_FINISH | | Unmodifiable Sinks | toUnmodifiableList(), toUnmodifiableSet() | List<T>, Set<T> | Unmodifiable | Non-identity | | Map Sinks | toMap(key, val, merge, mapSup) | Map<K, V> | Mutable | IDENTITY_FINISH | | Partitioning | partitioningBy(pred, down) | Map<Boolean, D> | Mutable | Dependent on downstream | | Grouping | groupingBy(func, down) | Map<K, D> | Mutable | Dependent on downstream | | Concurrent Group | groupingByConcurrent(func, down)| ConcurrentMap<K, D> | Thread-Safe | CONCURRENT, UNORDERED | | Bi-Collector | teeing(col1, col2, merge) | Custom R | Custom | Non-identity |


Key Takeaways

  • The 4 Functions: Every collector coordinates supplier, accumulator, combiner, and finisher.
  • Downstream Modularity: Grouping and partitioning collectors can accept downstream collectors to compute multi-level aggregations in a single pass.
  • Java 16+ Standard: Default to stream.toList() instead of stream.collect(Collectors.toList()) unless mutability or custom collections are explicitly required.
  • teeing() Power: Use Collectors.teeing() to calculate compound metrics (like min/max or mean/variance) in a single stream pass without multiple iterations.

Red Flags & Anti-Patterns to Avoid

  • ❌ Assuming Collectors.toList() returns an unmodifiable list: It returns a mutable list (typically ArrayList). Never rely on it for immutability invariants.
  • ❌ Omitting the merge function in Collectors.toMap(): Duplicate keys will cause an unexpected runtime crash (IllegalStateException).
  • ❌ Attaching CONCURRENT to a non-thread-safe accumulator: Declaring CONCURRENT on an ArrayList causes race conditions, corrupted internal arrays, and ArrayIndexOutOfBoundsException in parallel streams.
  • ❌ Passing null elements into toUnmodifiableList(): Unlike Stream.toList(), Collectors.toUnmodifiableList() immediately throws NullPointerException on null elements.