Java并行流,让我debug了一整天

上个月中旬的一个周五下午,我看了一眼Jira上的bug单,差点没把刚端起来的咖啡放下。 运营说导出的订单数据和后台系统对不上------差了大概8%。不多不少,刚好在"不仔细核对发现不了"的范围内。但这个导出数据是要给财务做月度对账的。 我花了一整天才定位到这个bug。最后发现,罪魁祸首是一行看起来很"高级"的代码:parallelStream()

事情是这样的

我们有个订单数据导出功能,需求不复杂:把订单列表过滤一遍,转换成DTO,然后导出CSV。原来用的是普通Stream:

scss 复制代码
List<OrderDTO> result = orders.stream()
    .filter(o -> o.getStatus() == OrderStatus.COMPLETED)
    .map(this::convertToDTO)
    .collect(Collectors.toList());

跑了大半年没问题。直到有一天,运营说数据量大了导出太慢,十几万条数据要等快一分钟。我心想这还不简单?Java 8不是有个parallelStream()嘛,一行代码就能利用多核CPU加速,改一下试试。

scss 复制代码
List<OrderDTO> result = orders.parallelStream()
    .filter(o -> o.getStatus() == OrderStatus.COMPLETED)
    .map(this::convertToDTO)
    .collect(Collectors.toList());

改完本地跑了一下,完美。10万条数据从45秒直接降到12秒,我甚至还在代码评审的时候小小得意了一下。 上线第一周,没事。第二周,运营说偶尔有数据对不上的情况。不是每次都出,时好时坏。我去后台查了一下,确实少了几百条。

一开始我找错了方向

说实话,第一反应我怀疑的是collect(Collectors.toList())在并行模式下有问题。毕竟文档上也没写得很清楚,万一是JDK的bug呢? 我甚至在本地写了个测试,循环跑了一千次parallelStream().collect(),每次结果都是对的。心想这不对啊,本地完全复现不了。 然后我注意到了一个细节:出问题的不只是导出功能,还有另一个统计模块,也是偶尔数据偏少。这两个模块唯一的共同点------都是Java 8的Stream API。 我又把目光投向了parallelStream(),但这次换了个角度看。

问题不在collect,在forEach

顺着统计模块的代码一路找过去,我看到了这么一段:

scss 复制代码
List<OrderRecord> allRecords = new ArrayList<>();

orders.parallelStream()
    .filter(o -> o.getAmount() > threshold)
    .forEach(record -> {
        allRecords.add(processRecord(record));
        // 顺便做一些日志记录和状态更新
        updateStatistics(record);
    });

我愣住了。 ArrayList不是线程安全的。parallelStream()底层用的是ForkJoinPool,多个线程会同时往allRecordsadd。这不是铁定的数据竞争吗? 但为什么本地测不出来?因为本地开发机是2核的虚拟机,并行度很低,大部分数据实际上被一个线程顺序处理了。到了生产环境,8核物理机,线程真的并发起来了,每次add都可能打架。

ArrayList.add()到底怎么炸的

可能有人觉得"add一下能有多大事",我拆一下源码你就明白了。 ArrayList的add方法核心逻辑就三行:

arduino 复制代码
public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;  // 这一行是重点
    return true;
}

elementData[size++] = e看着是一行代码,但其实是三个操作:读取当前size的值、把元素放到size对应的位置、size加1。 在单线程下这没问题。但parallelStream多线程同时执行时:

css 复制代码
线程A:读到 size=100,准备写到 elementData[100]
线程B:也读到 size=100(A还没来得及size++),也往 elementData[100] 写
结果:线程A的数据被覆盖了,丢了一条

这还不是最惨的。有时候size的读和写交错得更乱,会导致ArrayIndexOutOfBoundsException,或者某些位置压根没写入变成了null。 最要命的是------这个bug不抛异常。数据默默丢了,List的size比实际少了一截,但程序正常运行,不报错不崩溃。如果没人逐条核对数据,可能几个月都发现不了。

后来回想了一下,这段代码其实在测试环境跑了好久,一直完全没问题。原因也简单------测试机只有2核,绝大部分数据被一个线程顺序处理了,几乎不会冲突。上了8核的生产机就完全是另一回事了。 这也是为什么我本地一直复现不了------我的开发机也是2核的。核数不同,bug表现完全不同,这大概是并行编程里最坑的地方。

更大的坑:commonPool

修完ArrayList的问题,我以为完事了。结果排查另一个模块的时候,发现了一个更隐蔽的问题。 parallelStream()底层用的线程池是ForkJoinPool.commonPool()。这个pool是JVM全局共享 的。也就是说,你应用里所有用parallelStream的地方,加上所有用CompletableFuture.supplyAsync()默认配置的异步任务,全在抢同一个线程池。 commonPool默认线程数 = CPU核心数。我们生产机8核,所以线程池里就8个线程。

举个实际例子:我们的订单导出接口用parallelStream处理数据,同一时间如果统计接口也在跑并行流,再加上几个CompletableFuture的异步调用,8个线程瞬间就被占满了。结果就是所有接口的响应时间都变慢了,监控上看到的延迟曲线突然就飙上去了。

这还不是最极端的情况。如果parallelStream里碰巧有阻塞操作(比如某个filter里调了数据库),那线程直接被卡住,整个commonPool就瘫痪了。我之前看到有人分享过类似的经历------在生产系统跑了一个并行计算任务,结果把所有用到commonPool的接口全拖慢了。当时我还觉得"这得是多极端的场景",没想到自己差点也踩进去。

