结论先行
CompletableFuture.supplyAsync(supplier) 不传 executor 时默认走 ForkJoinPool.commonPool,而 commonPool 默认 worker 数 = Runtime.availableProcessors() - 1——8 核容器下只有 7 个 worker。 当 supplier 内部是阻塞调用(同步 RPC、DB、synchronized)时,一个 worker 挂 200ms 就占掉一个 worker;7 个 worker 被 7 个慢调用占满后,全进程所有走 commonPool 的任务——包括 list.parallelStream()、别的 supplyAsync——集体排队。我们一个 8C16G 的订单服务,一次下游 RPC P99 从 40ms 抖到 200ms,结果同进程里所有 parallelStream 接口的 P99 一起从 80ms 跳到 2.4s,CPU 只有 35%、GC 正常、连接池没打满——所有常规指标都指向「没问题」。这篇文章把机制、定位、修复和四个反直觉的坑一次讲清楚。
一、现象:1% 的请求受影响,但受影响的是「全进程所有 parallelStream」
规模:Spring Boot 3.2.5 + JDK 21.0.3(LTS),K8s 单 Pod request=limit=16(8 物理核 / 16 逻辑核,Runtime.availableProcessors() 返回 8,因为 cgroup v2 下 JDK 19+ 会读 cpu.max),下游是 gRPC 风控服务(P50 15ms / P99 正常 40ms / 抖动时 200ms)+ MySQL 8.0.32(HikariCP 5.1.0,maximumPoolSize=64)。
出问题的接口 GET /order/summary:
- 正常:P50 22ms,P99 80ms,QPS 1500;
- 抖动期(下游 P99 200ms,持续约 8 分钟):P99 跳到 2.4s,P50 只涨到 45ms。
更诡异的是同进程里另外两个接口:
GET /report/export(用list.parallelStream().map(...)做本地聚合,完全不碰下游 RPC):P99 从 60ms 跳到 1.8s;GET /tag/batch(另一个supplyAsync(supplier)做批量查库):P99 从 35ms 跳到 2.1s。
三个接口没有任何代码路径共享,但 P99 同时飙——这个信号是关键:它们共享的不是代码,是线程池。CPU 35%、GC 正常(G1 停顿 <20ms)、HikariCP active 稳定在 20/64、下游自身 P99 就是 200ms 没变——所有「单点」指标都正常,只有「全进程 parallelStream 集体变慢」这一条异常。
二、原理:commonPool 的 worker 数为什么是 7,以及为什么阻塞调用会把它吃光
2.1 commonPool 的 worker 数公式
ForkJoinPool.commonPool() 是 JVM 全局单例,构造时:
int p = (p = Runtime.getRuntime().availableProcessors()) > 1 ? p - 1 : 1;
默认 worker 数 = availableProcessors() - 1。8 核容器 = 7 个 worker。这个减 1 的设计初衷是「留一个核给系统 / 其他线程」,对 CPU-bound 任务是合理的——但对阻塞调用完全不合理:ForkJoinPool 的补偿机制(ForkJoinTask 的 compensation 计数)只对 ForkJoinPool.managedBlock 显式声明的阻塞生效,业务代码里的 grpcStub.invoke()、jdbc.query()、synchronized 都不会触发补偿,worker 就那样干挂着。
2.2 为什么「一个慢调用」能拖死「全进程」
关键在 commonPool 是进程级单例,且 parallelStream() 的默认执行器就是它:
// java.util.stream 内部
Spliterator spliterator = source.spliterator();
// 底层最终走 ForkJoinPool.commonPool().submit(...)
所以只要任何一个业务路径把 supplier 里的阻塞调用扔进 commonPool,7 个 worker 就会被 7 个「正在等下游」的任务占满。此时:
- 新的
supplyAsync任务进 commonPool 的 work queue,排队; parallelStream的ForkJoinTask也进同一个 queue,排队;- 只有 7 个 worker 都空闲后才能执行——而每个 worker 的「空闲」要等它手头的下游 RPC 返回。
排队时间 ≈ 上游已堆积任务数 / worker 数 × 单任务阻塞时长。按 Little 定律,QPS 1500 × 下游 P99 0.2s = 300 个并发阻塞调用,7 个 worker 分摊 → 平均排队 ≈ 300/7 × 0.2s ≈ 8.6s。我们实测 P99 2.4s 偏低,是因为只有 ~30% 的请求走 supplyAsync 路径(另外 70% 走 Tomcat 线程池 + 同步调用),实际排队 ≈ 90/7 × 0.2s ≈ 2.6s,对得上。
2.3 为什么 CPU 只有 35%
worker 在等下游 RPC 时是park 状态(LockSupport.park),不烧 CPU。7 个 worker 全 park 时,CPU 自然低。「CPU 不高 + 全进程 parallelStream 变慢」是 commonPool 阻塞调用的指纹,和 GC 毛刺的「CPU 升高 + P99 单独飙」完全不同。
三、具体做法:定位 + 修复 + 兜底
3.1 30 秒定位:jstack 看 commonPool worker 的栈顶
# 找到 JVM pid
jps -l | grep order-service
# 抓 2 次栈,间隔 5s
jstack <pid> > /tmp/jstack.1
sleep 5
jstack <pid> > /tmp/jstack.2
# 过滤 commonPool worker
grep -A 20 "ForkJoinPool.commonPool-worker" /tmp/jstack.1
指纹:7 个 ForkJoinPool.commonPool-worker-N 线程,栈顶全是 sun.misc.Unsafe.park,再往上是 io.grpc.internal.ClientCallImpl$ClientStreamListenerImpl(或 com.mysql.cj.jdbc、java.util.concurrent.locks)——7 个 worker 全部卡在阻塞调用上,没有一个是 ForkJoinTask 在跑。对比 Tomcat-Executor 线程栈,业务代码正常,说明问题不在业务层。
3.2 短期修复:显式传独立 executor(最小改动)
// ❌ 错误:默认 commonPool,7 个 worker 被阻塞调用占满
CompletableFuture<RiskResult> f = CompletableFuture.supplyAsync(() ->
riskStub.evaluate(req));
// ✅ 正确:显式传独立池,线程数按 Little 定律
private static final ExecutorService RISK_EXECUTOR = new ThreadPoolExecutor(
32, 128, // core/max:QPS 1500 × P99 0.2s = 300 并发,取 128 留 2 倍余量
60L, TimeUnit.SECONDS,
new SynchronousQueue<>(), // 不缓冲,满了直接 reject → 快速失败
new ThreadFactoryBuilder().setNameFormat("risk-async-%d").build(),
new ThreadPoolExecutor.AbortPolicy() // 不要 CallerRunsPolicy!会把 Tomcat 线程也拖进阻塞
);
CompletableFuture<RiskResult> f = CompletableFuture.supplyAsync(
() -> riskStub.evaluate(req), RISK_EXECUTOR);
为什么 SynchronousQueue + AbortPolicy 而不是 LinkedBlockingQueue + CallerRunsPolicy:
LinkedBlockingQueue会无限缓冲,背压失效,请求在队列里排队 → P99 继续飙;CallerRunsPolicy让 Tomcat 线程自己跑 supplier → Tomcat 线程也被阻塞调用占住,雪崩面从 7 个 worker 扩大到 400 个 Tomcat 线程,比 commonPool 问题更严重。
3.3 中期修复:把下游 RPC 换成异步客户端,让 worker 不阻塞
gRPC 天然支持 future:
// 用 gRPC 的 ListenableFuture,不占线程
ListenableFuture<RiskResult> f = riskStub.withDeadlineAfter(500, MILLISECONDS)
.evaluateAsync(req);
// 用 Guava 的 Futures 组合,不阻塞 worker
return Futures.toCompletableFuture(f);
此时 supplier 里没有任何阻塞调用,7 个 worker 也能扛住 300 并发(每个 worker 同时挂多个 future,靠事件回调驱动)。
3.4 兜底:orTimeout + 降级
无论怎么修,阻塞调用必须有超时:
CompletableFuture<RiskResult> f = CompletableFuture
.supplyAsync(() -> riskStub.evaluate(req), RISK_EXECUTOR)
.orTimeout(500, TimeUnit.MILLISECONDS) // 兜底 500ms
.exceptionally(ex -> RiskResult.defaultPass()); // 降级
没有 orTimeout 时,一个下游 hang 住的请求会永久占住一个 worker,7 个 worker 被 7 个 hang 请求占满后,全进程 commonPool 任务全部卡死——这是比「200ms 慢调用」更严重的故障模式。
四、四个反直觉的坑
坑 1:parallelStream() 也走 commonPool,一个 RPC 慢调用能「传染」到全进程
这是最反直觉的:list.parallelStream() 和 CompletableFuture.supplyAsync 共用同一个 commonPool。一个业务路径的阻塞调用,能让完全不相关的 parallelStream 接口一起变慢。我们的 /report/export 接口代码里没有任何 RPC,但 P99 从 60ms 跳到 1.8s——因为它和 /order/summary 共享 commonPool 的 7 个 worker。排查时不能只看「出问题的接口」的代码,要看全进程所有走 commonPool 的路径。
坑 2:换虚拟线程救不了
看到「worker 被阻塞占满」,第一反应是「换虚拟线程,M:N 调度能扛阻塞」。但 commonPool 底层还是 ForkJoinPool,worker 还是 7 个;supplier 里如果有 synchronized 块,虚拟线程还会被 pin(参考 林星河-181 的复盘:「carrier 池默认只有 CPU 核数那么多,一旦大量虚拟线程同时被钉,整个进程的并发就退化回物理线程数的天花板」——commonPool 的 7 个 worker 就是那个「物理线程数天花板」)。真正的解法是「让 supplier 不阻塞」(异步客户端),而不是「让阻塞更便宜」(虚拟线程)。
坑 3:-Djava.util.concurrent.ForkJoinPool.common.parallelism 是全局开关,副作用大
JDK 允许启动参数 -Djava.util.concurrent.ForkJoinPool.common.parallelism=64 把 commonPool 扩到 64 个 worker。不建议用:
- 全局开关,影响所有 ForkJoinPool 任务(包括 JDK 内部的
Arrays.parallelSort、IntStream.parallel等),线程数暴增 → 上下文切换开销、内存占用(每线程 1MB 栈)都会涨; - 8C 容器开 64 个线程,CPU 争抢会反过来拖慢 CPU-bound 任务;
- 治标不治本:下游 hang 住的请求还是能占满 64 个 worker,只是把故障从「7 个 worker 占满」推迟到「64 个 worker 占满」。
坑 4:ForkJoinPool.managedBlock 是「正确姿势」,但业务代码几乎不会用
ForkJoinPool 设计了 managedBlock 机制:阻塞前调用 ForkJoinPool.managedBlock(action),worker 会自动补偿(多 spawn 一个 worker 顶上)。gRPC、Netty 的某些版本在 synchronized 块里用了这个机制,但绝大多数业务代码不会主动调用——grpcStub.invoke() 内部是 NIO 事件驱动,不会触发 managedBlock;jdbc.query() 是 socket 阻塞,更不会。所以「用 ForkJoinPool 跑阻塞调用」本身就是反模式,正确做法是「阻塞调用放独立 ThreadPoolExecutor,ForkJoinPool 只跑 CPU-bound 任务」。
五、结论与可复用清单
一句话:CompletableFuture.supplyAsync 不传 executor 时默认 commonPool(worker 数 = availableProcessors() - 1),阻塞调用会把它吃光,且全进程所有 parallelStream 一起遭殃——指纹是「CPU 不高 + 全进程 parallelStream 集体 P99 飙 + jstack 里 commonPool worker 栈顶全是 park」。
可复用清单:
- 代码规约:
CompletableFuture.supplyAsync / runAsync必须显式传 executor,禁止裸调用(Code Review 检查项,IDEA 可配 Inspection); - 线程池参数:独立池按 Little 定律
QPS × P99 × 1.5算 max 线程数,SynchronousQueue+AbortPolicy(快速失败),不用CallerRunsPolicy; - 兜底:所有阻塞调用必须
orTimeout(500ms)+ 降级,防止 worker 被 hang 请求永久占住; - 监控:Prometheus 加
jvm_forkjoinpool_commonpool_queued_tasks(JDK 21 有jdk.ForkJoinPoolMBean)+jvm_forkjoinpool_commonpool_active_threads,active 持续等于 worker 数(7)+ queued > 0 即告警; - 排查 SOP:P99 飙 + CPU 不高 + 全进程 parallelStream 一起慢 →
jstack看ForkJoinPool.commonPool-worker-N栈顶是否 park → 是则 commonPool 被阻塞调用占满,按 3.2/3.3 修。
一句话给 Code Review:看到 CompletableFuture.supplyAsync(() -> rpc...) 没有第二个参数,直接打回。