8.3 Advanced Collectors, Grouping, Partitioning, and Teeing
Key Takeaways
The Collectors utility class provides mutable reduction implementations including toList, toSet, toCollection, toMap, and unmodifiable collection factories.
toMap requires an explicit merge function to resolve key collisions; omitting the merge function when duplicate keys occur causes an IllegalStateException at runtime.
groupingBy organizes elements into multi-valued maps and supports downstream collectors for nested reductions, while partitioningBy always divides elements by a boolean predicate into a Map containing both true and false keys.
The Collectors.teeing collector (Java 12+) evaluates two independent downstream collectors simultaneously over a single stream pass and merges their results using a BiFunction.
Advanced Collectors, Grouping, Partitioning, and Teeing
The collect() terminal operation represents the most powerful and versatile reduction mechanism in the Stream API. Rather than combining elements into a single scalar value like reduce(), collect() performs a mutable reduction, accumulating input elements into a mutable result container (such as a List, Set, Map, StringBuilder, or custom aggregate).
The java.util.stream.Collectors class provides a rich library of static factory methods for collection aggregation, grouping, partitioning, string formatting, and multi-stage downstream transformations. For the Java SE 21 Developer (1Z0-830) exam, advanced mastery of downstream collectors, key collision strategies, and the teeing collector is essential.
1. The Collector Interface Architecture
A Collector<T, A, R> is parameterized by three types:
T: The type of input elements to be collected.A: The mutable accumulation type (often an internal container likeList<T>orStringBuilder).R: The final result type returned by the collection process.
The Five Core Methods of a Collector
supplier(): Creates and returns a new, empty mutable result container (Supplier<A>).accumulator(): Folds an input element into the mutable container (BiConsumer<A, T>).combiner(): Merges two partial result containers together during parallel processing (BinaryOperator<A>).finisher(): Transforms the intermediate containerAinto the final resultR(Function<A, R>).characteristics(): Returns a set ofCharacteristics(CONCURRENT,UNORDERED,IDENTITY_FINISH) informing the stream execution engine of optimization capabilities.
// Three-argument collect() overload (imperative style equivalent to Collector):
List<String> list = Stream.of("a", "b", "c").collect(
ArrayList::new, // Supplier: creates container
ArrayList::add, // Accumulator: adds item
ArrayList::addAll // Combiner: merges containers in parallel
);
2. Standard and Unmodifiable Collection Collectors
Java provides multiple collectors to accumulate elements into collections:
Stream<String> techStream = Stream.of("Java", "Kotlin", "Java", "Rust");
// 1. toList() vs Stream.toList()
List<String> list1 = techStream.collect(Collectors.toList()); // Standard modifiable List (ArrayList)
List<String> list2 = Stream.of("A", "B").toList(); // Java 16+ concise, unmodifiable List (allows nulls)
// 2. toUnmodifiableList() (Java 10+)
List<String> unmodList = Stream.of("A", "B").collect(Collectors.toUnmodifiableList()); // Immutable, rejects nulls
// 3. toSet() and toUnmodifiableSet()
Set<String> set = Stream.of("A", "B", "A").collect(Collectors.toSet()); // Set of ["A", "B"]
Set<String> unmodSet = Stream.of("A", "B").collect(Collectors.toUnmodifiableSet());
// 4. toCollection() - Specify concrete collection implementation
TreeSet<String> treeSet = Stream.of("Z", "A", "M")
.collect(Collectors.toCollection(TreeSet::new)); // Sorted: ["A", "M", "Z"]
LinkedList<String> linkedList = Stream.of("1", "2")
.collect(Collectors.toCollection(LinkedList::new));
Stream.toList() vs Collectors.toList() vs Collectors.toUnmodifiableList()
| Feature | Stream.toList() (Java 16+) | Collectors.toList() | Collectors.toUnmodifiableList() (Java 10+) |
|---|---|---|---|
| Syntax | Direct terminal method on Stream | Passed to .collect() | Passed to .collect() |
| Modifiable? | No (Throws UnsupportedOperationException) | Yes (Typically ArrayList) | No (Throws UnsupportedOperationException) |
| Allows Nulls? | Yes | Yes | No (Throws NullPointerException) |
| Memory / Allocation | Optimized internal array | Standard ArrayList | Unmodifiable wrapper / array |
3. Map Collectors and Key Collision Resolution
The Collectors.toMap() collector converts stream elements into key-value map entries. There are three primary overloads:
Overload 1: Two-Argument toMap(keyMapper, valueMapper)
Throws IllegalStateException at runtime if duplicate keys are encountered!
List<String> fruits = List.of("Apple", "Banana", "Cherry");
Map<String, Integer> map = fruits.stream()
.collect(Collectors.toMap(
Function.identity(), // Key: the string itself
String::length // Value: length of string
));
// Result: {"Apple": 5, "Banana": 6, "Cherry": 6}
// RUNTIME CRASH (Duplicate Key: "Apple"):
// List.of("Apple", "Apricot", "Apple").stream()
// .collect(Collectors.toMap(s -> s.charAt(0), s -> s));
// Throws: java.lang.IllegalStateException: Duplicate key A
Overload 2: Three-Argument toMap(keyMapper, valueMapper, mergeFunction)
Provides a BinaryOperator merge function to resolve duplicate key collisions without throwing an exception.
List<String> items = List.of("apple", "apricot", "banana", "avocado");
// Keep the longer string when keys (first letter) collide:
Map<Character, String> longestByChar = items.stream()
.collect(Collectors.toMap(
s -> s.charAt(0), // Key: first char
Function.identity(), // Value: string
(existing, replacement) -> existing.length() >= replacement.length() ? existing : replacement // Merger
));
// Result: {'a': "apricot", 'b': "banana"}
Overload 3: Four-Argument toMap(keyMapper, valueMapper, mergeFunction, mapSupplier)
Allows specifying a custom Map implementation (e.g., TreeMap, LinkedHashMap).
TreeMap<Character, String> sortedMap = items.stream()
.collect(Collectors.toMap(
s -> s.charAt(0),
s -> s,
(v1, v2) -> v1 + ", " + v2,
TreeMap::new // mapSupplier: returns sorted TreeMap
));
4. String Joining and Numeric Summaries
List<String> frameworks = List.of("Spring", "Micronaut", "Quarkus");
// joining()
String joined1 = frameworks.stream().collect(Collectors.joining()); // "SpringMicronautQuarkus"
String joined2 = frameworks.stream().collect(Collectors.joining(", ")); // "Spring, Micronaut, Quarkus"
String joined3 = frameworks.stream().collect(Collectors.joining(", ", "[", "]")); // "[Spring, Micronaut, Quarkus]"
// Summarizing Numbers
List<String> words = List.of("Java", "SE", "21");
IntSummaryStatistics stats = words.stream()
.collect(Collectors.summarizingInt(String::length));
// stats.getAverage(), stats.getCount(), stats.getMax(), stats.getMin(), stats.getSum()
5. Multi-Level Grouping with groupingBy
Collectors.groupingBy partitions stream elements into a Map<K, List<T>> based on a classifier function. Like toMap, it offers three overloads:
record Employee(String name, String department, double salary) {}
List<Employee> employees = List.of(
new Employee("Alice", "Engineering", 120_000),
new Employee("Bob", "Engineering", 110_000),
new Employee("Charlie", "HR", 85_000),
new Employee("David", "Finance", 95_000),
new Employee("Eve", "HR", 90_000)
);
// 1-Arg: groupingBy(classifier) -> Map<String, List<Employee>>
Map<String, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
// 2-Arg: groupingBy(classifier, downstreamCollector)
// Count employees per department -> Map<String, Long>
Map<String, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
// Extract employee names into a Set per department -> Map<String, Set<String>>
Map<String, Set<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toSet())
));
// 3-Arg: groupingBy(classifier, mapFactory, downstreamCollector)
// Returns sorted TreeMap<String, Double> of average salary per department
TreeMap<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
TreeMap::new,
Collectors.averagingDouble(Employee::salary)
));
6. Partitioning with partitioningBy
Collectors.partitioningBy is a specialized form of grouping that takes a Predicate<? super T> and divides elements into two groups: true and false.
Important
The Partitioning Invariant: The resulting map from partitioningBy ALWAYS contains exactly two keys: Boolean.TRUE and Boolean.FALSE, even if one (or both) of the partitions contains zero matching elements!
List<Integer> numbers = List.of(10, 15, 20, 25, 30);
// 1-Arg: partitioningBy(predicate) -> Map<Boolean, List<Integer>>
Map<Boolean, List<Integer>> evenOdd = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
// evenOdd.get(true) -> [10, 20, 30]
// evenOdd.get(false) -> [15, 25]
// 2-Arg: partitioningBy(predicate, downstreamCollector)
Map<Boolean, Long> countEvenOdd = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0, Collectors.counting()));
// {true=3, false=2}
// Empty Partition Demonstration:
Map<Boolean, List<Integer>> emptyTrue = List.of(1, 3, 5).stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
// Result: {true=[], false=[1, 3, 5]} -> true key is PRESENT with an empty list!
7. Downstream Transformations (Java 9+)
Java 9 added downstream adaptation collectors that can be nested inside groupingBy and partitioningBy:
Collectors.filtering(Predicate, downstream): Filters elements before downstream accumulation, ensuring categories with zero matching elements are still present in the map (unlike a top-level stream.filter()which removes empty categories entirely).Collectors.flatMapping(Function<T, Stream<R>>, downstream): Flattens nested streams into the downstream container.Collectors.collectingAndThen(downstream, finisher): Applies a post-processing finisher function to the result of a downstream collector.
// filtering(): Keeps departments with 0 matching high-earners
Map<String, Set<String>> highEarnersByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.filtering(
e -> e.salary() > 100_000,
Collectors.mapping(Employee::name, Collectors.toSet())
)
));
// Finance dept is present: {"Engineering": ["Alice", "Bob"], "HR": [], "Finance": []}
8. Compound Aggregation with Collectors.teeing (Java 12+)
Introduced in Java 12, Collectors.teeing() takes two independent downstream collectors and a merging BiFunction. It passes every stream element to both collectors simultaneously in a single pass, then combines their results.
record MinMax(int min, int max) {}
List<Integer> nums = List.of(42, 12, 89, 5, 67, 99, 23);
MinMax minMax = nums.stream().collect(
Collectors.teeing(
Collectors.minBy(Integer::compareTo), // Collector 1: Optional<Integer>
Collectors.maxBy(Integer::compareTo), // Collector 2: Optional<Integer>
(minOpt, maxOpt) -> new MinMax(minOpt.orElse(0), maxOpt.orElse(0)) // Merger
)
);
// minMax = MinMax[min=5, max=99]
// Calculate Mean and Count in a single pass:
record AverageStats(double average, long count) {}
AverageStats stats = nums.stream().collect(
Collectors.teeing(
Collectors.averagingInt(n -> n), // Collector 1
Collectors.counting(), // Collector 2
AverageStats::new // Merger: (avg, cnt) -> new AverageStats(avg, cnt)
)
);
What happens when the following code snippet is compiled and executed?
List<String> list = List.of("cat", "camel", "cheetah", "dog");
Map<Character, Integer> map = list.stream()
.collect(Collectors.toMap(
s -> s.charAt(0),
String::length
));
System.out.println(map.size());
What is the result?
2
4
An IllegalStateException is thrown at runtime due to duplicate key collision
The code fails to compile because toMap requires 3 arguments
Examine the following code using partitioningBy:
List<String> words = List.of("java", "kotlin", "scala");
Map<Boolean, List<String>> result = words.stream()
.collect(Collectors.partitioningBy(s -> s.startsWith("z")));
System.out.println(result.keySet() + " " + result.get(true));
What is the printed output?
[false] null
[false] []
[false, true] null
[false, true] []
Given the following code snippet utilizing Collectors.teeing:
List<Integer> numbers = List.of(10, 20, 30, 40);
String result = numbers.stream().collect(
Collectors.teeing(
Collectors.summingInt(n -> n),
Collectors.counting(),
(sum, count) -> "Sum: " + sum + ", Count: " + count
)
);
System.out.println(result);
What is the printed output?
Sum: 100, Count: 4
Sum: 100, Count: 4.0
Compilation fails because Collectors.teeing requires two collectors of identical return types
Compilation fails because summingInt returns a Double
Consider the following code comparing upstream filter vs downstream filtering:
record Item(String category, int price) {}
List<Item> items = List.of(
new Item("Book", 25),
new Item("Electronics", 500)
);
Map<String, List<Item>> map = items.stream()
.collect(Collectors.groupingBy(
Item::category,
Collectors.filtering(i -> i.price() > 100, Collectors.toList())
));
System.out.println(map.get("Book"));
What is printed upon executing this code?
null
[]
[Item[category=Book, price=25]]
An NoSuchElementException is thrown
Sections you finish are checked off in the contents.