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 代码。

javastreamcollectorsgroupingByjava 21