跳到主要内容
约 11 分钟3 张卡

Collector

Collector 是 Stream 的收集规则,用来定义容器怎样创建、元素怎样加入,以及最终怎样返回结果。collect 按这些规则,把流中的元素累积为列表、Map 或自定义对象。

本文先解释可变归约和 Collector 的组成,再说明列表收集、键冲突与分组统计。最后介绍单位元、结合律和并发收集,判断同一套规则在并行执行时是否仍然正确。

1. 面试核心​

先记住这几条,再读正文
  • collect 与 reduce。 collect 适合把元素累积到可变容器,reduce 适合把元素合并成一个不可变值。卡 · 可变归约
  • 并行容器隔离。 普通并行收集为各分区创建独立容器,再由 combiner 合并部分结果。
  • 键冲突。 两参数 toMap 遇到重复键会抛出 IllegalStateException,合并函数决定键冲突时保留哪个值。卡 · toMap 与键冲突
  • 下游收集器。 groupingBy 按分类键分组,下游收集器决定每组保存列表、计数还是其他统计结果。
  • 归约规则。 归约的初始值须满足单位元要求,合并规则须满足结合律,才能让不同分区得到等价结果。卡 · 归约单位元与并发收集

2. 可变归约​

可变归约指把元素逐个加入可修改的容器,最后返回累积结果。比如,要把活跃用户收集到列表,可以写成:

List<User> activeUsers = users.stream()
.filter(User::active)
.collect(Collectors.toCollection(ArrayList::new));

这段顺序流会按创建、累积和返回完成收集:

  1. 创建一个空 ArrayList。
  2. 遍历元素并调用 add。
  3. 返回累积完成的列表。

结果是一个只包含活跃用户的可修改 ArrayList。容器由收集过程创建和修改,调用方无需维护外部共享列表。并行执行时,输入会拆成多个分区,每个分区先使用自己的容器,再按 Collector 的规则合并。

2.1 collect 与 reduce​

选择 collect 还是 reduce,取决于累积时是否要修改容器。reduce 更适合把元素合成一个不可变值,比如计算订单金额总和:

int total = orders.stream()
.map(Order::amount)
.reduce(0, Integer::sum);

上面从 0 开始累加每笔金额,得到整数总和,空流返回 0。初始值 0 称为 identity,也就是参与运算后不改变结果的单位元。

若用同一套方式修改一个共享 ArrayList,并行归约就会出错:

// 错误示例:并行时多个任务可能共享并修改 identity
List<User> result = users.parallelStream().reduce(
new ArrayList<>(),
(list, user) -> {
list.add(user);
return list;
},
(left, right) -> {
left.addAll(right);
return left;
}
);

上面的初始列表只有一个,多个并行任务会拿到同一引用并修改它。列表一旦变化,就失去了空列表作为初始值的作用,累积与合并还会互相干扰。收集可变容器时,应使用 collect 的供应函数为各分区创建独立容器。

3. Collector 的组成​

理解自定义收集器,需要先区分输入元素、中间容器和最终结果。Collector<T, A, R> 用三个类型参数分别表示它们:

  • T:Stream 中的元素类型。
  • A:内部累积容器类型。
  • R:最终结果类型。

收集器再用以下五个组成部分描述操作:

组成部分作用
supplier创建新的累积容器
accumulator把一个元素加入容器
combiner合并两个分区的容器
finisher把累积容器转换成最终结果
characteristics声明并发、顺序和是否无需转换等特征

下面是一个简化的字符串拼接 Collector:

Collector<String, StringJoiner, String> commaSeparated = Collector.of(
() -> new StringJoiner(", "),
StringJoiner::add,
StringJoiner::merge,
StringJoiner::toString
);

String names = users.stream()
.map(User::displayName)
.collect(commaSeparated);

这里 T 和 R 都是 String,中间容器 A 则是 StringJoiner。收集器把显示名加入拼接器,最后返回用逗号和空格分隔的字符串。并行时,merge 先合并各分区的拼接器,再转换为字符串。

串行收集无需合并分区,但 combiner 仍要满足正确的合并规则。否则,自定义 Collector 改成并行执行后,就会丢数据或产生错误顺序。

4. List 与 Set 收集​

收集到列表或集合时,要先决定是否去重、是否保持顺序,以及结果是否允许修改。下面集中展示列表、集合和指定容器的写法:

List<String> names = users.stream()
.map(User::displayName)
.collect(Collectors.toList());

Set<String> roles = users.stream()
.flatMap(user -> user.roles().stream())
.collect(Collectors.toSet());

List<String> immutableNames = users.stream()
.map(User::displayName)
.collect(Collectors.toUnmodifiableList());

LinkedHashSet<String> orderedRoles = users.stream()
.flatMap(user -> user.roles().stream())
.collect(Collectors.toCollection(LinkedHashSet::new));

