Administrator
发布于 2018-05-29 / 341 阅读
7

Java 8 Stream 很好用,但这几个坑我踩过

用 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.summingIntaveragingIntgroupingBypartitioningBy…… 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 排序 Integer100 万412ms138ms
简单 map 转换100 万18ms31ms
sum 求和10 万3ms9ms
forEach 里查 MySQL10001200ms180ms

结论很明确:数据量小、单元素处理快、有 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 循环往往更清楚。

参考