用 Stream 写的统计,结果是错的
Java 8 的 Stream 我从去年开始用,写起来确实爽,但这段时间接连踩了几个坑,都是"能编译、能运行、结果不对"那种,比直接报错更难查。记一下。
坑一:Stream 只能用一次
第一次遇到是这段代码:
Stream<Order> stream = orders.stream().filter(o -> o.getAmount() > 100);
long count = stream.count();
List<Order> list = stream.collect(Collectors.toList());
运行时报错:
java.lang.IllegalStateException: stream has already been operated upon or closed
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)
Stream 和迭代器一样是一次性的。它的设计是"流水线",数据从源头流过一遍,中间操作(filter/map)被串成一条链,终端操作(count/collect)触发执行,执行完这条链就废了。
正确做法是每次重新创建,或者干脆把中间结果收集起来:
List<Order> filtered = orders.stream()
.filter(o -> o.getAmount() > 100)
.collect(Collectors.toList());
long count = filtered.size();
我用 Supplier<Stream> 处理过需要重复用的场景,写起来别扭,不如直接收成 List。
坑二:在 forEach 里改外部变量
这个坑我栽得最惨。想统计订单总金额,我很自然地写了:
int total = 0;
orders.stream().forEach(o -> total += o.getAmount());
编译直接报错:
error: local variables referenced from a lambda expression
must be final or effectively final
Java 的 lambda 捕获外部局部变量时,要求这个变量事实上不可变(effectively final)。这是有原因的:lambda 可能在另一个线程执行,如果允许多线程修改一个局部变量,那这个变量得放堆上,还得处理同步,JVM 的局部变量表是线程私有的栈结构,做不到。
绕过限制的办法也有人用——比如搞个 AtomicInteger 或者数组:
AtomicInteger total = new AtomicInteger();
orders.stream().forEach(o -> total.addAndGet(o.getAmount()));
能跑,但这完全是用函数式的壳写命令式的代码,还白白引入了并发开销(我实测在 10 万元素的列表上,这种写法比 for 循环慢 4 倍)。Stream 的正确姿势是用归约:
int total = orders.stream()
.mapToInt(Order::getAmount)
.sum();
一行搞定,还快。类似需求先想想有没有现成的收集器:Collectors.summingInt、averagingInt、groupingBy、partitioningBy…… Collectors 里的方法比我以为的多得多,查一遍能省不少事。
坑三:peek 不是用来做业务操作的
我刚学 Stream 的时候,觉得 peek 很好用,经常拿来"顺便做点事":
orders.stream()
.filter(o -> o.getStatus() == 1)
.peek(o -> log.info("处理订单 {}", o.getId())) // 日志有时不打印
.map(Order::getUserId)
.collect(Collectors.toList());
这段代码有个诡异现象:日志时有时无。加上 filter 之后如果结果为空,日志一条都不打;去掉 filter 又全打了。
原因有两个。一是 peek 是惰性的,只有在终端操作真正消费到那个元素时才会执行。二是 Stream 有优化:如果 JVM 发现某些中间操作的结果不会被使用(比如后面接 count() 时,map 的结果其实不需要),它会直接跳过整个中间链。
我验证过一次:
Stream.of("a", "b", "c")
.peek(s -> System.out.println("peek: " + s))
.count();
// 什么都没打印!因为 count() 不需要元素,JDK 8 里直接跳过了 peek
(这个优化在 JDK 9 之后对 count 场景做了调整,但 filter + peek 的惰性本质没变。)
结论:peek 只应该用于 debug,而且只在你需要观察元素流动时用。真要做副作用操作,用 forEach,或者老老实实写 for 循环。
坑四:parallelStream 不是免费的加速
看到列表大就手痒想加 parallel(),我干过。这段代码的运行结果每次都不一样:
List<Integer> result = new ArrayList<>();
IntStream.range(0, 10000)
.parallel()
.forEach(result::add);
System.out.println(result.size()); // 输出 8432、9127、7651……每次都不同
ArrayList 不是线程安全的,多个线程同时 add,扩容时 elementData[size++] = e 这三步不是原子的,会互相覆盖,还会数组越界。正确做法是别在 forEach 里往共享容器塞东西,而是让 Stream 自己收集:
List<Integer> result = IntStream.range(0, 10000)
.parallel()
.boxed()
.collect(Collectors.toList()); // 收集器内部处理了并发合并
还有更隐蔽的一个:parallelStream 里的 lambda 如果用了共享的 SimpleDateFormat、Random、HashMap 这些非线程安全的东西,一样会出问题,而且表现是随机的脏数据,不是异常。
什么时候不该用并行流
我做过一组测试,环境是 8 核机器、JDK 8u161:
| 场景 | 元素数 | 串行 | 并行 |
|---|---|---|---|
| sorted 排序 Integer | 100 万 | 412ms | 138ms |
| 简单 map 转换 | 100 万 | 18ms | 31ms |
| sum 求和 | 10 万 | 3ms | 9ms |
| forEach 里查 MySQL | 1000 | 1200ms | 180ms |
结论很明确:数据量小、单元素处理快、有 IO 阻塞(但要控制并发度)这三类场景差别很大。前两行说明——元素少或者单个操作简单时,并行反而更慢,因为 fork/join 的拆分、合并、线程调度本身就有开销(我实测单次并行启动开销约 1ms 量级)。
另外两个必须知道的:
- 并行流默认用的是全局共享的 ForkJoinPool.commonPool,线程数是 CPU 核数 - 1。一个地方用 parallel 把公共池占满了,其他地方的并行流全得排队。我见过因为一个批量任务用并行流,导致整个应用其他并行任务卡住的情况。
- 并行流不改变原有的顺序语义——
collect(Collectors.toList())收集的结果顺序和串行一致,但如果用了forEach而不是forEachOrdered,遍历顺序是不保证的。
坑五:Optional 用在字段上
这个不算 Stream 的坑,但经常一起出现。我把 Optional 当成了 DTO 的字段类型:
public class OrderVO {
private Optional<String> couponCode; // 别这么干
}
Optional 没有实现 Serializable,一旦这个对象要进 Redis 或者走 RPC,直接 NotSerializableException。而且 Jackson 默认不知道怎么序列化 Optional,得额外引 jackson-datatype-jdk8 模块。
Optional 的设计意图是作为方法返回值,提醒调用方"这里可能没有值"。用在别的地方都算误用。
我的几条使用习惯
- Stream 链不要写太长,超过 5 个操作就考虑拆开或者改回循环。我 review 过一段 12 个操作的 Stream,出问题时没法在中间在 IDEA 里打断点,调试到崩溃。
- lambda 里超过 3 行就提取成方法,用方法引用调用,可读性好很多。
- 不确定性能的时候测一下。Stream 在大多数场景和 for 循环差距不大(我测过 filter + map + collect 十万元素,Stream 32ms,for 循环 28ms),但用错了地方能差 10 倍。
- 别为了用 Stream 而用 Stream。一个简单遍历里做三件事的场景,for 循环往往更清楚。