为什么collect()是安全的

回到那个导出功能。我前面说collect()本身是安全的,那它是怎么做到的? collect()底层走的是"拆分-分别收集-合并"模式:每个线程维护自己的局部ArrayList,各收各的,互不干扰。全部处理完之后,再把这些局部列表合并成一个最终结果。整个过程没有多线程同时操作同一个ArrayList的情况,所以是安全的。

scss 复制代码
// 这个写法是安全的
List<OrderDTO> result = orders.parallelStream()
    .filter(o -> o.getStatus() == OrderStatus.COMPLETED)
    .map(this::convertToDTO)
    .collect(Collectors.toList());  // collect内部处理了线程安全

// 这个写法是不安全的
List<OrderDTO> result = new ArrayList<>();
orders.parallelStream()
    .filter(o -> o.getStatus() == OrderStatus.COMPLETED)
    .map(this::convertToDTO)
    .forEach(result::add);  // 多线程同时操作同一个ArrayList,必炸

所以关键区别在于:collect()没有"外部共享可变状态",而forEach(result::add)有。

Collectors.toMap()也有坑

既然说到了,顺便提一个我后来发现的隐藏坑。Collectors.toMap()在parallelStream下也不太靠谱------底层默认用HashMap,数据量大的时候并发写入容易出问题。不是说一定会崩,而是取决于数据量和key冲突的概率,属于那种"不知道什么时候就炸"的类型。 保险的做法是直接用toConcurrentMap,这个是专门为并行场景设计的:

rust 复制代码
Map<String, Order> map = orders.parallelStream()
    .collect(Collectors.toConcurrentMap(
        Order::getId, 
        o -> o, 
        (existing, replacement) -> replacement  // key冲突时取新的
    ));

注意是toConcurrentMap而不是toMap加参数。另外key冲突时默认会抛IllegalStateException,记得指定合并策略。

吃了两次亏之后的经验法则

现在我的原则很简单:

用parallelStream的条件(全部满足才用)

  • lambda表达式里没有任何外部状态修改(纯函数)
  • 数据源本身是线程安全的(比如Collections.unmodifiableListCopyOnWriteArrayList
  • 数据量够大(一般10万+以上),能cover住拆分的开销
  • 确认不会影响commonPool里的其他任务

不用parallelStream的场景(这些情况别碰)

  • lambda里有synchronized块、有对外部集合的add/remove、有对共享计数器的++
  • lambda中修改外部非线程安全集合(不管数据源本身是什么类型)
  • 操作涉及I/O(数据库查询、网络请求)------I/O阻塞会直接占住ForkJoin线程
  • 你在写Web接口,commonPool被阻塞会影响所有接口

如果确实需要并行处理,比如想隔离线程池避免影响commonPool,可以自建ForkJoinPool:

scss 复制代码
ForkJoinPool customPool = new ForkJoinPool(4);  // 自定义线程数
try {
    List<OrderDTO> result = customPool.submit(() ->
        orders.parallelStream()
            .filter(o -> o.getStatus() == OrderStatus.COMPLETED)
            .map(this::convertToDTO)
            .collect(Collectors.toList())
    ).get();
} finally {
    customPool.shutdown();
}

这样这个并行任务就用自己独立的线程池,不会影响到其他模块。如果是I/O密集型操作,那parallelStream根本不适合,用CompletableFuture配合自定义线程池,或者Java 21以上直接用虚拟线程,比ForkJoinPool香多了。

我的经验是:大多数业务场景根本不需要parallelStream。你以为你在优化性能,其实你是在引入并发bug。真想优化,先看看SQL能不能加个索引、分页能不能改批量拉取,这些的收益远比一行parallel()大得多。

最后

那天的bug最后改成了什么?把forEach(result::add)换成了collect(Collectors.toList()),然后统计模块也做了同样的修改。顺便把commonPool的线程池隔离了一下,用自定义的ForkJoinPool替代默认的。 改完上线跑了一个月,再没出过数据不一致的问题。 Java 8的Stream API确实是好东西,声明式写法让代码可读性提升不少。但parallelStream这个特性,怎么说呢------它把"并行计算"这件事包装得太简单了,简单到你会忘记底层有多复杂。 一行代码就能并行的代价是,你很容易忘记并行这两个字意味着什么。

相关推荐
DFT计算杂谈1 小时前
FeSe超薄膜在CaF2衬底上的电子结构DFT研究
java·服务器·前端
circuitsosk1 小时前
长文本与高并发下的Token“瘦身”策略:Prompt压缩与上下文窗口优化
java·前端·python·prompt·上下文窗口·token优化
岁岁养乐多1 小时前
LangChain4j 工厂模式
java·开发语言
小小小米粒1 小时前
idea常用搭配
java
吃饱了得干活1 小时前
Java Map 核心原理:从数据结构到 put/get 执行,一篇彻底讲透
java·后端
晚安code1 小时前
Java并发集合详解:从HashMap到ConcurrentHashMap
java
不才不才不不才2 小时前
Spring 源码系列(17): HandlerMapping 与 HandlerAdapter 两大体系
java·后端·spring
lisin-lee-cooper2 小时前
JVM知识体系
java·jvm
凤山老林2 小时前
Spring Boot 大文件处理实战:分片上传、断点续传与 OSS 集成
java·spring boot·后端·大文件上传·分片上传·断点续传