Collectors & Stream Aggregation
Collectors & Stream Aggregation
Why this matters in interviews
Collectors is where stream pipelines become genuinely useful for real business logic, and it is the area most senior candidates get partially right but rarely fully right. Interviewers use collector questions to test whether you can go beyond basic toList() — specifically whether you can groupingBy with a downstream collector, spot a toMap duplicate-key crash before it happens in production, and know when to reach for summarizingInt instead of chaining three separate streams. Mastery of collectors is a strong differentiator because it separates developers who understand functional composition from those who just know the API surface.
Concept
The Collector Contract
A Collector<T, A, R> is a recipe with four components:
| Component | Type | Role |
|---|---|---|
supplier | Supplier<A> | Creates the mutable accumulation container |
accumulator | BiConsumer<A, T> | Folds one element into the container |
combiner | BinaryOperator<A> | Merges two containers (used in parallel) |
finisher | Function<A, R> | Converts the container to the final result |
You rarely implement this directly — Collectors factory methods cover almost every case. But knowing the contract explains why collectors compose: a downstream collector is simply a Collector whose output becomes the input to an outer collector's finisher.
Basic Collectors
| Collector | Result type | Notes |
|---|---|---|
Collectors.toList() | List<T> | Mutable ArrayList; order preserved |
Collectors.toUnmodifiableList() | List<T> | Immutable; Java 10+ |
.toList() (stream method) | List<T> | Unmodifiable shorthand; Java 16+ |
Collectors.toSet() | Set<T> | No order guarantee; deduplicates |
Collectors.toUnmodifiableSet() | Set<T> | Immutable; Java 10+ |
Collectors.toCollection(TreeSet::new) | Collection<T> | Custom collection type |
Collectors.counting() | Long | Count of elements |
Collectors.joining(delim, prefix, suffix) | String | String concatenation |
toMap — and Its Trap
Collectors.toMap(keyMapper, valueMapper)
Collectors.toMap(keyMapper, valueMapper, mergeFunction)
Collectors.toMap(keyMapper, valueMapper, mergeFunction, mapFactory)
The two-argument form throws IllegalStateException on duplicate keys. In production, duplicate keys are common (e.g., grouping by an enum that two records share). Always supply a mergeFunction when duplicates are possible:
// keeps the last value for duplicate keys
.collect(Collectors.toMap(Person::dept, Person::salary, (a, b) -> b))
To get a LinkedHashMap (insertion-order) instead of an unordered HashMap:
.collect(Collectors.toMap(k, v, (a, b) -> a, LinkedHashMap::new))
groupingBy — Single and Multi-Level
groupingBy(classifier) partitions the stream into a Map<K, List<T>>:
people → groupingBy(dept) → { "Eng" → [Alice, Bob, Eve],
"HR" → [Carol, Dave] }
With a downstream collector, you replace the default toList() accumulation:
| Downstream | What it replaces | Result type |
|---|---|---|
counting() | toList() → count | Map<K, Long> |
summingInt(fn) | toList() → integer sum | Map<K, Integer> |
averagingInt(fn) | toList() → double average | Map<K, Double> |
summarizingInt(fn) | toList() → full stats object | Map<K, IntSummaryStatistics> |
mapping(fn, toList()) | transform then collect | Map<K, List<U>> |
toSet() | toList() → dedup | Map<K, Set<T>> |
joining(delim) | concatenate strings | Map<K, String> |
maxBy(comparator) | find max in group | Map<K, Optional<T>> |
collectingAndThen(c, fn) | post-process the result | Map<K, R> |
Multi-level grouping (group by department, then by seniority level):
Map<String, Map<String, List<Person>>> nested =
people.stream()
.collect(groupingBy(Person::dept,
groupingBy(Person::level)));
partitioningBy — Boolean Split
partitioningBy is a specialized groupingBy with a Predicate. It always returns a Map<Boolean, List<T>> with both true and false keys present (even if one group is empty). It also accepts a downstream collector:
Map<Boolean, Long> countBySeniority =
people.stream()
.collect(partitioningBy(p -> p.salary() >= 100_000, counting()));
Use partitioningBy over groupingBy(p -> predicate, ...) when the split is genuinely binary — the API communicates intent clearly.
Numeric Summary Collectors
For a single numeric property, you have three options depending on what you need:
| Collector | Return type | Computes |
|---|---|---|
summingInt(fn) | Integer | Sum only |
averagingInt(fn) | Double | Average only |
summarizingInt(fn) | IntSummaryStatistics | Count, sum, min, max, average — in one pass |
IntSummaryStatistics (and its Long/Double siblings) is the senior answer when an interviewer asks "how would you compute min, max, and average in a single stream pass."
joining
Collectors.joining() // concatenate
Collectors.joining(", ") // with delimiter
Collectors.joining(", ", "[", "]") // delimiter + prefix + suffix
Operates on Stream<String>. For non-string streams, use .map(Object::toString) first, or .map(Person::name).
collectingAndThen
Wraps another collector and applies a finishing function to its result:
// toList() then make it unmodifiable
Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList)
// groupingBy counting, then get the max-count entry
Map.Entry<String, Long> topDept = people.stream()
.collect(collectingAndThen(
groupingBy(Person::dept, counting()),
map -> map.entrySet().stream().max(Map.Entry.comparingByValue()).orElseThrow()
));
Collectors.teeing (Java 12+)
teeing passes every element to two downstream collectors simultaneously and merges their results with a BiFunction. This is the idiomatic way to compute two aggregations in one pass:
// compute min and max salary in a single pass
record Range(int min, int max) {}
Range salaryRange = people.stream()
.collect(Collectors.teeing(
Collectors.minBy(Comparator.comparingInt(Person::salary)),
Collectors.maxBy(Comparator.comparingInt(Person::salary)),
(min, max) -> new Range(
min.map(Person::salary).orElse(0),
max.map(Person::salary).orElse(0))
));
Before Java 12, achieving this required either summarizingInt (when the stats fit) or two separate stream passes.
Custom Collector via Collector.of
When built-in collectors do not compose to what you need, implement one directly:
// Collect into an ImmutableList (Guava example)
Collector<T, ImmutableList.Builder<T>, ImmutableList<T>> toImmutableList =
Collector.of(
ImmutableList::builder, // supplier
ImmutableList.Builder::add, // accumulator
(b1, b2) -> b1.addAll(b2.build()), // combiner
ImmutableList.Builder::build // finisher
);
Key rules / gotchas
toMapwith duplicate keys throws at runtime: theIllegalStateExceptionmessage includes the duplicate key value, but it only appears under the data that triggers it — often a production data set that your test data did not cover. Always supply a merge function.groupingBydoes not accept a null key: if the classifier returnsnullfor any element, it throwsNullPointerException. UseOptionalor replace nulls before grouping.toList()(Java 16 stream method) returns an unmodifiable list: adding to it throwsUnsupportedOperationException. UseCollectors.toList()when the caller needs a mutable result.groupingByConcurrentfor parallel streams: when using a parallel stream withgroupingBy, the result is still correct but the collector serializes access to each group list.groupingByConcurrentuses aConcurrentMapand allows true parallel accumulation — at the cost of losing encounter order within groups.- Downstream
averagingIntreturns0.0on empty groups: for groups created bygroupingBy, there are no empty groups (elements only appear where they match). But if you usecollectingAndThenand the source is empty, numeric collectors return their identity (0.0,0). Be explicit about empty-source semantics. joiningon a non-string stream gives a compile error:Collectors.joining()requires aStream<CharSequence>. If you call it on aStream<Person>without.map(Person::name)first, the code will not compile — a helpful guardrail.summarizingIntwrapsintviaapplyAsInt: the function is anToIntFunction<T>, so method references likePerson::salarywork only ifsalary()returnsint, notInteger.
Code example
import java.util.*;
import java.util.stream.*;
import java.util.function.*;
public class JavaLabRunner {
record Person(String name, String dept, int salary) {}
public static void main(String[] args) {
List<Person> people = List.of(
new Person("Alice", "Eng", 90000),
new Person("Bob", "Eng", 85000),
new Person("Carol", "HR", 70000),
new Person("Dave", "HR", 72000),
new Person("Eve", "Eng", 95000)
);
// groupingBy — map of lists
Map<String, List<Person>> byDept = people.stream()
.collect(Collectors.groupingBy(Person::dept));
System.out.println("By dept: " + byDept.keySet());
// groupingBy + downstream: count per dept
Map<String, Long> countByDept = people.stream()
.collect(Collectors.groupingBy(Person::dept, Collectors.counting()));
System.out.println("Count by dept: " + countByDept);
// groupingBy + downstream: average salary
Map<String, Double> avgSalary = people.stream()
.collect(Collectors.groupingBy(Person::dept, Collectors.averagingInt(Person::salary)));
System.out.println("Avg salary: " + avgSalary);
// partitioningBy — boolean split
Map<Boolean, List<Person>> highEarners = people.stream()
.collect(Collectors.partitioningBy(p -> p.salary() >= 85000));
System.out.println("High earners: " + highEarners.get(true).stream().map(Person::name).toList());
// joining
String names = people.stream()
.map(Person::name)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println("Names: " + names);
// toMap — watch for duplicate key exception
Map<String, Integer> nameSalary = people.stream()
.collect(Collectors.toMap(Person::name, Person::salary));
System.out.println("Alice salary: " + nameSalary.get("Alice"));
// summarizingInt — stats in one pass
IntSummaryStatistics stats = people.stream()
.collect(Collectors.summarizingInt(Person::salary));
System.out.printf("Salary: min=%d, max=%d, avg=%.0f%n",
stats.getMin(), stats.getMax(), stats.getAverage());
}
}
Interview questions you should be able to answer
-
Q: You need to group a list of transactions by currency and compute the total amount per currency in one stream pass. How do you write this, and what is the return type?
Collectors.groupingBy(Transaction::currency, Collectors.summingLong(Transaction::amount))— returnsMap<String, Long>. The downstreamsummingLongreplaces the defaulttoList()accumulation with summation, so the map values are totals rather than lists of transactions. This is a single-pass O(n) operation with no intermediate collection. -
Q:
Collectors.toMapthrows in production on data that worked in your tests. What is the most likely cause and how do you fix it?Duplicate keys.
toMap(keyMapper, valueMapper)throwsIllegalStateExceptionthe first time two elements map to the same key. Test data often has unique keys by construction. Fix: supply a merge function as the third argument —(existing, replacement) -> existingto keep the first,(a, b) -> bto keep the last, or a combining function like(a, b) -> a + bfor numeric accumulation. For complex types, the merge function can apply domain-specific conflict resolution. -
Q: What does
collectingAndThendo, and give a real-world example where it is useful?collectingAndThen(downstream, finisher)appliesdownstreamfirst, then passes its result throughfinisher. A common use:collectingAndThen(toList(), Collections::unmodifiableList)wraps the collected list in an unmodifiable view before returning it — enforcing immutability at the collection boundary. Another use: wrapgroupingBy(counting())with a finisher that extracts the maximum-count entry, doing a two-level reduction in one collector expression. -
Q: How would you compute the minimum salary, maximum salary, and employee count for each department in a single stream pass?
Use
groupingBy(Person::dept, summarizingInt(Person::salary))— returnsMap<String, IntSummaryStatistics>.IntSummaryStatisticsholds count, sum, min, max, and average, all computed in one accumulation pass per element with no intermediate objects. Alternatively, for Java 12+,teeingwithminByandmaxByplus a separatecounting()downstream can be composed, thoughsummarizingIntis more concise here. -
Q: Why should you use
groupingByConcurrentinstead ofgroupingBywhen the stream is parallel?groupingByon a parallel stream is correct but uses a concurrent merge step: each thread builds its own partial map, and the combiner then merges them — this involves locking or copying per merge.groupingByConcurrentreturns aConcurrentMapand allows all threads to accumulate into the same map concurrently using fine-grained locking (e.g.,ConcurrentHashMap), avoiding the merge phase entirely. The trade-off:groupingByConcurrentdoes not guarantee encounter order within each group's list. If order matters, usegroupingBy; if throughput matters, usegroupingByConcurrent. -
Q: What is
Collectors.teeingand when is it the right tool?teeing(c1, c2, merger)(Java 12+) feeds every element to two independent downstream collectors simultaneously and merges their results with aBiFunction. It is the right tool when you need two different aggregations over the same stream and do not want to iterate the source twice. Example: compute min and max salary in one pass usingminByandmaxByas the two downstreams, then merge into aRangerecord. Before Java 12, this required eithersummarizingInt(only works for numeric stats) or two separate stream operations.
Further reading
Click Run to execute the code.