Collectors.groupingBy 和 partitioningBy 例解
1. 概述
使用 Stream 时,你通常需要按照某个条件对元素进行分组。例如:按城市分组订单、按客户统计总金额,或者区分大订单和小订单。Collectors.groupingBy 和 Collectors.partitioningBy 就是为了解决这类问题而生的。
本文逐个介绍常见用法,并附有具体输出。你将学会如何组合下游 collector、选择合适的 Map 返回类型,以及避免一些常见错误。
2. 样本数据
本文所有示例共用一套订单数据:
import java.util.List;
enum OrderStatus { PENDING, PAID, CANCELLED }
record Order(String id, String customer, String city, OrderStatus status, long amount) {}
class OrderFixtures {
static List<Order> sample() {
return List.of(
new Order("O1", "An", "河内", OrderStatus.PAID, 500_000),
new Order("O2", "Binh", "岘港", OrderStatus.PAID, 1_200_000),
new Order("O3", "An", "河内", OrderStatus.CANCELLED, 300_000),
new Order("O4", "Chi", "胡志明市", OrderStatus.PAID, 2_000_000),
new Order("O5", "Binh", "岘港", OrderStatus.PENDING, 150_000),
new Order("O6", "Chi", "胡志明市", OrderStatus.PAID, 800_000)
);
}
}
amount 单位为越盾(VND),使用 long 类型以便简洁。在真实系统中,你应该使用 BigDecimal 来处理货币。
下面的示例假设已有 import static java.util.stream.Collectors.*; 和 List<Order> orders = OrderFixtures.sample();。
3. groupingBy 基础
最简单的形式只接收一个分类函数(classifier):
Map<String, List<Order>> byCity = orders.stream()
.collect(groupingBy(Order::city));
byCity.forEach((city, list) ->
System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
河内 -> [O1, O3]
岘港 -> [O2, O5]
胡志明市 -> [O4, O6]
Order::city 决定了分组键。每个键映射到一个 List,包含所有具有相同城市值的订单。内部实现上,这个版本等价于 groupingBy(Order::city, HashMap::new, toList())。
注意: 返回的 Map 是
HashMap,因此键的顺序不保证。你的机器上输出可能不同。如果需要固定顺序,请参见第 5 节。
4. 下游 Collector
groupingBy 的第二个参数是另一个 Collector,称为下游 collector。它决定了如何组合同一组内的元素。这正是使 groupingBy 真正强大的地方。
4.1. 计数和求和
Map<OrderStatus, Long> countByStatus = orders.stream()
.collect(groupingBy(Order::status, counting()));
Map<String, Long> totalByCustomer = orders.stream()
.collect(groupingBy(Order::customer, summingLong(Order::amount)));
System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Binh=1350000, Chi=2800000}
counting() 总是返回 Long,而不是 Integer。声明变量类型时要注意这一点。除了 summingLong,还有 summingInt、summingDouble、averagingLong 和 summarizingLong。最后一个返回 LongSummaryStatistics,同时包含 count、sum、min、max、average。
4.2. 使用 mapping 转换元素
如果只需要某个属性而不是整个对象,使用 mapping:
Map<String, Set<String>> customersByCity = orders.stream()
.collect(groupingBy(Order::city, mapping(Order::customer, toSet())));
System.out.println(customersByCity);
{河内=[An], 岘港=[Binh], 胡志明市=[Chi]}
mapping(Order::customer, toSet()) 提取每个订单的客户名,然后放入 Set 去重。An 在河内有两个订单,但只出现一次。
4.3. 获取每组中最大的元素
maxBy 返回 Optional<Order>,因为理论上一个组可能为空。在 groupingBy 中,只有存在至少一个元素时组才会存在。因此你可以用 collectingAndThen 来“打开” Optional:
Map<String, Order> biggestByCustomer = orders.stream()
.collect(groupingBy(
Order::customer,
collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
));
biggestByCustomer.forEach((customer, order) ->
System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Binh -> O2 (1200000)
Chi -> O4 (2000000)
collectingAndThen 先运行内部 collector,然后将完成函数(Optional::get)应用于结果。
另一种常用的方法是 toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...))。这个方法产生相同结果且不需要经过 Optional。
4.4. 使用 filtering 在组内过滤
Collectors.filtering 从 Java 9 开始存在。它不同于在分组前调用 .filter():
Map<String, Long> cancelledPerCustomer = orders.stream()
.collect(groupingBy(Order::customer,
filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));
Map<String, Long> filteredFirst = orders.stream()
.filter(o -> o.status() == OrderStatus.CANCELLED)
.collect(groupingBy(Order::customer, counting()));
System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Binh=0, Chi=0}
{An=1}
使用 filtering 时,所有客户都会出现在结果中,即使计数为 0。而在分组前调用 .filter() 时,Binh 和 Chi 会完全消失。根据需要选择:如果报告需要完整的客户列表,就使用 filtering。
类似地,flatMapping(Java 9)可以在每个组内展平一个集合。
5. 选择返回的 Map 类型
三参数版本允许传入一个 Supplier 来创建 Map:
Map<String, Long> sortedTotals = orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));
Map<OrderStatus, Long> statusCounts = orders.stream()
.collect(groupingBy(Order::status,
() -> new EnumMap<>(OrderStatus.class), counting()));
System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Binh=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}
TreeMap 按自然顺序排序键。EnumMap 保持键按枚举声明的顺序,且对枚举键的性能优于 HashMap。若要保持流中首次出现的顺序,使用 LinkedHashMap::new。
注意:
TreeMap对String按 Unicode 码进行排序,不遵循越南语排序规则。例如“岘港”会排在“胡志明市”之后。需要正确的越南语排序时,传入() -> new TreeMap<>(Collator.getInstance(Locale.of("vi")))。
6. 嵌套分组
由于下游是任意 collector,你可以在其中放置另一个 groupingBy:
Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
.collect(groupingBy(Order::city, TreeMap::new,
groupingBy(Order::status, counting())));
System.out.println(cityThenStatus);
{河内={PAID=1, CANCELLED=1}, 胡志明市={PAID=2}, 岘港={PAID=1, PENDING=1}}
结果是一个两层的 Map。这对透视表报告非常方便。但是超过两层的话,数据类型会变得难以阅读。此时应该考虑使用复合键,例如 record CityStatus(String city, OrderStatus status),然后 groupingBy(o -> new CityStatus(o.city(), o.status()))。
7. partitioningBy
partitioningBy 是分组的特殊情况。分类函数是一个 Predicate,键始终是 Boolean:
Map<Boolean, List<String>> bigOrders = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000,
mapping(Order::id, toList())));
Map<Boolean, Long> bigOrderCount = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));
System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("大订单: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
大订单: [O2, O4]
partitioningBy 也接收下游 collector,与 groupingBy 完全相同。但它没有接受 Supplier 的版本,因为返回的 Map 是内部实现,只有恰好两个键。
7.1. 重要区别:键总是存在
这是让很多人在两个 collector 间选错的关键点:
List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();
Map<Boolean, List<Order>> partitioned = smallOnly.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000));
Map<Boolean, List<Order>> grouped = smallOnly.stream()
.collect(groupingBy(o -> o.amount() >= 1_000_000));
System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null
partitioningBy 总是 同时包含 true 和 false,即使其中一方为空。groupingBy 只在至少有一个元素时才创建键。在这里调用 grouped.get(true).size() 会抛出 NullPointerException。
因此,当条件是真/假时,使用 partitioningBy。代码意图更清晰,且不用担心键缺失。
8. 为分组逻辑编写测试
在 pom.xml 中添加 JUnit 5 和 AssertJ。如果项目使用 Spring Boot 3.x,spring-boot-starter-test 已经包含两者:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
将报告逻辑放入单独的 service 以便于测试:
class OrderReportService {
Map<String, Long> paidTotalByCustomer(List<Order> orders) {
return orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new,
filtering(o -> o.status() == OrderStatus.PAID,
summingLong(Order::amount))));
}
Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
return orders.stream()
.collect(partitioningBy(o -> o.amount() >= threshold, counting()));
}
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class OrderReportServiceTest {
private final OrderReportService service = new OrderReportService();
@Test
void shouldSumOnlyPaidOrdersPerCustomer() {
var result = service.paidTotalByCustomer(OrderFixtures.sample());
assertThat(result)
.containsExactly(
entry("An", 500_000L),
entry("Binh", 1_200_000L),
entry("Chi", 2_800_000L));
}
@Test
void shouldAlwaysContainBothPartitions() {
var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);
assertThat(result)
.containsEntry(true, 0L)
.containsEntry(false, 6L);
}
}
第一个测试使用 containsExactly 是因为结果是 TreeMap,顺序是固定的。An 只被计算 500,000,因为订单 O3 已被取消。Binh 只被计算 1,200,000,因为 O5 仍未支付。
第二个测试确认即使没有订单超过阈值,键 true 仍存在且值为 0。
9. 常见错误
键为 null。 groupingBy 不接受分类函数返回 null:
var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key
有两种方法处理。第一是用默认值替换 null:groupingBy(o -> Objects.requireNonNullElse(o.city(), "未知"))。第二是在分组前过滤掉缺失城市的订单。
返回类型混淆。 counting() 返回 Long。averagingLong 返回 Double。maxBy 返回 Optional。声明错类型会得到难读的编译错误。有疑问时,使用 var 或让 IDE 推断类型。
键唯一时使用 groupingBy。 如果 id 是唯一的,groupingBy(Order::id) 会产生 Map<String, List<Order>>,其中每个列表只有一个元素。这种情况应该用 toMap(Order::id, Function.identity())。
修改结果 Map。 默认版本的 Javadoc 不保证返回 Map 的类型、可变性或线程安全性。如果你打算之后修改 Map,显式传入 Supplier,例如 HashMap::new。
注意: 对于并行流,
groupingBy必须合并多个 Map 子结果,可能较慢。groupingByConcurrent直接写入ConcurrentHashMap更快。代价是不保持各组内元素的顺序。对于顺序流,就用groupingBy。
10. 结论
groupingBy 按某个键对元素进行分组。partitioningBy 按 Predicate 分成两个分区,且总是返回 true 和 false 两个键。真正的力量在于下游 collector:counting、summingLong、mapping、filtering、collectingAndThen 或嵌套的 groupingBy。
使用这两个 collector 时记住三点:
- 选择合适的 Map 类型(
TreeMap、EnumMap、LinkedHashMap)以获得需要的顺序。 - 在分组前处理 null 键。
- 对真/假条件优先使用
partitioningBy。
掌握这些要点后,你的大多数报告逻辑只需少数几行易读易测试的 Stream 代码。