Advanced Java Stream Collectors: groupingBy, teeing, and Custom Collectors
Most Java developers know toList() and joining(), but the Collectors class has over 40 factory methods covering grouping, partitioning, summarizing, mapping, and more. Mastering downstream collectors, teeing, and custom Collector.of() unlocks powerful single-pass data transformations that would otherwise require multiple iterations or complex loops.
Contents
groupingBy and partitioningBy
groupingBy()MappartitioningBy()truefalse
import java.util.stream.*;
import java.util.*;
record Order(String customer, String category, double amount) {}
List<Order> orders = List.of(
new Order("Alice", "Books", 29.99),
new Order("Bob", "Books", 14.99),
new Order("Alice", "Electronics", 299.99),
new Order("Bob", "Electronics", 499.99),
new Order("Alice", "Books", 9.99)
);
// groupingBy — group by a classifier function
Map<String, List<Order>> byCustomer =
orders.stream().collect(Collectors.groupingBy(Order::customer));
// {Alice=[...], Bob=[...]}
// groupingBy — group by multiple fields (cascade)
Map<String, Map<String, List<Order>>> byCustomerAndCategory =
orders.stream().collect(
Collectors.groupingBy(Order::customer,
Collectors.groupingBy(Order::category)));
// partitioningBy — splits into exactly two groups: true and false
Map<Boolean, List<Order>> highLow = orders.stream().collect(
Collectors.partitioningBy(o -> o.amount() > 100));
List<Order> highValue = highLow.get(true); // amount > 100
List<Order> lowValue = highLow.get(false);
// Counting in groups
Map<String, Long> countByCategory = orders.stream().collect(
Collectors.groupingBy(Order::category, Collectors.counting()));
// {Books=3, Electronics=2}
Downstream Collectors
Downstream collectors transform the grouped collections. They are the second argument to groupingBypartitioningBy
// Sum amounts per customer
Map<String, Double> totalByCustomer = orders.stream().collect(
Collectors.groupingBy(Order::customer,
Collectors.summingDouble(Order::amount)));
// {Alice=339.97, Bob=514.98}
// Average per category
Map<String, Double> avgByCategory = orders.stream().collect(
Collectors.groupingBy(Order::category,
Collectors.averagingDouble(Order::amount)));
// Collect to a specific collection type
Map<String, TreeSet<Double>> sortedAmounts = orders.stream().collect(
Collectors.groupingBy(Order::category,
Collectors.mapping(Order::amount,
Collectors.toCollection(TreeSet::new))));
// summarizingDouble — count, sum, min, max, avg in one pass
Map<String, DoubleSummaryStatistics> stats = orders.stream().collect(
Collectors.groupingBy(Order::category,
Collectors.summarizingDouble(Order::amount)));
stats.forEach((cat, s) ->
System.out.printf("%s: count=%d sum=%.2f avg=%.2f%n",
cat, s.getCount(), s.getSum(), s.getAverage()));
// joining — concatenate strings
Map<String, String> customerOrders = orders.stream().collect(
Collectors.groupingBy(Order::customer,
Collectors.mapping(Order::category,
Collectors.joining(", ", "[", "]"))));
// {Alice=[Books, Electronics, Books], Bob=[Books, Electronics]}
// maxBy / minBy
Map<String, Optional<Order>> biggestByCustomer = orders.stream().collect(
Collectors.groupingBy(Order::customer,
Collectors.maxBy(Comparator.comparingDouble(Order::amount))));
teeing: Two Collectors in One Pass
Collectors.teeing()BiFunction
import java.util.stream.Collectors;
// Find min and max in a single pass
record MinMax(int min, int max) {}
MinMax minMax = IntStream.rangeClosed(1, 10).boxed().collect(
Collectors.teeing(
Collectors.minBy(Comparator.naturalOrder()),
Collectors.maxBy(Comparator.naturalOrder()),
(min, max) -> new MinMax(min.orElseThrow(), max.orElseThrow())
));
System.out.println(minMax); // MinMax[min=1, max=10]
// Count and sum simultaneously
record CountSum(long count, double sum) {}
CountSum cs = orders.stream().collect(
Collectors.teeing(
Collectors.counting(),
Collectors.summingDouble(Order::amount),
CountSum::new
));
System.out.printf("count=%d, sum=%.2f%n", cs.count(), cs.sum());
// Partition and count each partition
record Split(List<Order> high, List<Order> low) {}
Split split = orders.stream().collect(
Collectors.teeing(
Collectors.filtering(o -> o.amount() > 100, Collectors.toList()),
Collectors.filtering(o -> o.amount() <= 100, Collectors.toList()),
Split::new
));
// Average and standard deviation in one pass
double[] avgAndStdDev = orders.stream()
.mapToDouble(Order::amount)
.collect(
() -> new double[3], // [sum, sumSq, count]
(arr, v) -> { arr[0]+=v; arr[1]+=v*v; arr[2]++; },
(a, b) -> { a[0]+=b[0]; a[1]+=b[1]; a[2]+=b[2]; }
);
// Manual aggregation alternative to teeing for primitive streams
flatMapping and filtering Collectors
Collectors.flatMapping()Collectors.filtering()
record BlogPost(String author, List<String> tags) {}
List<BlogPost> posts = List.of(
new BlogPost("Alice", List.of("java", "streams", "functional")),
new BlogPost("Bob", List.of("java", "concurrency")),
new BlogPost("Alice", List.of("kotlin", "java"))
);
// flatMapping — flatten lists and collect per group
Map<String, List<String>> tagsByAuthor = posts.stream().collect(
Collectors.groupingBy(BlogPost::author,
Collectors.flatMapping(p -> p.tags().stream(),
Collectors.toList())));
// {Alice=[java, streams, functional, kotlin, java], Bob=[java, concurrency]}
// flatMapping with distinct
Map<String, Set<String>> uniqueTagsByAuthor = posts.stream().collect(
Collectors.groupingBy(BlogPost::author,
Collectors.flatMapping(p -> p.tags().stream(),
Collectors.toUnmodifiableSet())));
// filtering — filter elements within each group
Map<String, List<Order>> largeOrdersByCustomer = orders.stream().collect(
Collectors.groupingBy(Order::customer,
Collectors.filtering(o -> o.amount() > 100,
Collectors.toList())));
// Includes keys even when no elements pass the filter (unlike stream().filter())
// {Alice=[Order(Alice, Electronics, 299.99)], Bob=[Order(Bob, Electronics, 499.99)]}
// Compare: filtering collector keeps empty groups, stream filter does not
Map<String, Long> countHighOrders = orders.stream().collect(
Collectors.groupingBy(Order::customer,
Collectors.filtering(o -> o.amount() > 1000, Collectors.counting())));
// {Alice=0, Bob=0} — both customers have zero high-value orders, but both keys present
Custom Collectors with Collector.of()
Build fully custom collectors using Collector.of()
// Custom collector: collect to an immutable LinkedList (in order)
Collector<String, ArrayDeque<String>, List<String>> toImmutableList =
Collector.of(
ArrayDeque::new, // supplier: create mutable container
ArrayDeque::addLast, // accumulator: add element
(a, b) -> { a.addAll(b); return a; }, // combiner: merge two containers (parallel)
List::copyOf // finisher: convert to immutable list
);
List<String> result = Stream.of("a", "b", "c").collect(toImmutableList);
System.out.println(result); // [a, b, c]
// Custom collector: build a frequency map
Collector<String, Map<String, Integer>, Map<String, Integer>> freqMap =
Collector.of(
HashMap::new,
(map, s) -> map.merge(s, 1, Integer::sum),
(a, b) -> { b.forEach((k, v) -> a.merge(k, v, Integer::sum)); return a; },
Collections::unmodifiableMap
);
Map<String, Integer> freq = Stream.of("a","b","a","c","b","a").collect(freqMap);
System.out.println(freq); // {a=3, b=2, c=1}
// Custom collector: running statistics (mean, variance online)
Collector<Double, double[], double[]> stats = Collector.of(
() -> new double[3], // [count, mean, M2] — Welford's online algorithm
(arr, x) -> {
arr[0]++;
double delta = x - arr[1];
arr[1] += delta / arr[0];
arr[2] += delta * (x - arr[1]);
},
(a, b) -> { /* combine — complex for Welford's, omitted */ return a; },
arr -> arr // finisher — caller reads arr[0]=n, arr[1]=mean, arr[2]=M2
);
Collector.Characteristics.UNORDEREDIDENTITY_FINISHCollector.of()