Advanced Stream Collectors and Custom Collectors in Java
Go beyond basic Java Stream collectors. Learn groupingBy compositions, teeing, custom Collector implementations, and performance considerations.
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:
- supplier: creates the mutable accumulation container
- accumulator: adds an element to the container
- combiner: merges two containers (for parallel streams)
- 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
- Avoid nested streams inside collectors: If your downstream collector creates a new stream, you are iterating twice.
- Use teeing instead of multiple terminal operations: One pass is always faster than two.
- Parallel streams with custom collectors: Ensure your combiner is correct. If your collector is inherently sequential, throw in the combiner and avoid parallel streams.
- Prefer specialized collectors:
summingIntis faster thanreducingwithInteger::sumbecause 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.
Related articles
- Java Advanced Java Stream API Patterns
Go beyond basic filtering and mapping with advanced Stream API patterns including custom collectors, parallel streams, and stateful operations.
- Java Java Stream Collectors Deep Dive
Master java.util.stream.Collectors with practical examples covering grouping, partitioning, downstream collectors, and building your own custom collector.
- Java Java Streams API: A Complete Guide
Master Java Streams for functional-style data processing. Covers stream creation, map, filter, reduce, collectors, parallel streams, and performance considerations.
- Java Java Lambda Expressions Tutorial
Learn how Java lambda expressions work, when to use them, and how they interact with functional interfaces and the Streams API.