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
partitioningByALWAYS contains exactly two keys:Boolean.TRUEandBoolean.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?
What is the result?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());
Examine the following code using partitioningBy:
What is the printed output?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));
Given the following code snippet utilizing Collectors.teeing:
What is the printed output?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);
Consider the following code comparing upstream filter vs downstream filtering:
What is printed upon executing this code?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"));