Java Collectors.groupingBy 进阶:多级分组、下游收集器与统计聚合一次搞定
「把订单按城市分组」------Collectors.groupingBy 一行就能做。但真实需求往往是:「按城市分组,每组再按月份分组,统计每个月的总金额」。这时候如果你还在 groupingBy 之后套一堆 forEach 手动累加,代码就会迅速失控。
groupingBy 的威力全在它的第二个、第三个参数------下游收集器(downstream collector)。搞懂它,一次流式操作就能做完多级分组 + 聚合统计,不用中间变量,也不用手写循环。这篇把最常用也最容易踩坑的几种组合讲透。
准备数据
java
record Order(String city, String month, String product, long amount) {}
List<Order> orders = List.of(
new Order("北京", "01", "手机", 3000),
new Order("北京", "01", "耳机", 500),
new Order("北京", "02", "手机", 4000),
new Order("上海", "01", "手机", 3500),
new Order("上海", "02", "耳机", 800)
);
最朴素的分组:值是 List
只传一个参数时,groupingBy 把每组的元素收进 List:
java
Map<String, List<Order>> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city));
// {北京=[3个订单], 上海=[2个订单]}
这只是起点。大多数时候我们不想要「一堆原始对象的 List」,而是想要每组的某个统计值。这就要用到下游收集器。
关键一步:第二个参数控制「每组收成什么」
groupingBy(分类函数, 下游收集器)------第二个参数决定每个分组的 value 长什么样。
每组求和 ,用 Collectors.summingLong:
java
Map<String, Long> amountByCity = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
Collectors.summingLong(Order::amount)));
// {北京=7500, 上海=4300}
每组计数 ,用 Collectors.counting:
java
Map<String, Long> countByCity = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
Collectors.counting()));
// {北京=3, 上海=2}
每组一次性拿全套统计 (总数、和、最大、最小、平均),用 summarizingLong:
java
Map<String, LongSummaryStatistics> stats = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
Collectors.summarizingLong(Order::amount)));
System.out.println(stats.get("北京").getMax()); // 4000
System.out.println(stats.get("北京").getAverage()); // 2500.0
一个 summarizingLong 就省掉了你分别写 sum、count、max、average 四段代码。
多级分组:下游收集器再套一个 groupingBy
下游收集器本身可以又是一个 groupingBy------这就是多级分组的实现方式。按城市分组,每组内部再按月份分组求和:
java
Map<String, Map<String, Long>> byCityThenMonth = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
Collectors.groupingBy(
Order::month,
Collectors.summingLong(Order::amount))));
// {北京={01=3500, 02=4000}, 上海={01=3500, 02=800}}
嵌套几层就是几级分组。这段代码如果用传统循环写,至少要两层嵌套 Map 的 computeIfAbsent 加手动累加,可读性和这个没法比。
mapping:分组后先转换再收集
有时你不想收集整个对象,只想收集它的某个字段。Collectors.mapping 在收进容器之前先做一次映射:
java
// 每个城市卖过哪些产品(去重)
Map<String, Set<String>> productsByCity = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
Collectors.mapping(Order::product, Collectors.toSet())));
// {北京=[手机, 耳机], 上海=[手机, 耳机]}
mapping 的第一个参数是转换函数,第二个参数才是真正的收集器。它常和 toList/toSet/joining 搭配。
一个高频坑:groupingBy 的 value 默认是可变的,但别依赖它
groupingBy 默认用 HashMap 装结果,value 的 List 默认是 ArrayList。如果你需要有序的分组结果(比如按城市名排序输出),要用三参重载显式指定 Map 类型:
java
Map<String, Long> sortedByCity = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
TreeMap::new, // 第二个参数指定 Map 实现,结果按 key 排序
Collectors.summingLong(Order::amount)));
// TreeMap: {上海=4300, 北京=7500}
注意参数顺序:三参版是 (分类函数, Map 工厂, 下游收集器),别把 Map 工厂和下游收集器写反了------写反会直接编译不过,但新手常在这卡壳。
filtering:Java 9+ 先过滤再分组,空组也保留
一个容易踩的坑:如果你在 stream() 上先 filter 再 groupingBy,被过滤掉的组会整个消失 。而 Collectors.filtering(Java 9 引入)是在组内过滤,能保留空组:
java
// 只统计金额 >= 1000 的订单,但所有城市都要出现在结果里
Map<String, List<Order>> big = orders.stream()
.collect(Collectors.groupingBy(
Order::city,
Collectors.filtering(o -> o.amount() >= 1000, Collectors.toList())));
// 若某城市全是小额订单,它对应的 value 会是空 List,而不是从 Map 里消失
到底要不要保留空组,取决于业务。要做报表「每个城市都要有一行」,就用 filtering;只关心有数据的组,stream().filter() 更省。
小结
groupingBy的精髓在下游收集器 :第二个参数决定「每组收成什么」,别只会返回List然后手动循环。- 求和
summingLong、计数counting、全套统计summarizingLong------分组即聚合,一步到位。 - 多级分组 就是把下游收集器再写成一个
groupingBy,嵌套几层就是几级。 mapping在收集前转换字段;filtering(Java 9+)在组内过滤且保留空组,和stream().filter()语义不同。- 需要结果有序用三参重载传
TreeMap::new,注意参数顺序是(分类, Map 工厂, 下游)。
一句话记忆:groupingBy 的第二个参数才是主角,把「怎么分组」和「每组算什么」拆成两件事,组合起来就无所不能。