2233 字
约 7 分钟
17
S04-A15 · Stream 的惰性与副作用:为什么它没执行

一段代码统计过滤后的数量,写成 list.stream().filter(x -> x > 0).map(this::save);,运行后数据库里没有新记录。原因很简单:Stream 是惰性的,没有终止操作就什么都不做。map 里的 save 从未被调用。

这个陷阱的代价在别处:它不是抛异常,而是"安静地什么都没发生"。更麻烦的是它的"隐蔽期":这段代码可能几个月都没问题,因为最初写的时候带了一个 collect,后来有人"简化"掉了——而改动在评审里看起来完全无害。
而这类"什么都没发生"在生产上最难发现——因为它看起来像"没有数据符合条件"。


1. 惰性管线的三段

段 操作 是否立即执行
源 collection.stream() 否
中间操作 filter / map / sorted / distinct 否(只记录)
终止操作 count / collect / forEach / anyMatch 是(触发全部)

关键规则:中间操作只是"声明",一次遍历在终止操作时发生。所以"我调了 map 却没生效"不是 bug,而是管线没被驱动。

peek 也是中间操作,它的用途是调试——在 peek 里写业务逻辑等于把逻辑挂在"可能执行也可能不执行"的地方。还有一个相关细节:peek 在 JDK 9+ 的某些优化下可能被跳过(当元素数已知且不需要遍历时)。所以"用 peek 记日志"在极端情况下也会漏记录。

// 反例:无终止操作,什么都不执行
list.stream().filter(x -> x > 0).map(this::save);       // 没有终止操作

// 正例:用终止操作驱动
long n = list.stream().filter(x -> x > 0).peek(this::log).count();
// 或需要副作用时,改用显式循环
for (var x : list) if (x > 0) save(x);

2. 副作用在 Stream 里的三个风险

风险 表现
未执行 无终止操作
执行次数不确定 findFirst 可能只跑一部分
并行下乱序/竞态 顺序不保证

第二项最容易被忽略:findFirst / anyMatch 会短路,于是"到匹配为止"之前的元素被处理,之后的没有。如果你的副作用是"记录所有处理过的项",结果会缺一部分。

第二项的短路特性有个"合理用途":它正是性能来源——anyMatch 在找到第一个匹配后立刻停止,比起"处理全部再判断"省很多。所以问题不在短路,而在"依赖它执行副作用"。
第三项在并行流上更严重:forEach 不保证顺序(要用 forEachOrdered),而共享的可变状态(如 ArrayList.add)会在并行下损坏。

// 反例:并行流里改共享集合
List<Integer> out = new ArrayList<>();
list.parallelStream().filter(x -> x > 0).forEach(out::add);   // 竞态,可能丢元素

// 正例:用 collect,由框架保证线程安全
List<Integer> out = list.parallelStream().filter(x -> x > 0).collect(Collectors.toList());

3. 什么时候该用循环而不是 Stream

情形 建议
有副作用(写库、发消息) 用循环
需要索引 用循环
需要 break/continue/return 用循环
需要异常与控制流 用循环
纯转换与聚合 用 Stream

第一项是原则性问题:Stream 的设计前提是"无状态、无副作用"。第四项(异常与控制流)在实践中很常见:在 lambda 里抛受检异常需要包装,而包装后的栈信息更难读。这类场景用循环,代码反而更短。
在它里面写副作用,等于用错工具,且会得到"不确定的执行次数"。

第三项也很现实:Stream 没有 break,只能靠 takeWhile(Java 9+)或 anyMatch 变通;复杂控制流写出来比循环难读得多。

// 需要提前退出:循环更清楚
for (var item : items) {
    if (process(item)) return item.getId();     // 直接返回
}
// Stream 变通写法(可读性下降)
return items.stream().filter(this::process).findFirst().map(Item::getId).orElse(null);

4. 性能上的两个误解

误解 实际
Stream 一定比循环快 不一定,取决于管线与 JIT
并行流一定更快 只有大数量+重计算才可能

