Skip to content
Codeloom
Java

Advanced Stream Collectors and Custom Collectors in Java

Go beyond basic Java Stream collectors. Learn groupingBy compositions, teeing, custom Collector implementations, and performance considerations.

·5 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How to compose collectors with groupingBy and partitioningBy
  • How to use the teeing collector for dual aggregations
  • How to build custom Collector implementations from scratch

Prerequisites

  • Java Streams basics (map, filter, collect)
  • Familiarity with lambda expressions
  • Java 12+ (for teeing)

Most Java developers use Collectors.toList(), Collectors.toMap(), and maybe Collectors.groupingBy(). But the Collectors API goes much deeper. You can compose collectors, run dual aggregations in a single pass, and build custom collectors for domain-specific transformations.

Composing collectors with groupingBy

The single-argument groupingBy collects values into lists. The two-argument version lets you specify a downstream collector, which opens up powerful compositions.

record Order(String customer, String product, double amount, LocalDate date) {}

List<Order> orders = List.of(
    new Order("Alice", "Laptop", 1200, LocalDate.of(2026, 1, 15)),
    new Order("Bob", "Phone", 800, LocalDate.of(2026, 1, 20)),
    new Order("Alice", "Mouse", 25, LocalDate.of(2026, 2, 10)),
    new Order("Alice", "Keyboard", 75, LocalDate.of(2026, 2, 12)),
    new Order("Bob", "Tablet", 500, LocalDate.of(2026, 2, 18))
);

Group and count

Map<String, Long> orderCountByCustomer = orders.stream()
    .collect(Collectors.groupingBy(Order::customer, Collectors.counting()));
// {Alice=3, Bob=2}

Group and sum

Map<String, Double> totalByCustomer = orders.stream()
    .collect(Collectors.groupingBy(
        Order::customer,
        Collectors.summingDouble(Order::amount)
    ));
// {Alice=1300.0, Bob=1300.0}

Multi-level grouping

// group by customer, then by month
Map<String, Map<Month, List<Order>>> nested = orders.stream()
    .collect(Collectors.groupingBy(
        Order::customer,
        Collectors.groupingBy(o -> o.date().getMonth())
    ));
// {Alice={JANUARY=[...], FEBRUARY=[...]}, Bob={JANUARY=[...], FEBRUARY=[...]}}

Group, then transform downstream

// group by customer, collect just the product names
Map<String, List<String>> productsByCustomer = orders.stream()
    .collect(Collectors.groupingBy(
        Order::customer,
        Collectors.mapping(Order::product, Collectors.toList())
    ));
// {Alice=[Laptop, Mouse, Keyboard], Bob=[Phone, Tablet]}

Group with filtering

// group by customer, but only include orders over $100
Map<String, List<Order>> bigOrdersByCustomer = orders.stream()
    .collect(Collectors.groupingBy(
        Order::customer,
        Collectors.filtering(o -> o.amount() > 100, Collectors.toList())
    ));
// {Alice=[Order[Laptop, 1200]], Bob=[Order[Phone, 800], Order[Tablet, 500]]}

The teeing collector

Collectors.teeing() (Java 12+) runs two collectors in parallel on the same stream and merges their results. This is useful when you need two aggregations without iterating twice.

// calculate both average and count in a single pass
record Stats(long count, double average) {}

Stats orderStats = orders.stream()
    .collect(Collectors.teeing(
        Collectors.counting(),
        Collectors.averagingDouble(Order::amount),
        Stats::new
    ));
// Stats[count=5, average=520.0]

Min and max in one pass

record Range(Optional<Order> cheapest, Optional<Order> mostExpensive) {}

Range range = orders.stream()
    .collect(Collectors.teeing(
        Collectors.minBy(Comparator.comparingDouble(Order::amount)),
        Collectors.maxBy(Comparator.comparingDouble(Order::amount)),
        Range::new
    ));

Partition-like logic with teeing

record SplitOrders(List<Order> small, List<Order> large) {}

SplitOrders split = orders.stream()
    .collect(Collectors.teeing(
        Collectors.filtering(o -> o.amount() <= 100, Collectors.toList()),
        Collectors.filtering(o -> o.amount() > 100, Collectors.toList()),
        SplitOrders::new
    ));

partitioningBy for boolean splits

