3.4 Stream 里的隐形陷阱 本节摘要:Stream 把"怎么算"和"算什么"分离,代价是执行时机变得隐式。本节覆盖四类高发陷阱:中间操作不触发执行、流复用抛 IllegalStateException、peek 的副作用滥用,以及并行流的公共池挤占与有序性成本,最后给出 Stream 与 for 循环的选型判据。 一次"什么都没发生"的清洗任务 日志清洗服务上线一段代码,运行一周后数据组反馈:目标表里一行数据都没有,而服务日志毫无异常。代码如下: Stream 分两类操作:中间操作(filter、map、sorted……)返回新流、惰性求值;终端操作(collect、forEach、count……)才触发遍历。
本节摘要:Stream 把"怎么算"和"算什么"分离,代价是执行时机变得隐式。本节覆盖四类高发陷阱:中间操作不触发执行、流复用抛 IllegalStateException、peek 的副作用滥用,以及并行流的公共池挤占与有序性成本,最后给出 Stream 与 for 循环的选型判据。
日志清洗服务上线一段代码,运行一周后数据组反馈:目标表里一行数据都没有,而服务日志毫无异常。代码如下:
List<String> dirty = loadLines(); dirty.stream() .filter(line -> line.startsWith("2026")) .map(this::normalize); // 没有 terminal 操作 整条流水线从未执行
Stream 分两类操作:中间操作(filter、map、sorted……)返回新流、惰性求值;终端操作(collect、forEach、count……)才触发遍历。这段代码全是中间操作,等于装配了一条流水线却从未按下启动钮——没有异常、没有日志、没有产出。编译器对"未使用的返回值"仅给提示,静默失败就这么发生了。
第二个经典坑:
Stream<Order> stream = orders.stream().filter(Order::isPaid); long count = stream.count(); List<Order> paid = stream.collect(Collectors.toList()); // IllegalStateException: stream has already been operated upon or closed
流是管道不是集合,用完即焚。要从同一数据源派生多个流,回到集合重新 stream()。背后设计原因是流可能包裹着文件、连接等资源,终端操作后即关闭,复用等于重开已关闭的资源。
orders.stream() .peek(o -> o.setDiscount(calculator.calc(o))) // "顺手"改状态 .map(Order::total) .collect(toList());
能跑,但脆:一旦下游加了 filter 提前短路、或换成 count() 这种可以跳过中间操作的终端操作(JDK 9+ 优化),peek 里的副作用可能一次都不执行。规范给 peek 的定位是调试观察。需要副作用的场景用 map(显式返回修改后的对象)或 forEach(明确的遍历语义),依赖惰性管道里的隐式副作用等于把正确性押给优化器。
并行流默认跑在全局 ForkJoinPool.commonPool() 上,CPU 核数减一是它的固定容量。事故形态很典型:某服务在请求路径上用并行流加速聚合计算,同时另一个模块的并行流在做慢 IO,两者共享同一个池——慢 IO 把计算任务的线程全占了,请求排队超时。更糟的是在 Tomcat 线程里再阻塞等待并行流结果,构成线程饥饿甚至死锁的拓扑。
// 请求线程 等待 commonPool 任务完成 List<R> result = ids.parallelStream() .map(this::slowRpcCall) // IO 任务进了计算池 .collect(toList());
判据很清晰:并行流适合 CPU 密集、任务切分均匀、数据量足够大的操作(比如千万级元素的纯计算);IO 密集请走自己的线程池(CompletableFuture + 专用 executor,见第 5 章)。另外注意有序性成本:ordered() 流的并行仍要保持遇见顺序,findFirst 比 findAny 贵——不关心顺序就明确放弃顺序。

Stream 不是性能优化,多数场景下与手写循环同量级,小集合上装箱流还可能更慢(IntStream 可避免装箱,见第 1 章的装箱账单)。它的真实收益是可读性:groupingBy + counting 一行顶手写二十行,且没有中间变量的命名负担。反过来,超过三四级的嵌套流、流里套流、需要维护复杂外部状态的处理,for 循环的命令式表达反而更直白。我自己的分界线:数据变换用流,控制流程用循环——判断依据是"这段代码在变换数据,还是在编排执行步骤"。
一个容易忽略的正确性细节:Collectors.toMap 遇到重复键直接抛 IllegalStateException(不是覆盖!):
Map<String, Order> byUser = orders.stream() .collect(Collectors.toMap(Order::getUser, o -> o, (a, b) -> a)); // 必须给 mergeFunction 否则重复键爆炸
数据里只要出现一个用户的两笔订单,没写 merge 函数的版本当场翻车——测试造数均匀时测不出,生产长尾数据必现。
⚠️ 常见坑:在 Stream 里捕获并修改外部局部变量。编译器要求捕获的局部变量事实 final,绕过手法(改数组元素、动集合)能编译通过,但把"数据流"变成了"隐藏的共享状态",并行时立刻数据竞争。
💡 关键直觉:Stream 把执行时机从代码顺序中剥离,惰性是它的引擎也是它的陷阱。写流的最后一行永远是终端操作——没有它,前面的一切只是声明。
第 3 章到此收束。下一章进入类库区——集合、字符串、日期、IO,坑地图最密集的地带。