【收藏级】Java Stream.reduce 全面解析:从零到通透(原理图 + 实战 + 最佳实践)

163 阅读7分钟

很多人学 Stream,只会:

list.stream().map(...).filter(...).collect(...)

一到 reduce 就开始犯怵:

“感觉很高级,但就是想不起来该怎么用。”

更糟的是,大部分教程只会告诉你:

“reduce 可以用来求和、求最大最小。”

其实,reduce 才是 Stream 里真正有味道的那个 API

  • 它能把一堆元素,折叠成任意你想要的“一个结果”;
  • 它是各种统计对象、报表聚合逻辑的核心;
  • 它直接体现了 Java 中的函数式编程思想。

这篇文章会从 简单 → 本质 → 图解 → 实战 → 适用场景判断 → 踩坑总结,一步一步带你真正吃透 reduce,看完就能在项目里自信地用起来。


一、reduce 是什么?先用一句人话

先别看函数签名,记住一句话就行:

reduce = 把 Stream 里的很多元素,通过一条规则,折叠成一个结果。

这个“结果”可以是:

  • 一个数字(sum、max、min、总长度…)
  • 一个字符串(不推荐,用 joining 更好)
  • 一个对象(统计对象 Stat、Summary、Dto…)
  • BigDecimal、Map、甚至你自定义的任何类型

当你脑子里出现:

“我现在手里有一堆数据,想变成一个『总结结果』”

这个时候,十有八九就是 reduce 的舞台


二、reduce 有哪几种?用最直白的方式讲清楚

Java 中一共有 3 个重载版本:

1)带初始值的 reduce(最常用)

T reduce(T identity, BinaryOperator<T> accumulator)

典型例子:求和。

int sum = nums.stream().reduce(0, (a, b) -> a + b);
// 等价:nums.stream().reduce(0, Integer::sum);
  • identity:初始值(种子),比如求和时是 0,求积时是 1
  • accumulator:折叠规则,将“之前的结果 + 当前元素”合成新结果

数学上可以写成:

(((identity ⊕ e1) ⊕ e2) ⊕ e3 ...) ⊕ en

2)不带初始值的 reduce

Optional<T> reduce(BinaryOperator<T> accumulator)

由于没有初始值,如果流是空的,就算不出结果,所以返回 Optional<T>

第一次折叠会使用前两个元素:

Optional<Integer> max = nums.stream().reduce(Integer::max);

3)并行流用的完整版本

<U> U reduce(U identity,
             BiFunction<U, T, U> accumulator,
             BinaryOperator<U> combiner)

适用于 parallelStream()

  • identity:每个分片的初始值
  • accumulator:分片内部怎么折叠
  • combiner:多个分片结果怎么合并

例子:统计字符串总长度:

int totalLen = list.parallelStream().reduce(
    0,
    (len, s) -> len + s.length(),
    Integer::sum
);

这个重载是理解并行 reduce 的关键,我们下面会用图解讲清楚。


三、reduce 在干什么?用图先搞懂本质

先看最常见的串行情况。

假设有:

nums = [3, 5, 2]
identity = 0
accumulator = (a, b) -> a + b

串行 reduce 执行过程:

identity = 0
0 + 3 → 3
3 + 5 → 8
8 + 2 → 10

画成一张图更直观:

   identity
       │
       ▼
   acc(0, 3) = 3
       │
       ▼
   acc(3, 5) = 8
       │
       ▼
   acc(8, 2) = 10
       │
       ▼
     最终结果:10

本质:从 identity 开始,每读到一个元素,就用 accumulator 折叠一次。

这就是 reduce 名字的含义:归约 / 折叠


四、并行流里的 reduce:图解你一定要看一眼

并行流(parallelStream())是很多人用 reduce 踩坑的地方。

假设:

nums = [1, 2, 3, 4, 5, 6]

并行流会把它拆成多个分片:

分片1:1, 2
分片2:3, 4
分片3:5, 6

每个分片单独执行 accumulator:

分片1acc(acc(identity,1),2) → r1
分片2acc(acc(identity,3),4) → r2
分片3acc(acc(identity,5),6) → r3

然后用 combiner 合并:

final = combiner( combiner(r1, r2), r3 )

完整 ASCII 图:

          ┌────────────┐
元素 1,2  →│  分片1线程  │→ r1
          └────────────┘
          ┌────────────┐
元素 3,4  →│  分片2线程  │→ r2
          └────────────┘
          ┌────────────┐
元素 5,6  →│  分片3线程  │→ r3
          └────────────┘

      ┌────────── combiner ───────────┐
      ▼                                ▼
    r1,r2 → combiner(r1,r2) → r12  +   r3
      ▼                                ▼
      └──────────→ final ─────────────┘

注意!!这里有一个非常关键的点:

identity 在每个分片都会用一次,不是只用一次。

所以这个写法在并行流下是危险的:

parallelStream().reduce(10, Integer::sum);

10 会在每个分片都加一次,最终结果会比你预期的大很多。

这也是很多人说“parallelStream 结果不对”的核心原因之一。


五、reduce 的高频实战场景(真正在项目里好用的)

很多文章只给你一个“求和”的例子,这里我们看些更贴近实际开发的场景。

1)求和 / 最大 / 最小(教科书但确实常用)