When you need to split a stream into two groups based on a predicate, partitioningBy is cleaner than groupingBy:

Map<Boolean, List<Order>> partitioned = orders.stream()
    .collect(Collectors.partitioningBy(o -> o.amount() > 100));

List<Order> expensive = partitioned.get(true);
List<Order> cheap = partitioned.get(false);

Like groupingBy, it accepts a downstream collector:

Map<Boolean, Long> counts = orders.stream()
    .collect(Collectors.partitioningBy(
        o -> o.amount() > 100,
        Collectors.counting()
    ));
// {false=2, true=3}

Building a custom Collector

Sometimes built-in collectors are not enough. The Collector interface has four components:

  1. supplier: creates the mutable accumulation container
  2. accumulator: adds an element to the container
  3. combiner: merges two containers (for parallel streams)
  4. finisher: transforms the container into the final result

Example: collecting to a frequency map sorted by value

public static <T> Collector<T, ?, LinkedHashMap<T, Long>> toFrequencyMap() {
    return Collector.of(
        // supplier: create accumulation map
        HashMap<T, Long>::new,

        // accumulator: increment count for each element
        (map, element) -> map.merge(element, 1L, Long::sum),

        // combiner: merge two maps (for parallel streams)
        (map1, map2) -> {
            map2.forEach((key, value) -> map1.merge(key, value, Long::sum));
            return map1;
        },

        // finisher: sort by frequency descending
        (map) -> {
            LinkedHashMap<T, Long> sorted = new LinkedHashMap<>();
            map.entrySet().stream()
                .sorted(Map.Entry.<T, Long>comparingByValue().reversed())
                .forEachOrdered(e -> sorted.put(e.getKey(), e.getValue()));
            return sorted;
        }
    );
}

// usage
LinkedHashMap<String, Long> freq = orders.stream()
    .map(Order::customer)
    .collect(toFrequencyMap());
// {Alice=3, Bob=2}

Example: collecting into a running average

public static Collector<Double, ?, List<Double>> toRunningAverage() {
    return Collector.of(
        () -> new double[]{0, 0}, // [sum, count]

        (state, value) -> {
            // we need to build a list, so this approach needs rethinking
        },

        (s1, s2) -> { s1[0] += s2[0]; s1[1] += s2[1]; return s1; },

        state -> List.of(state[0] / state[1])
    );
}

A better approach for running averages uses a mutable container:

public static Collector<Double, ?, List<Double>> toRunningAverages() {
    class Accumulator {
        double sum = 0;
        int count = 0;
        List<Double> averages = new ArrayList<>();
    }

    return Collector.of(
        Accumulator::new,
        (acc, value) -> {
            acc.sum += value;
            acc.count++;
            acc.averages.add(acc.sum / acc.count);
        },
        (a1, a2) -> {
            throw new UnsupportedOperationException("Sequential only");
        },
        acc -> Collections.unmodifiableList(acc.averages),
        Collector.Characteristics.IDENTITY_FINISH
    );
}

// usage
List<Double> running = Stream.of(10.0, 20.0, 30.0, 40.0)
    .collect(toRunningAverages());
// [10.0, 15.0, 20.0, 25.0]

Collector characteristics

The Characteristics enum controls collector behavior:

  • CONCURRENT: the accumulator can be called concurrently from multiple threads (container must be thread-safe)
  • UNORDERED: the collection does not depend on encounter order
  • IDENTITY_FINISH: the finisher is the identity function (skip the finisher step)
// toList() has IDENTITY_FINISH because the accumulator IS the result
// toUnmodifiableList() does NOT because the finisher wraps in unmodifiable

Performance considerations

  1. Avoid nested streams inside collectors: If your downstream collector creates a new stream, you are iterating twice.
  2. Use teeing instead of multiple terminal operations: One pass is always faster than two.
  3. Parallel streams with custom collectors: Ensure your combiner is correct. If your collector is inherently sequential, throw in the combiner and avoid parallel streams.
  4. Prefer specialized collectors: summingInt is faster than reducing with Integer::sum because it avoids boxing.

Summary

The Collectors API is a composition framework. Master groupingBy with downstream collectors, use teeing for dual aggregations, and build custom collectors when the built-in ones fall short. The key is thinking of collectors as composable building blocks rather than standalone utilities.