一段代码统计过滤后的数量,写成 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. 自测
- 惰性管线的三段分别是什么?为什么"没有终止操作"不会报错?
peek为什么不适合放业务逻辑?它的正当用途是什么?findFirst/anyMatch造成的"只处理一部分"是什么机制?- 并行流写共享集合为什么危险?正确的替代是什么?
- 并行流的适用条件有哪些?为什么阻塞 IO 放进去会拖死整个池?
- 判断"用 Stream 还是循环"的那个问题是什么?