并行流中reduce的第三个参数combiner不可省略,因其专用于合并各线程产生的中间结果,确保数据不丢失且线程安全;省略会导致串行退化或异常。

为什么并行流里 reduce 的第三个参数不能省略
并行流调用 reduce(identity, accumulator, combiner) 时,如果只传两个参数(即用 reduce(identity, accumulator)),底层会尝试用 identity 作为“既是初始值又是合并器”的占位符——但这仅在操作满足结合律且 identity 真正是单位元时才安全。实际中,比如累加 ArrayList 或拼接 StringBuilder,new ArrayList() 是单位元,但 list1.addAll(list2) 和 list2.addAll(list1) 虽然结果等价,却不是同一对象;更关键的是,并行分段后各线程产生的中间容器必须被显式合并,否则会丢数据或抛 ConcurrentModificationException。
第三个参数 combiner 就是干这个的:它不参与单个元素的累积,只负责把两个中间结果“合二为一”。漏掉它,JDK 只能退回到串行逻辑,或者直接报错(如对不可变集合)。
combiner 必须满足的三个条件
它不是随便写个 lambda 就能用的。常见翻车点包括:
- 副作用:比如在
combiner里修改入参对象本身(如(a, b) -> { a.addAll(b); return a; }),会导致多线程竞争写同一对象 - 非恒等性:
combiner.apply(a, b)和combiner.apply(b, a)结果不等价(比如字符串拼接顺序敏感,但若业务要求顺序无关,则必须确保逻辑可交换) - 不满足结合律:例如
(a, b) -> a - b,因为(a - b) - c ≠ a - (b - c),并行分组后合并顺序不确定,结果就不可预测
正确做法是让 combiner 返回一个新对象,或明确使用线程安全的构造方式。例如合并两个 HashSet:(a, b) -> { Set。
典型场景:累加自定义对象或构建复杂结构
假设你有一组订单 Order,想并行统计总金额 + 去重用户数 + 最大单笔金额:
record Summary(BigDecimal total, long userCount, BigDecimal maxAmount) {}
<p>Summary result = orders.parallelStream()
.reduce(
new Summary(BigDecimal.ZERO, 0L, BigDecimal.ZERO),
(sum, order) -> new Summary(
sum.total.add(order.amount()),
sum.userCount + (order.userId() != null ? 1 : 0),
sum.maxAmount.max(order.amount())
),
(s1, s2) -> new Summary(
s1.total.add(s2.total),
s1.userCount + s2.userCount,
s1.maxAmount.max(s2.maxAmount)
)
);
</p>
注意三点:
- identity 必须是“零值”:总金额为
BigDecimal.ZERO、计数为0L、最大值为BigDecimal.ZERO(不能用null) - accumulator 每次返回新实例,不复用
sum - combiner 逻辑必须和 accumulator 中对应字段的计算方式一致,且彼此独立
性能陷阱:别在 combiner 里做重活
看似无害的操作,在高并发下可能成为瓶颈。比如:
- 在
combiner里反复创建大对象(如每次 new HashMap)、序列化、IO 或锁等待 - 用
String::concat做大量字符串合并(产生大量中间对象),应改用StringBuilder配合toString() - 误把本该在 accumulator 里做的过滤/转换挪到 combiner,导致重复计算
一个简单原则:combiner 应尽量轻量,只做“加法类”合并。如果合并逻辑复杂,考虑先用 collect() + ConcurrentMap 或 parallelStream().map().collect() 替代。
最常被忽略的一点是:combiner 的执行次数 ≈ 并行分段数 − 1,而 accumulator 执行次数 = 元素总数。哪怕只有 10 万条数据,分 8 段,combiner 也只跑 7 次;但如果你在 combiner 里做了 O(n) 的遍历,代价就远超预期。











