What is Collector and what built-in Collectors exist
A Collector
🟢 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:
- Collection Sinks:
toList(),toSet(),toCollection(...),toMap(...),toUnmodifiableList(). - Aggregations & Reductions:
counting(),summingInt/Long/Double(),averagingInt(),summarizingDouble(),minBy(),maxBy(). - String Concatenation:
joining(),joining(", "),joining(", ", "[", "]"). - Classification & Partitioning:
groupingBy(...),partitioningBy(...). - 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 thatfinisher()is an identity function (Function.identity()). The runtime skips callingfinisher()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: Theaccumulator()is thread-safe and can be invoked concurrently by multiple threads on a single shared accumulator container, eliminating the need forcombiner().
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)
- Tree-Fold Merge Strategy (Default):
- Each worker thread in
ForkJoinPoolallocates its own private accumulator instance viasupplier(). - 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.
- Each worker thread in
- 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:
Collectors.toList(): Returns a mutableListimplementation (usuallyArrayList). The specification makes no guarantee on exact type, mutability, or thread-safety. Permissively acceptsnullelements.Collectors.toUnmodifiableList()(Java 10+): Returns an unmodifiable list backed byCollections.unmodifiableListor internal immutable collections. Strictly rejectsnullelements, throwingNullPointerExceptionif any stream element is null.Stream.toList()(Java 16+): Dedicated terminal method onStream. Returns an unmodifiable compact list (ImmutableCollections.ListN/List12) implementingRandomAccess. UnliketoUnmodifiableList(), it fully supportsnullelements and directly populates an internal array without allocating an intermediateCollectorImplor 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
synchronizedmonitor 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, andfinisher. - 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 ofstream.collect(Collectors.toList())unless mutability or custom collections are explicitly required. teeing()Power: UseCollectors.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 (typicallyArrayList). Never rely on it for immutability invariants. - ❌ Omitting the merge function in
Collectors.toMap(): Duplicate keys will cause an unexpected runtime crash (IllegalStateException). - ❌ Attaching
CONCURRENTto a non-thread-safe accumulator: DeclaringCONCURRENTon anArrayListcauses race conditions, corrupted internal arrays, andArrayIndexOutOfBoundsExceptionin parallel streams. - ❌ Passing
nullelements intotoUnmodifiableList(): UnlikeStream.toList(),Collectors.toUnmodifiableList()immediately throwsNullPointerExceptionon null elements.