第二项的真相还有一层:并行流的加速来自"拆分",而拆分需要 spliterator 支持。LinkedList 与基于迭代器的源拆分开销大,于是并行几乎一定更慢。
第一项的真相:简单管线(filter+count)在 JIT 优化后与循环接近;而在元素少、逻辑简单时,Stream 的固定开销(对象创建、lambda 调用)反而更明显。

第二项的边界很硬:并行流使用公共 ForkJoinPool,会与其它并行任务争抢线程;且拆分/合并有成本,元素少于几万时基本是负收益。

// 并行流的适用条件的粗判
数据量 > 10 万、单元素计算 > 微秒级、无共享状态、无阻塞 IO
// 任一不满足 → 用串行或循环;阻塞 IO 在并行流里会拖死整个池

还有一个"复用"陷阱值得说:Stream 只能被消费一次。把 stream 存在变量里第二次调用会抛 IllegalStateException: stream has already been operated upon or closed,而如果把 Supplier<Stream> 误写成 Stream,这类错误会散布在多处。

// 反例:stream 被复用
Stream<Item> s = items.stream().filter(this::valid);
long a = s.count();
List<Item> b = s.collect(toList());   // IllegalStateException

// 正例:需要多次用就每次新建,或缓存集合
long a = items.stream().filter(this::valid).count();
List<Item> b = items.stream().filter(this::valid).toList();

5. 调试惰性管线

手段 用途
peek + 日志 看每个元素流向
拆成多步赋值 定位哪一步没跑
加终止操作 确认是否被驱动
IDE 断点在 lambda 观察调用次数

第二项最实用:把长管线拆成几次 collect,看中间结果。第三项(加终止操作确认)有一个诊断价值:如果加了 count() 之后副作用出现了,说明原来只是"没被驱动";如果仍然没有,说明条件本身不成立——这两种情况修法完全不同。
虽然多了一次遍历,但能立刻定位问题在哪一段。

第四项能揭示"执行次数"问题:在 lambda 里打断点,看它被调用了几次——如果只调了 1 次而你以为是 10 次,那可能是短路提前结束。

6. 排错:五种"Stream 行为不符预期"

现象 成因 处理方向
什么都没发生 无终止操作 加终止操作或换循环
只处理了一部分 短路(findFirst/anyMatch) 改用 forEach 或循环
结果顺序乱 并行流无序 forEachOrdered 或串行
元素丢失 并行写共享集合 用 collect
比循环还慢 元素少或管线复杂 换循环

第五项(比循环还慢)在"元素少但 lambda 复杂"时最明显:每个 lambda 都是一次虚调用,而 JIT 的内联需要预热。所以同样的逻辑,短集合上循环稳定更快。
第四项最难查:丢失是概率性的,压力小时看不出来。而它的根因通常是 ArrayList 的非线程安全写在并行流里。

7. 边界:什么情况 Stream 明显更好

场景 为什么
纯转换与聚合(group/join) 表达力强、不易错
多步条件过滤 可读性优于嵌套 if
需要 groupingBy 分类 手写循环冗长
短小的集合处理 一行表达清楚

判断口径一句话:这段逻辑有没有副作用。最后一条与"团队约定"有关的边界:约定要写成可检查的规则。比如"Stream 内禁止调用 repository",可以靠评审或静态检查落地;而"尽量用函数式"这种约定无法检查,也就无法执行。
有(写库、发消息、改外部状态),用循环;没有(纯计算、纯转换),Stream 更清楚。


8. 自测

  1. 惰性管线的三段分别是什么?为什么"没有终止操作"不会报错?
  2. peek 为什么不适合放业务逻辑?它的正当用途是什么?
  3. findFirst / anyMatch 造成的"只处理一部分"是什么机制?
  4. 并行流写共享集合为什么危险?正确的替代是什么?
  5. 并行流的适用条件有哪些?为什么阻塞 IO 放进去会拖死整个池?
  6. 判断"用 Stream 还是循环"的那个问题是什么?
S04-A15 · Stream 的惰性与副作用:为什么它没执行
https://www.clxhxhhr.top/posts/6489/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。