int sum = nums.stream().reduce(0, Integer::sum);

Optional<Integer> max = nums.stream().reduce(Integer::max);
Optional<Integer> min = nums.stream().reduce(Integer::min);

2)构建统计对象(⭐ 强烈推荐,企业开发高频场景)

例如,我们想统计所有用户的总年龄和人数:

class Stat {
    int totalAge;
    int count;
}

Stat stat = users.stream().reduce(
    new Stat(),
    (s, u) -> {
        s.totalAge += u.getAge();
        s.count++;
        return s;
    },
    (s1, s2) -> {
        s1.totalAge += s2.totalAge;
        s1.count += s2.count;
        return s1;
    }
);
  • 串行时:依次把每个用户“折叠进”同一个 Stat
  • 并行时:每个线程有一个自己的 Stat,最后用 combiner 合并

这一类“把一堆对象聚合成一个汇总对象”的场景,reduce 非常适合

典型应用:

  • 订单统计对象(总金额、总件数、订单数…)
  • 用户行为统计对象(总访问次数、总时长…)
  • 报表 DTO 聚合

3)属性累加:比如字符串总长度

int totalLen = list.stream().reduce(
    0,
    (acc, s) -> acc + s.length(),
    Integer::sum
);

思路非常简单:

累计值 + 当前元素的某个属性 → 新的累计值。


4)多字段计算:金额 × 汇率

BigDecimal total = orders.stream().reduce(
    BigDecimal.ZERO,
    (acc, o) -> acc.add(o.getAmount().multiply(o.getRate())),
    BigDecimal::add
);

这类“遍历所有订单做一堆运算 → 得到一个总值”的需求,用 reduce 写非常自然。


六、什么时候适合用 reduce?什么时候不适合?(这部分很重要)

很多人只是“会写”,但不知道“该不该写”,这里直接给出一套判断标准。


✅ 非常适合用 reduce 的场景

1)数值类聚合(sum / max / min / 总长度等)

原因:

  • 本来就是“多个数 → 一个数”的折叠过程
  • reduce 写法最直观、最精简

2)构建统计对象 / 汇总对象(Stat、Summary、DTO)

例如:

  • 统计总金额、总数量、总订单数
  • 统计用户总访问时长、平均停留时间

这类需求天然符合:

“有一个累计对象,每看到一个元素,就把它的信息折叠进去。”


3)多字段复杂计算,最终只要一个结果值

比如上面的“金额 × 汇率”、“权重评分”、“综合指数”等。

只要结果是一个非集合的值,而不是 List / Map,通常都适合用 reduce。


❌ 不适合用 reduce 的场景

1)要构建的是 List / Set / Map 等集合

错误示例:

List<Integer> list = nums.stream().reduce(
    new ArrayList<>(),
    (l, n) -> { l.add(n); return l; },
    (l1, l2) -> { l1.addAll(l2); return l1; }
);

为什么不推荐?

  • 可读性差:别人一看根本猜不到你是在“收集元素”
  • 并行流下有线程安全问题
  • 性能不如 collect

正确写法应该是:

List<Integer> list = nums.stream().collect(Collectors.toList());

记住:想要集合 → 优先考虑 collect,而不是 reduce。


2)需要副作用(打印日志、写数据库、修改外部变量)

stream.reduce(0, (acc, e) -> {
    log.info("{}", e); // 副作用
    return acc + e;
});

为什么不推荐?

  • reduce 设计成“纯函数式”:输入 → 输出,不改外部状态
  • 在并行流里会带来线程安全和顺序问题

副作用类操作建议用:

forEach(...)

3)操作不满足结合律,尤其在 parallelStream 中

例如:

  • 字符串拼接(顺序不同结果不同)
  • 浮点数计算要求完全一致时

并行流会改变执行顺序,如果操作本身不满足:

(ab) ⊕ c == a ⊕ (b ⊕ c)

那么结果可能不稳定。


4)只是想“数一数有多少个元素”

int count = list.stream().reduce(0, (acc,e) -> acc + 1);

功能没问题,但:

  • 不直观
  • 不如 count() 高效

正确写法:

long count = list.stream().count();

七、一张表总结:该不该用 reduce?

需求类型是否适合用 reduce更推荐的方式
求和 / 最大 / 最小✅ 非常适合reduce
统计对象(多字段聚合)✅ 非常适合reduce
复杂计算得到一个数值✅ 适合reduce
想要 List / Set / Map❌ 不适合collect(toList / toSet / toMap)
想打印 / 写库 / 改状态❌ 不适合forEach
想做分组❌ 不适合Collectors.groupingBy
想统计数量❌ 不适合count

可以用一句话概括:

当你只需要“一个非集合的结果”时,优先考虑 reduce。


八、最后总结几句“金句”(方便你记忆 & 面试吹水)

  • map / filter 是在“加工数据”;reduce 是在“做最后的总结”。
  • 当需求是“很多元素 → 一个结果”,十有八九可以用 reduce 写得更优雅。
  • reduce 真正的价值不在“求和”,而在于“把一堆对象聚合成一个统计对象”。
  • parallelStream + reduce 时,一定要注意 identity 和结合律问题。

如果你现在已经看完并理解了上面的内容,那你对 reduce 的掌握程度,已经远超大多数只会“求和”的同学了。