选择时,需要比较去重、顺序和可修改性:

  • names 收集所有显示名。Collectors.toList() 的契约未规定具体实现、可变性、线程安全性或可序列化性。
  • roles 用 Set 去重。flatMap 先展开每个用户的角色,再把角色收集到集合。
  • immutableNames 是不可修改列表,toUnmodifiableList() 遇到 null 元素会抛出异常。
  • orderedRoles 明确使用 LinkedHashSet,在去重的同时保留角色首次出现的顺序。

需要明确的容器能力时,应像最后一例那样指定工厂。Stream.toList() 则明确返回不可修改列表,它与 Collectors.toList() 的契约不同。

5. toMap 与键冲突​

把列表转成 Map 时,需要先决定键是什么,以及同一个键遇到多个值时怎样处理。toMap 用键函数和值函数完成转换,Function.identity() 表示直接使用输入对象作为值。

下面对比唯一键、重复键和固定顺序三种需求:

Map<Long, User> usersById = users.stream()
.collect(Collectors.toMap(User::id, Function.identity()));

Map<String, User> latestByEmail = users.stream()
.collect(Collectors.toMap(
User::email,
Function.identity(),
(left, right) -> left.updatedAt().isAfter(right.updatedAt())
? left
: right
));

Map<String, User> ordered = users.stream()
.collect(Collectors.toMap(
User::email,
Function.identity(),
(left, right) -> right,
LinkedHashMap::new
));

对应的映射结果与冲突规则如下:

  • usersById 按用户 ID 建立映射。两参数版本要求键唯一,遇到重复键就抛出 IllegalStateException。
  • latestByEmail 按邮箱建立映射,第三个参数选择更新时间较晚的用户。时间相同时,这个示例保留右值。
  • ordered 用第四个参数指定 LinkedHashMap。键按首次遇到的顺序遍历,重复邮箱的值则由后遇到的用户替换。

合并函数决定遇到数据冲突时保留什么。选择左值、右值或合成新值,都应有明确的业务依据;若一个键本来就对应多人,可以使用下一节的分组方式。

6. groupingBy 与下游 Collector​

groupingBy 先按分类函数得到的键分组,再处理各组元素。默认每组保存一个 List,传入下游 Collector 后,则由它决定每组的结果。

下面对比分组列表、组内计数和角色去重:

Map<Department, List<User>> usersByDepartment = users.stream()
.collect(Collectors.groupingBy(User::department));

Map<Department, Long> counts = users.stream()
.collect(Collectors.groupingBy(
User::department,
Collectors.counting()
));

Map<Department, Set<String>> roleNames = users.stream()
.collect(Collectors.groupingBy(
User::department,
Collectors.flatMapping(
user -> user.roles().stream(),
Collectors.toSet()
)
));

三个结果都以部门为键,每组的值类型与内容不同:

  • usersByDepartment 的每个值是该部门的用户列表,类型为 List<User>。
  • counts 用 counting() 统计组内用户数,每个值是 Long。
  • roleNames 用 flatMapping 展开组内用户的角色,再用 toSet() 去重,每个值是 Set<String>。

还可以按需要组合以下下游收集器:

  • mapping 先转换每个组内元素,再交给下游收集。
  • filtering 只收集组内满足条件的元素。已出现的分类键即使没有元素通过过滤,也会保留空组。
  • summingInt 与 averagingLong 分别计算组内数值总和与平均值。
  • maxBy 与 minBy 按比较器寻找组内最大值与最小值。
  • collectingAndThen 在下游收集完成后,再把结果转换为需要的类型。

6.1 partitioningBy​

按一个条件把元素分成两类时,可以使用 partitioningBy。比如,把用户分成活跃与非活跃两组:

Map<Boolean, List<User>> partition = users.stream()
.collect(Collectors.partitioningBy(User::active));

结果 Map 固定包含 true 和 false 两个键,对应活跃与非活跃用户列表。即使某一类没有用户,其列表也会保留为空。按部门等多个业务键分类时,则使用 groupingBy。

7. 结合律​

并行归约会把数据拆到不同分区,改变括号分组和合并次序。要让这些执行方式得到等价结果,合并操作必须满足结合律,也就是改变括号分组后结果相同:

(a op b) op c == a op (b op c)

整数加法满足这个条件,整数减法不满足:

int value = Stream.of(10, 3, 2)
.reduce(0, (left, right) -> left - right);

上面的顺序流算出 ((0-10)-3)-2=-15。但 (10-3)-2=5,而 10-(3-2)=9,说明减法会因括号分组变化而改变结果。因此,这种减法归约不适合并行执行。

浮点加法也受有限精度与舍入影响,不满足严格结合律。改变合并次序时,即使输入相同,结果仍会有数值差异。

自定义 Collector 还要满足以下归约契约:

  • 同一个输入在等价分区方式下得到等价结果。
  • combiner 能合并两个独立累积容器。
  • 声明 CONCURRENT 前,累积容器和累积操作确实支持并发。
  • 声明 IDENTITY_FINISH 时,A 能直接作为 R 返回。

