Skip to content

一个 parallelStream,拖慢了不相干的接口 ​

订单详情接口要查四个下游,有人把循环改成了 parallelStream(),单测里从 800 毫秒降到 200 毫秒。上线后这个接口在高峰期又回到了 800 毫秒,同一个服务里另一个用了 CompletableFuture.supplyAsync 的报表功能也跟着变慢。两处代码没有任何共同的类。

它们共用了一个线程池。parallelStream() 和不传执行器的 supplyAsync 都把任务交给 ForkJoinPool.commonPool(),整个 JVM 只有一个,线程数按 CPU 核数定。这个池是为纯计算准备的;把会阻塞的调用放进去,几个线程被占住之后,进程里所有用到它的地方一起排队。

实验见文末。主实验在 JDK 25.0.4.1 上按 4 个 CPU 运行,阻塞调用用 200 毫秒的 sleep 代替。

订单接口:parallelStream()每个元素调一次下游报表模块:supplyAsync(任务)没有传执行器另一个接口:parallelStream()纯计算ForkJoinPool.commonPool()并行度 = CPU 数 − 1工作线程 1:阻塞 200 ms工作线程 2:阻塞 200 ms工作线程 3:阻塞 200 ms8 个请求同时进来最慢的 214 → 826 ms一个空的异步任务等了 826 ms 才开始纯计算的并行流只在调用线程上跑,3 ms并行流的调用线程自己也会参与执行,所以最坏是退化成串行;supplyAsync 的任务只能等池里的线程1—2 个 CPU 的容器里并行度是 1。JDK 21 及更早在这种情况下为每个异步任务新建一个线程,JDK 25 改为仍用公共池:2 核时 6 个 200 ms 的阻塞任务从 205 ms 变成 1233 ms阻塞调用交给专用线程池(并传给 supplyAsync),12 个任务 835 ms → 206 ms
图 1 · 实测(JDK 25.0.4.1,按 4 个 CPU 计算,公共池并行度 3):parallelStream() 与不传执行器的 supplyAsync 都把任务交给 ForkJoinPool.commonPool(),整个进程只有这一个。池里的线程数按 CPU 数定,适合纯计算;把 200 毫秒的阻塞调用放进去,三个线程全被占住,其他地方提交的任务只能等。

一、公共池有多大 ​

text
availableProcessors = 4,公共池并行度 = 3

并行度是 CPU 数减一,留下的那一个给调用线程:并行流的调用线程自己也会参与执行。所以 4 核机器上一个并行流最多同时有 4 个线程在干活。

CPU 数公共池并行度8 次阻塞调用的并行流用到的线程耗时
112 个约 4 轮(823 ms)
212 个约 4 轮(840 ms)
434 个约 2 轮(420 ms)
878 个约 1 轮(211 ms)

这里的 CPU 数是 JVM 看到的数。容器里它由 CPU 限额决定:限额 2 核的 Pod,不管宿主机有多少核,公共池的并行度都是 1。在 8 核的开发机上测出来的「并行之后快了 8 倍」,到了 2 核的容器里是快 2 倍。

二、一个请求时很好,多个请求时退化成串行 ​

一个请求里有 4 次下游调用,各 200 毫秒,用并行流发出去:

单独一个请求时是 214 毫秒,4 个线程各做一次。这就是单测里看到的效果。

8 个这样的请求同时进来:

请求(按耗时排序)12345678
耗时(ms)404408614615615814818826

没有一个请求还是 200 毫秒,最慢的三个在 814 到 826 毫秒,等于 4 次调用完全串行。

原因是池里只有 3 个线程,要分给 8 个请求。每个请求的调用线程会自己执行一部分,剩下的等池里的线程来帮忙;没轮到帮忙的请求,就由调用线程一个一个做完。并行流的加速来自公共池的那几个线程,它们是全进程共享的;并发的请求越多,每个请求分到的越少,下限是完全串行。

这个退化在压测里才看得到,而且不报错,表现只是「接口在高峰期变慢了」。

三、supplyAsync 不传执行器,用的也是它 ​

不传执行器时任务运行在公共池线程上。12 个 200 毫秒的阻塞任务用了 835 毫秒:四轮,每轮 3 个。

supplyAsync(任务) 与并行流有一处不同:调用线程不参与执行,任务只能等池里的线程。3 个线程都在阻塞时,后面提交的任务一直等着,不管它本身多小:

池被这 12 个任务占住期间结果
提交一个什么都不做的异步任务等了 826 ms 才开始执行
跑一个纯计算的并行流(1000 个元素求和)参与执行的只有调用线程,3 ms

那个空任务什么都不做,却等了 826 毫秒。这就是开头两个不相干的功能互相拖慢的原因:一处把阻塞调用放进了公共池,另一处提交的任务排在它们后面。纯计算的并行流受的影响小一些,因为调用线程可以自己把活干完,只是失去了并行。

把阻塞的任务交给专用线程池:

