9.2 The Collectors API: Downstream Reductions, Grouping, and Partitioning
Key Takeaways
The Collector<T, A, R> interface orchestrates mutable container reductions via four components: supplier(), accumulator(), combiner(), and finisher(), parameterized with characteristics.
The 2-argument Collectors.toMap() throws an IllegalStateException upon encountering duplicate keys, and throws NullPointerException if any valueMapper evaluates to null.
Numeric collectors (summingInt, averagingDouble, summarizingLong) and string joining(delimiter, prefix, suffix) perform specialized aggregations directly within terminal operations.
Multi-level groupingBy supports 1-, 2-, and 3-argument overloads allowing custom Map container suppliers (e.g., TreeMap::new) and arbitrary downstream reduction collectors.
Collectors.partitioningBy(Predicate) always returns a Map<Boolean, D> containing both true and false keys regardless of element matches, while Collectors.teeing() executes two collectors on a single stream pass and merges their results.
9.2 The Collectors API: Downstream Reductions, Grouping, and Partitioning
In Java Stream pipelines, terminal operations convert intermediate streams into concrete domain collections, primitive aggregations, or complex grouped data structures. While Stream.reduce() performs immutable folding, the mutable reduction mechanism provided by Stream.collect(Collector) is the industry standard for high-performance data processing. The utility class java.util.stream.Collectors provides a rich set of pre-built, reusable implementations. The 1Z0-829 examination tests your ability to choose, configure, and nest collectors, handle map key collisions, apply downstream reductions, and use modern additions like partitioningBy and Java 12's teeing collector.
The Architecture of Collector<T, A, R>
A Collector encapsulates the recipe for performing a mutable reduction into an intermediate accumulator container A, ultimately producing a final result R from elements of type T.
The Collector<T, A, R> interface defines four core functional components:
Supplier<A> supplier(): Creates and returns a new, mutable intermediate container (e.g.,ArrayList::new,StringBuilder::new, orHashMap::new).BiConsumer<A, T> accumulator(): Folds a single stream elementTinto the mutable containerA(e.g.,List::add).BinaryOperator<A> combiner(): Merges two partial accumulation containers into one. This method is critical for parallel streams when different threads process distinct sub-tasks.Function<A, R> finisher(): Transforms the intermediate containerAinto the final result typeR. If containerArequires no final transformation, the collector reports theIDENTITY_FINISHcharacteristic and the identity function is used.
Collectors can also report optimization characteristics via characteristics():
IDENTITY_FINISH: Indicates the finisher is an unchecked identity cast fromAtoR.CONCURRENT: Indicates the accumulator can be invoked concurrently by multiple threads on a shared container without synchronization (used primarily with concurrent data structures).UNORDERED: Indicates the collection operation does not depend on preserving encounter order.
Basic Collection Targets: Lists, Sets, and Custom Collections
List<String> items = Stream.of("alpha", "beta", "gamma").toList(); // Java 16+ unmodifiable list
// 1. Collectors.toList(): Typically returns a modifiable ArrayList (no guarantee)
List<String> modifiableList = items.stream().collect(Collectors.toList());
// 2. Collectors.toSet(): Collects into a HashSet (eliminates duplicates, drops order)
Set<String> set = items.stream().collect(Collectors.toSet());
// 3. Collectors.toCollection(Supplier): Collects into an explicit collection type
TreeSet<String> sortedSet = items.stream()
.collect(Collectors.toCollection(TreeSet::new));
LinkedList<String> linkedList = items.stream()
.collect(Collectors.toCollection(LinkedList::new));
// 4. Collectors.toUnmodifiableList() / toUnmodifiableSet() (Java 10+)
List<String> immutableList = items.stream().collect(Collectors.toUnmodifiableList());
Map Collection and the Duplicate Key Trap
Transforming stream elements into a Map using Collectors.toMap() requires careful attention to key collisions and null handling.
The Three toMap() Overloads
-
2-Argument:
toMap(keyMapper, valueMapper)- Uses elements to generate keys and values.
- Throws
IllegalStateExceptionif duplicate keys are generated!
-
3-Argument:
toMap(keyMapper, valueMapper, mergeFunction)- When duplicate keys collide, invokes
mergeFunction.apply(existingVal, newVal)to resolve the collision.
- When duplicate keys collide, invokes
-
4-Argument:
toMap(keyMapper, valueMapper, mergeFunction, mapSupplier)- Supplies a custom
Mapimplementation (e.g.,TreeMap::new,LinkedHashMap::new).
- Supplies a custom
record Employee(int id, String department, double salary) {}
List<Employee> staff = List.of(
new Employee(101, "Engineering", 90000),
new Employee(102, "HR", 65000),
new Employee(103, "Engineering", 95000)
);
// 2-arg toMap: id is unique -> SUCCESS
Map<Integer, Double> idSalaryMap = staff.stream()
.collect(Collectors.toMap(Employee::id, Employee::salary));
// 2-arg toMap on duplicate department -> THROWS IllegalStateException!
// Map<String, Double> deptSalaryMap = staff.stream()
// .collect(Collectors.toMap(Employee::department, Employee::salary));
// 3-arg toMap with mergeFunction: keep the higher salary on collision
Map<String, Double> topDeptSalary = staff.stream()
.collect(Collectors.toMap(
Employee::department,
Employee::salary,
(existing, incoming) -> Math.max(existing, incoming)
));
// 4-arg toMap: preserves sorted key order via TreeMap
TreeMap<String, Double> sortedDeptMap = staff.stream()
.collect(Collectors.toMap(
Employee::department,
Employee::salary,
Double::sum,
TreeMap::new
));
Warning
The toMap() Null Value Trap
Collectors.toMap() rejects null values: if any valueMapper returns null, the collection throws a NullPointerException even though the underlying HashMap itself would permit null values! If your stream values can be null, collect into an existing map manually or filter out nulls.
String Joining and Numeric Aggregations
Collectors.joining()
Joins elements of CharSequence (or String) into a single output string.
joining(): Concatenates elements with no delimiter.joining(delimiter): Concatenates elements separated by the delimiter.joining(delimiter, prefix, suffix): Concatenates with delimiter, enclosed by prefix and suffix.
String csv = Stream.of("red", "green", "blue")
.collect(Collectors.joining(", ", "[", "]")); // "[red, green, blue]"
Numeric and Statistical Collectors
Rather than mapping to primitive streams (mapToInt()), you can aggregate directly in collect():
counting(): ReturnsCollector<T, ?, Long>counting elements.summingInt(ToIntFunction),summingLong(),summingDouble(): Returns the sum as boxedInteger,Long, orDouble.averagingInt(),averagingLong(),averagingDouble(): Always returnsDouble(even foraveragingInt()).summarizingInt(),summarizingLong(),summarizingDouble(): Returns rich summary objects (IntSummaryStatistics,DoubleSummaryStatistics) providingcount,sum,min,max, andaveragein a single pass.
IntSummaryStatistics stats = staff.stream()
.collect(Collectors.summarizingInt(e -> (int) e.salary()));
System.out.println("Max: " + stats.getMax() + ", Avg: " + stats.getAverage());
Post-Processing: Collectors.collectingAndThen()
Performs a collection and then immediately applies a finishing transformation function to the accumulated result:
List<String> readOnlyList = staff.stream()
.map(Employee::department)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList
));
Classification with groupingBy
Collectors.groupingBy() organizes stream elements into groups based on a classification function, analogous to GROUP BY in SQL.
The Three groupingBy Overloads
- 1-Argument:
groupingBy(classifier)- Defaults to a
HashMap<K, List<T>>.
- Defaults to a
- 2-Argument:
groupingBy(classifier, downstreamCollector)- Classifies by key, but processes values in each bucket using the specified downstream collector instead of accumulating into a
List.
- Classifies by key, but processes values in each bucket using the specified downstream collector instead of accumulating into a
- 3-Argument:
groupingBy(classifier, mapSupplier, downstreamCollector)- Allows choosing the concrete
Mapclass (e.g.,TreeMap::new).
- Allows choosing the concrete
// 1-arg: Map<String, List<Employee>>
Map<String, List<Employee>> byDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department));
// 2-arg: Map<String, Long> (count employees per department)
Map<String, Long> headCount = staff.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
// 2-arg: Map<String, Double> (average salary per department)
Map<String, Double> avgSalary = staff.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.averagingDouble(Employee::salary)));
// 3-arg: TreeMap<String, Set<Double>>
TreeMap<String, Set<Double>> salariesByDept = staff.stream()
.collect(Collectors.groupingBy(
Employee::department,
TreeMap::new,
Collectors.mapping(Employee::salary, Collectors.toSet())
));
Downstream Collectors: mapping(), filtering(), and flatMapping()
mapping(mapper, downstream): Transforms bucket elements before downstream collection.filtering(predicate, downstream)(Java 9+): Filters bucket elements. Crucial Advantage: Unlike a pipeline.filter()which eliminates keys entirely if all their items are dropped,filtering()preserves the key with an empty downstream collection!flatMapping(mapper, downstream)(Java 9+): Flattens nested collections generated from bucket elements.
Partitioning with partitioningBy
Collectors.partitioningBy() is a specialized form of grouping that divides stream elements into two categories based on a Predicate<? super T>.
- 1-Argument:
partitioningBy(predicate)-> ReturnsMap<Boolean, List<T>>. - 2-Argument:
partitioningBy(predicate, downstreamCollector)-> ReturnsMap<Boolean, D>.
Map<Boolean, List<Employee>> highEarners = staff.stream()
.collect(Collectors.partitioningBy(e -> e.salary() >= 90000));
// highEarners.get(true) contains employees earning >= 90000
// highEarners.get(false) contains employees earning < 90000
Warning
The Partitioning Key Invariant Rule
A frequent trap concerns the keys present in a partitioned map. While groupingBy only creates keys that exist in the stream elements, partitioningBy ALWAYS contains BOTH Boolean.TRUE and Boolean.FALSE keys, even if all elements match the predicate (or if no elements match). The absent partition key will simply map to an empty list or the empty value of the downstream collector.
Consider the following code snippet:
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class ToMapCollisionTest {
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "apricot", "cherry");
Map<Integer, String> result = words.stream()
.collect(Collectors.toMap(
String::length,
s -> s
));
System.out.println(result.size());
}
}
What is the outcome of compiling and running this code?
The program compiles cleanly, but throws an IllegalStateException at runtime due to duplicate keys.
The program compiles and prints 3.
A compilation error occurs because toMap requires three arguments when processing strings.
The program compiles and prints 4, allowing the later word to overwrite earlier entries.
Consider the following program evaluating integer parity:
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class PartitioningKeysDemo {
public static void main(String[] args) {
List<Integer> evens = List.of(2, 4, 6, 8, 10);
Map<Boolean, List<Integer>> partitioned = evens.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(partitioned.containsKey(false) + " " + partitioned.get(false).size());
}
}
What is printed to standard output when this code executes?
true null
false 0
false followed by a NullPointerException
true 0
Given the following record and stream pipeline:
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
record Product(String category, double price) {}
public class GroupingDownstreamTest {
public static void main(String[] args) {
List<Product> products = List.of(
new Product("Hardware", 20.0),
new Product("Hardware", 30.0),
new Product("Software", 50.0)
);
Map<String, Double> averages = products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.averagingDouble(Product::price)
));
System.out.println(averages.get("Hardware"));
}
}
What value is output to the console?
25.0
Compilation fails because averagingDouble produces DoubleSummaryStatistics rather than Double.
25
50.0
Which of the following statements accurately characterizes the behavior of the Collectors.teeing() collector introduced in Java 12?
It filters stream elements into two branches based on a predicate, routing matching items to the first collector and non-matching items to the second.
It splits an active stream pipeline into two distinct streams so that terminal operations can be executed twice on the same data source.
It requires both downstream collectors to produce results of the identical data type before executing an identity cast.
It passes every element of a single stream through two independent downstream collectors simultaneously and merges their accumulated results using a BiFunction.
Sections you finish are checked off in the contents.