8. 归约单位元与并发收集​

结合律决定能否改变分组,单位元则决定初始值是否会改变结果。reduce(identity, op) 要求 op(identity, x) 等于 x。求和的单位元是 0;若使用 1,每个并行分区都会额外累计一次初始值。

三参数 reduce 还要求累积函数与合并函数兼容。也就是说,直接把一个元素累积到当前结果,与先把该元素和单位元累积、再合并,必须得到相同结果。

普通 collect 用供应函数为各分区创建独立容器,各容器在累积时由单个线程使用。所以,非并发收集器可以使用 ArrayList,再由 combiner 合并结果,避免同时修改同一个列表。

要让多个线程共享同一容器累积,收集器需声明 CONCURRENT,并真正支持并发写入。流还须无序,或收集器同时声明 UNORDERED,表示结果无需保留遇到顺序。IDENTITY_FINISH 则表示累积容器可以直接作为最终结果,框架能够省去转换。

选择最终列表类型时,还要区分可修改性与 null 支持:

列表结果可修改性null 与版本
Stream.toList()不可修改支持 null,Java 16 起
Collectors.toList()无可修改性承诺不宜依赖具体实现
Collectors.toUnmodifiableList()不可修改拒绝 null,Java 10 起
toCollection(ArrayList::new)可修改支持 null

不可修改限制的是列表的添加、删除和替换操作。若列表中存放可变对象,仍可以修改对象自己的字段,所以列表只读与元素不可变要分别判断。

9. 常见问题​

选好收集器后,还需要确认顺序、并发收益和空值约束是否符合调用方要求。下面分别说明这些边界。

9.1 groupingBy 会保留输入顺序吗?​

有序流配合 toList 下游时,组内列表保留遇到顺序,默认外层 Map 则没有键顺序承诺。若外层键需要按首次出现的顺序遍历,应指定 LinkedHashMap;需要按键排序时,则使用 TreeMap。

Map<Department, List<User>> ordered = users.stream()
.collect(Collectors.groupingBy(
User::department,
LinkedHashMap::new,
Collectors.toList()
));

这个结果中,部门按首次出现的顺序遍历,每个部门内的用户也保留原有遇到顺序。

9.2 groupingByConcurrent 一定更快吗?​

9.3 怎样同时计算多个统计值?​

9.4 收集结果可以包含 null 吗?​

10 · 面试题卡片​

10.1 使用 Stream 把人员列表转换成 Map,重复姓名怎样处理​

出现公司:飞猪

考察重点:解释键函数、值函数与合并函数各自做什么。能按业务决定重复姓名保留一人还是分组保存,并明确结果 Map 的遍历顺序。

相关内容:toMap 与键冲突。

参考回答

重复姓名可以用 toMap 的合并函数处理;若需要保留同名的所有人,则应按姓名分组。

具体可写成 Collectors.toMap(Person::name, Function.identity(), mergeFunction)。前两个参数分别选择姓名和人员对象,第三个参数决定同名时保留什么。两参数版本遇到重复键会抛出 IllegalStateException。

若姓名本来就对应多人,groupingBy(Person::name) 能保留完整列表。业务只需要一条时,再按版本、更新时间或明确优先级选择。若还需要按键首次出现的顺序遍历,可通过第四个参数指定 LinkedHashMap::new。

10.2 collect 和 reduce 应该怎样选择​

出现公司:经纬恒润

考察重点:区分不可变值归约与可变容器累积,说明单位元、累积函数和合并函数的契约。能解释共享容器为何在并行时出错。

相关内容:可变归约、结合律。

参考回答

累积列表、Map 等可变容器时使用 collect,合成求和、最值等不可变结果时使用 reduce。

普通 collect 为各分区创建独立容器,再通过 combiner 合并。reduce 则用初始值与归约函数不断产生新结果,初始值必须满足单位元要求,累积与合并也要兼容。

两者在并行时都要求组合规则满足结合律。若 reduce 的初始值是同一个可变列表,各分区会修改同一引用,破坏单位元和独立累积的要求。因此,串行测试通过后,还要检查并行归约契约。

10.3 并行 collect 为什么可以使用 ArrayList,而 reduce 的初始值有额外约束?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释分区容器隔离怎样支持普通并行收集,以及归约单位元和并发特性分别约束什么。

相关内容:归约单位元与并发收集。

参考回答

普通并行 collect 让各分区独立使用 ArrayList,再合并结果;reduce 的初始值则必须满足单位元要求。

分区容器在累积时由单个线程使用,所以 ArrayList 无需承受并发写入。reduce 若用 1 作为求和初值,每个分区都会额外加一次 1;若共享并修改同一个列表,初始值本身也会变化。

真正共享容器的并发收集,需要容器与累积操作支持并发,并声明 CONCURRENT。流还要无序,或收集器同时声明 UNORDERED。这与普通并行 collect 的容器隔离方式不同。

11. 参考资料​