java
ExecutorService io = Executors.newFixedThreadPool(12);
CompletableFuture.runAsync(task, io);

同样 12 个任务,用了 206 毫秒。

CompletableFuture 上所有带 Async 后缀的方法都有一个接收执行器的重载。涉及 I/O 的链路,每一处都传。专用线程池的大小怎么定,见线程池参数怎么定。

JDK 25 在小核数容器里的变化 ​

公共池并行度小于 2 时(1 核或 2 核),JDK 8 到 21 的 CompletableFuture 不使用公共池,而是为每个异步任务新建一个线程;JDK 25 改为仍然使用公共池。同一段代码,6 个 200 毫秒的阻塞任务:

JDK1 或 2 个 CPU4 个 CPU
8u504每个任务新建一个线程,约 1 轮(2 个 CPU 时 211 ms)公共池,约 2 轮
21.0.12每个任务新建一个线程,约 1 轮(2 个 CPU 时 205 ms)公共池,约 2 轮
25.0.4.1公共池(1 个线程),约 6 轮(2 个 CPU 时 1233 ms)公共池,约 2 轮

两个版本的 Javadoc 对应着这个差别:JDK 21 写的是公共池「不支持至少为 2 的并行度时,为每个任务新建一个线程」,JDK 25 里这半句没有了。

对在 1 核、2 核限额的容器里运行、又在默认执行器上做阻塞调用的服务,这意味着升级到 JDK 25 之后这些异步任务从并发变成了串行。旧行为本身也有问题(线程数没有上限),但它掩盖了「阻塞调用用了默认执行器」这件事。升级前搜一遍不带执行器参数的 supplyAsync、runAsync、thenApplyAsync 等调用。

四、能不能让并行流用自己的池 ​

java
ForkJoinPool own = new ForkJoinPool(8);
own.submit(() -> list.parallelStream().forEach(this::call)).get();

8 次阻塞调用全部由自建池的线程执行,209 毫秒。

在一个自建的 ForkJoinPool 的线程里启动并行流,任务会留在这个池里。这是当前实现的行为,Stream 的文档没有承诺,使用时要清楚这一点。

更直接的做法是不用并行流做 I/O:把每次调用提交到专用线程池,拿到一组 CompletableFuture 再汇总;或者用虚拟线程,每个调用一个,见虚拟线程迁移。并行流留给它擅长的事:数据量大、每个元素的处理是纯计算、没有共享的可变状态。

五、并行流的另外三个问题 ​

线程上下文不会跟过去。

text
调用线程设置了 ThreadLocal 之后跑并行流:[公共池线程读到 null, 调用线程读到 tenant-a]

同一个流里,在调用线程上执行的元素能读到 ThreadLocal,在公共池线程上执行的读不到。租户、用户身份、链路追踪、事务都是靠线程上下文传递的,于是同一次请求里一部分下游调用带着身份、一部分没有,而且哪些元素落在哪个线程上每次都不一样。上下文的传递见压测流量写进了正式表。

往非线程安全的集合里收集。

text
并行流里 forEach(list::add) 往 ArrayList 里放 10 万个元素:丢元素、出现 null 或抛异常 = true;collect(toList()) 得到 100000 个

forEach 里修改外部的 ArrayList、HashMap 是并发写。用 collect,由框架负责合并各个线程的结果。

类初始化里用并行流会死锁。

java
class Holder {
    static final int SUM;
    static { SUM = IntStream.range(0, 100).parallel().map(i -> one() * i).sum(); }
    static int one() { return 1; }
}
text
静态初始化块里跑并行流(lambda 调用了本类的静态方法):5 秒内没有结束,被强制终止

主线程正在初始化 Holder,持有它的初始化锁,并等着公共池的线程把任务做完;公共池的线程要执行 lambda,lambda 属于 Holder,得先等 Holder 初始化完成。两边互相等。静态初始化块、单例的构造过程里不要启动并行流或向线程池提交会用到本类的任务。

六、检查清单 ​

  • 并行流里不放阻塞调用:远程调用、数据库查询、文件读写、加锁等待。
  • CompletableFuture 的异步方法,涉及 I/O 时一律传入专用的执行器。
  • 评估并行流的收益时,用生产环境的 CPU 限额,并且在有并发请求的情况下测。
  • 依赖线程上下文的代码不放进并行流。
  • 结果用 collect 收集,不在 forEach 里写共享的集合。
  • 升级到 JDK 25 且容器限额不超过 2 核时,检查默认执行器上的阻塞任务。

配套实验

  • codesphere-labs/java/parallel-stream-common-pool:JDK 25.0.4.1 按 4 个 CPU 运行:阻塞调用放进并行流、8 个请求同时使用、默认执行器的异步任务、自建池、ThreadLocal、类初始化死锁;另在 JDK 8u504、21.0.12、25.0.4.1 上比较 1、2、4 个 CPU 时默认执行器的行为。结论行确定,耗时只断言区间(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权