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.
Last updated: September 2026

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 like List<T> or StringBuilder).
  • R: The final result type returned by the collection process.

The Five Core Methods of a Collector

  1. supplier(): Creates and returns a new, empty mutable result container (Supplier<A>).
  2. accumulator(): Folds an input element into the mutable container (BiConsumer<A, T>).
  3. combiner(): Merges two partial result containers together during parallel processing (BinaryOperator<A>).
  4. finisher(): Transforms the intermediate container A into the final result R (Function<A, R>).
  5. characteristics(): Returns a set of Characteristics (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()

FeatureStream.toList() (Java 16+)Collectors.toList()Collectors.toUnmodifiableList() (Java 10+)
SyntaxDirect terminal method on StreamPassed to .collect()Passed to .collect()
Modifiable?No (Throws UnsupportedOperationException)Yes (Typically ArrayList)No (Throws UnsupportedOperationException)
Allows Nulls?YesYesNo (Throws NullPointerException)
Memory / AllocationOptimized internal arrayStandard ArrayListUnmodifiable 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.

Stream Elements{Collector 1R1Collector 2R2merger(R1,R2)Rfinal\text{Stream Elements} \longrightarrow \begin{cases} \text{Collector 1} \longrightarrow R_1 \\ \text{Collector 2} \longrightarrow R_2 \end{cases} \longrightarrow \text{merger}(R_1, R_2) \longrightarrow R_{final}

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)
    )
);
Loading diagram...
Collectors Taxonomy, Grouping, Partitioning, and Teeing Aggregation Architecture
Test Your Knowledge

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?

A
B
C
D
Test Your Knowledge

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?

A
B
C
D
Test Your Knowledge

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?

A
B
C
D
Test Your Knowledge

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?

A
B
C
D