先别急着记API,咱们聊聊直觉
想象你在超市排队结账。收银员面前有一堆商品,他需要一个动作:每拿起一件商品,就把它的价格加到小票上。这个“拿起并累加”的过程,就是 reduce 的核心思想——把一个集合折叠成一个值。
但今天我们要聊的,不是简单的 1+2+3 这种小学生算术。我们要聊的是:如何用 reduce 一边累加求和,一边保证数据不重复?这听起来有点矛盾对吧?求和是加加减减,去重是筛选过滤,它们怎么会混在一起呢?
别急,这就带你拆解清楚。
reduce 的三个“面孔”
Java 8 的 Stream.reduce() 方法其实有三种重载形式,理解它们之间的区别,是掌握高级用法的前提。
第一种:Optional<T> reduce(T identity, BinaryOperator<T> accumulator)
这是最基础的形式。identity 是初始值(就像你空手开始数钱,初始是0),accumulator 是一个二元函数,告诉程序“当前累计值”和“下一个元素”怎么合并。
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
Integer sum = numbers.stream()
.reduce(0, (currentSum, next) -> currentSum + next);
System.out.println(sum); // 输出 15
这里 (currentSum, next) -> currentSum + next 就是典型的累加器。currentSum 代表之前所有数的总和,next 是当前正在处理的那个数。第一次执行时,currentSum 是初始值 0,next 是 1,结果变成 1;第二次 currentSum 是 1,next 是 2,结果变成 3……以此类推。
注意:这个版本的返回值是 Integer 而不是 Optional,因为你传了初始值,所以结果一定存在。
第二种:Optional<T> reduce(BinaryOperator<T> accumulator)
这种形式没有初始值。如果流是空的,结果就是空的 Optional。如果流里只有一个元素,直接返回那个元素。
List<Integer> numbers = List.of(10, 20, 30);
Optional<Integer> result = numbers.stream()
.reduce((a, b) -> a + b);
result.ifPresent(System.out::println); // 输出 60
这个方法在空流的情况下会返回 Optional.empty(),所以调用方需要处理“没有值”的情况。
第三种:<U> U reduce(U identity, Function<U, Stream<T>> mapper, BinaryOperator<U> combiner)
这是最复杂、也最强大的一种。它允许你在reduce的过程中改变值的类型,并且支持并行流的合并操作。不过在日常业务开发中,前两种已经能满足90%的需求。第三种通常用在高级场景,比如把整数流转换成字符串累积,或者在并行计算中合并多个部分结果。
我们今天的重点,是前两种在“去重求和”场景下的变体用法。
为什么“去重+求和”会让人困惑?
很多人第一次遇到这个需求时,脑子里会有两种思路在打架:
- 思路A:先
distinct()去重,再mapToInt().sum()求和。这条路简单直接,确实能完成任务。 - 思路B:用
reduce一步到位,既去重又求和,显得更“函数式”。
思路A完全正确,而且是生产环境中最推荐的做法。但如果你坚持要用 reduce 来实现,或者你想理解 reduce 在更复杂状态管理下的能力,那就需要动点脑子了。
因为 reduce 的累加器 (currentSum, next) -> currentSum + next 只能记住一个数值,它没法同时记住“哪些数已经加过了”。所以,要让它同时实现“去重”和“求和”,我们必须升级累计状态——不再只传一个 Integer,而是传一个能同时携带“当前总和”和“已出现元素集合”的对象。
实战:用 reduce 实现去重累计求和
第一步:设计一个状态持有类
既然普通 Integer 装不下“总和”和“已见元素”这两个信息,我们就自己写一个类来装。
public class SumWithDistinctState {
private int sum = 0;
private Set<Integer> seen = new HashSet<>();
public int getSum() {
return sum;
}
public boolean isSeen(int value) {
return seen.contains(value);
}
public void addIfNew(int value) {
if (seen.add(value)) { // HashSet.add() 返回 true 表示是新元素
sum += value;
}
}
// 合并两个状态,用于并行流场景
public SumWithDistinctState merge(SumWithDistinctState other) {
SumWithDistinctState merged = new SumWithDistinctState();
merged.sum = this.sum + other.sum;
merged.seen.addAll(this.seen);
merged.seen.addAll(other.seen);
return merged;
}
}
这个类很简单:sum 记录累加结果,seen 记录已经处理过的数字。addIfNew 方法是关键——只有当这个数字是第一次出现时,才加入总和。
第二步:用 reduce 串联起来
List<Integer> data = List.of(3, 1, 4, 1, 5, 9, 2, 6, 5, 3);
SumWithDistinctState result = data.stream()
.reduce(
new SumWithDistinctState(), // 初始状态
(state, element) -> { // 累加器
state.addIfNew(element);
return state;
},
(state1, state2) -> state1.merge(state2) // 合并器(并行时用到)
);
System.out.println("去重后的总和: " + result.getSum());
// 去重后的数据: [3, 1, 4, 5, 9, 2, 6]
// 总和 = 3+1+4+5+9+2+6 = 30
让我们一行行拆给你看:
- 初始状态:
new SumWithDistinctState()—— 此时总和是0,集合是空的。 - 累加器:
(state, element) -> { state.addIfNew(element); return state; }—— 每来一个新元素,先检查是不是第一次出现,如果是,就加到总和里。注意,HashSet.add()方法有个小特性:如果元素已存在,它返回false且不会覆盖;如果元素不存在,返回true并加入集合。这个返回值正好帮我们判断“是否是新元素”。 - 合并器:
(state1, state2) -> state1.merge(state2)—— 如果你开启并行流(.parallel()),Stream 会拆分成多个片段分别reduce,最后需要用这个合并器把各片段的结果合在一起。如果不写合并器,并行执行时会抛异常。
第三步:对比——用 distinct + sum 的写法
为了让你更清楚两者的差异,我们看看“常规写法”长什么样:
int regularSum = data.stream()
.distinct() // 先去重
.mapToInt(Integer::intValue)
.sum(); // 再求和
System.out.println("常规去重求和: " + regularSum); // 同样输出 30
这两种写法结果一样,但思维模型完全不同:
distinct().sum()是管道式的:数据流经过一个去重过滤器,再经过一个求和器,每一步都有明确的输入输出。reduce()是折叠式的:你把整个集合“捏”成一个状态对象,通过累加器逐步更新这个状态。
在实际开发中,我建议你优先用 distinct().sum(),因为它更直观、更容易读懂、也更容易调试。reduce 在这种场景下有点“杀鸡用牛刀”,而且可读性差不少。
进阶:reduce 在更复杂场景下的真正优势
既然 distinct + sum 这么简单,那 reduce 还有什么用武之地?答案是:当你的“状态”不仅仅是求和,而是需要保留更多信息时,reduce 才能真正发光。
场景一:去重求和的同时,记录每个值的出现次数
public class RichState {
private int sum = 0;
private Map<Integer, Integer> countMap = new HashMap<>();
private int distinctCount = 0;
public void add(int value) {
int count = countMap.getOrDefault(value, 0) + 1;
countMap.put(value, count);
if (count == 1) { // 第一次出现
sum += value;
distinctCount++;
}
}
public RichState merge(RichState other) {
RichState merged = new RichState();
merged.sum = this.sum + other.sum;
merged.distinctCount = this.distinctCount + other.distinctCount;
// 合并计数map,去重逻辑稍复杂,这里简化处理
other.countMap.forEach((k, v) -> {
int existing = merged.countMap.getOrDefault(k, 0);
if (existing == 0) {
merged.distinctCount++;
}
merged.countMap.merge(k, v, Integer::sum);
});
return merged;
}
@Override
public String toString() {
return "RichState{sum=" + sum + ", distinctCount=" + distinctCount + ", countMap=" + countMap + '}';
}
}
List<Integer> data = List.of(3, 1, 4, 1, 5, 9, 2, 6, 5, 3);
RichState result = data.stream()
.reduce(
new RichState(),
(state, elem) -> {
state.add(elem);
return state;
},
RichState::merge
);
System.out.println(result);
// 输出: RichState{sum=30, distinctCount=7, countMap={1=2, 2=1, 3=2, 4=1, 5=2, 6=1, 9=1}}
你看,如果用 distinct().map().sum() 这种管道式写法,你只能得到总和 30。但使用 reduce,你可以在一次遍历中同时得到:总和、去重数量、每个数字的出现次数。这就是 reduce 的真正威力——一个循环,多个统计。
场景二:并行流下的性能优势
当数据量特别大时(比如百万级),reduce 配合 parallel() 可以充分利用多核CPU。而 distinct().sum() 虽然也能并行,但中间多了 distinct 这个阶段的洗牌开销,在某些场景下反而不如自定义的 reduce 高效。
// 大数据量场景,用并行reduce
List<Integer> hugeData = IntStream.rangeClosed(1, 1_000_000)
.map(i -> ThreadLocalRandom.current().nextInt(1, 100))
.boxed()
.collect(Collectors.toList());
long start = System.nanoTime();
RichState parallelResult = hugeData.parallelStream()
.reduce(new RichState(), RichState::add, RichState::merge);
long elapsed = System.nanoTime() - start;
System.out.println("并行reduce耗时: " + elapsed / 1_000_000 + "ms");
System.out.println("结果: " + parallelResult);
并行流会把数据拆成多个片段,每个片段独立执行 reduce,最后用 merge 合并。自定义的 RichState.merge() 可以优化合并逻辑,避免不必要的重复计算。
常见坑点和注意事项
坑1:忘记写合并器导致并行流崩溃
// 错误写法 —— 缺了第三个参数
data.parallelStream()
.reduce(new RichState(), (s, e) -> { s.add(e); return s; });
// 运行时会抛 UnsupportedOperationException
只要用了 .parallel(),就必须提供合并器 combiner。哪怕你暂时不用并行流,也建议养成习惯:三个参数一起写。这样将来想切换并行时不会踩坑。
坑2:可变对象 vs 不可变对象的抉择
上面示例中,RichState.add() 修改了自身状态(可变对象)。这在串行流中没问题,但在并行流中,如果多个线程同时修改同一个 RichState 实例,就会出现竞态条件。
正确的做法是:在累加器中不修改原对象,而是创建新对象。
public class ImmutableRichState {
private final int sum;
private final Map<Integer, Integer> countMap;
private final int distinctCount;
public ImmutableRichState add(int value) {
Map<Integer, Integer> newMap = new HashMap<>(countMap);
int newCount = newMap.getOrDefault(value, 0) + 1;
newMap.put(value, newCount);
int newDistinct = newCount == 1 ? this.distinctCount + 1 : this.distinctCount;
int newSum = newCount == 1 ? this.sum + value : this.sum;
return new ImmutableRichState(newSum, newMap, newDistinct);
}
// 构造器、合并方法等省略...
}
这样每个线程操作的都是自己的副本,完全线程安全。代价是会产生更多临时对象,对GC有一定压力。在数据量不大的场景下,差异可以忽略;在超大数据量场景下,需要根据实测决定是否值得。
坑3:reduce 不是万能的,可读性优先
最后说一句掏心窝的话:reduce 很强大,但它的抽象程度高,团队成员如果不熟悉函数式编程,读起来会很痛苦。
如果你的需求只是“去重求和”,请用:
list.stream().distinct().mapToInt(Integer::intValue).sum();
如果你的需求是“在统计总和的同时,还要记录其他信息”,再用 reduce。
代码是写给人看的,顺便给机器执行。 不要为了炫技而炫技。
总结一张表
| 方案 | 代码复杂度 | 并行安全 | 扩展性 | 推荐场景 |
|---|---|---|---|---|
distinct().sum() |
低 | 依赖Stream并行实现 | 低,只能求和 | 单纯去重求和 |
reduce + 可变状态 |
中 | ❌ 线程不安全 | 高,可携带任意状态 | 串行流,简单统计 |
reduce + 不可变状态 |
高 | ✅ 完全安全 | 高,可携带任意状态 | 并行流,复杂统计 |
希望这篇详解能帮你把 reduce 的底层逻辑彻底理清。记住:工具没有好坏,只有适不适合。先用最简单的方式解决问题,当简单方式不够用时,再考虑 reduce 的进阶用法。