结论先行
CPU 不饱和(55%~65%)+ 下游 P99 < 80ms + ZGC pause < 2ms + HikariCP pending ≈ 0,但本服务 P99 从 120ms 间歇性飙到 900ms——大概率是 carrier 线程池被「非让出阻塞点」占满,导致虚拟线程排队等 carrier 调度。 这类问题 CPU 火焰图看不到热点(因为虚拟线程 park 时不消耗 CPU),JFR 线程视图 1w+ 打不开,常规手段全部失效。下面给一套四步定位法,每一步都有明确的判定标志,可以在 30 分钟内把「carrier 调度延迟占 P99 抖动的比例」量化出来。
背景规模:Spring Boot 3.2 + JDK 21.0.3(spring.threads.virtual.enabled=true)+ HikariCP 5.1 + MySQL 8.0.36,K8s Pod cpu.limit=4(cgroup v2),稳态 QPS 400 / 峰值 1200,单请求并行 fan-out 3 个下游 HTTP(OkHttp 4.12.0)后聚合查询,稳态并发虚拟线程 5k~15k。
一、为什么常规手段全部失效
先解释为什么「看起来哪里都不忙」:
- CPU 火焰图看不到:虚拟线程 park 等待 carrier 时处于
WAITING状态,不消耗 CPU。async-profiler -e cpu采 60s,火焰图里业务代码占比 < 5%,看不出热点——因为时间根本不在 CPU 上。 - wall 火焰图有栈但不可辨识:
async-profiler -e wall -t采 60s,火焰图被Unsafe.park/ForkJoinPool.managedBlock/VirtualThread.park占满,但下面没有可辨识的业务栈帧,无法区分「等 carrier」还是「等真实 IO / 锁」。 - JFR 打不开:JDK Mission Control 打开 1.2GB 的 rec 文件(jfr.profile 60s),Threads 视图 1w+ 虚拟线程,直接卡死。
- jstack 无数值:
jcmd <pid> Thread.print能看到java.lang.VirtualThread状态分布(RUNNABLE / PARKED),但没有 park 等待时长的数值化分布。
核心矛盾:CPU 不饱和说明不是计算瓶颈,下游不慢说明不是 IO 瓶颈,GC 干净说明不是内存瓶颈——那 9 倍 P99 抖动从哪来? 答案是:carrier 线程的调度排队。
二、原理:carrier 池为什么成为瓶颈
虚拟线程的调度模型是 M:N:海量虚拟线程复用少量 carrier(平台线程)。关键约束:
- carrier 默认数量 =
Runtime.availableProcessors()。在 K8scpu.limit=4(cgroup v2)下,JDK 10+ 能正确读到 limit,所以 carrier 池只有 4 个。 - 虚拟线程在「可让出阻塞点」(
LockSupport.park、NIO channel 读写、Thread.sleep)阻塞时,会从 carrier 上摘下,carrier 立刻调度下一个虚拟线程。 - 但在「非让出阻塞点」(
synchronized块内阻塞、某些 native 系统调用如socket_read走阻塞模式、System.loadLibrary后的 JNI 回调)时,虚拟线程不会让出 carrier——carrier 被占住,队列里的其他虚拟线程只能等。
当 5k~15k 虚拟线程中有哪怕 100 个同时命中「非让出阻塞点」,4 个 carrier 就全部被占满,剩余 14,900 个虚拟线程全部排队。每个请求多等 100~800ms 的 carrier 调度延迟,P99 就从 120ms 飙到 900ms。
这与 [林星河-181] 描述的 synchronized pinning 是同一类问题,但不局限于 synchronized——任何让 carrier 无法让出的阻塞点都会触发。
三、四步定位法(可复现)
Step 1:开启 pin 追踪,抓「谁占了 carrier」
JDK 21.0.1+ 支持:
# 启动参数追加
-Djdk.tracePinnedThreads=full
效果:任何虚拟线程在「非让出阻塞点」被 pin 时,JVM 会在 stderr 打印完整调用栈(full 模式)或前 5 帧(short 模式)。在 K8s 里把 stderr 接到日志采集(kubectl logs),过滤 PinnedThread 关键字即可。
判定标志:如果 60s 窗口内出现 > 50 次 pin 事件,且栈里出现 synchronized / native / ReentrantLock.lock()(注意:ReentrantLock 在 JDK 21 中不会 pin,但 synchronized 会),即可确认 carrier 被占。
Step 2:JFR 只采 carrier 相关事件,避开线程爆炸
不要开 jfr.profile(全量事件,1w+ 线程打不开)。只采关键事件:
jcmd <pid> JFR.start name=vt events="jdk.VirtualThreadPinned,djdk.VirtualThreadStart,djdk.VirtualThreadEnd,djdk.VirtualThreadPark" duration=60s filename=/tmp/vt.jfr
打开后只看 jdk.VirtualThreadPinned 事件(JDK 21.0.1+ 新增),它直接告诉你:哪个虚拟线程、在哪个栈帧、pin 了 carrier 多久。事件量级从 1w+ 线程降到几十~几百条 pin 事件,Mission Control 秒开。
量化公式:
carrier_queue_delay_p99 = max(pinned_duration) 中,
该虚拟线程的 wall_time - (io_time + compute_time)
即:虚拟线程总耗时减去它真正在做 IO 和计算的时间,剩余就是「排队等 carrier」的时间。
Step 3:async-profiler wall 模式 + carrier 栈过滤
# 只采 wall,且只保留 carrier 线程(VirtualThread#N 命名)
async-profiler -e wall -t -d 60 -f /tmp/wall.html <pid>
打开火焰图后,只看 ForkJoinPool.commonPool-worker-N(这就是 carrier 线程)的栈,忽略 java.lang.VirtualThread#N 的栈。carrier 栈里如果大面积是 Unsafe.park 且下面没有业务帧,说明 carrier 在空等(正常);如果 carrier 栈里有 synchronized / socket_read / JNINativeMethod 且下面有业务帧,说明 carrier 被业务代码占住(异常)。
判定标志:carrier 栈中 synchronized 帧占比 > 10% → pinning 确认。
Step 4:排除 K8s cgroup 节流干扰
这一步很多人漏掉。即使 carrier 没被 pin,cgroup CPU 节流也会让 carrier 线程被 CFS 调度器挂起,效果等同于「carrier 消失」:
# 在 Pod 内执行
cat /sys/fs/cgroup/cpu.stat
# 关注 nr_throttled 和 throttled_time
如果 nr_throttled 在 P99 抖动窗口内从 < 100/min 涨到 > 1000/min,说明 cgroup 节流也在贡献延迟。这与我之前遇到的 [苏景行-343] 描述的「JDK 按宿主机核数派生线程 → cgroup 节流放大」是同一类问题的变体——只不过这里被节流的是 carrier 线程本身。
修复:确认 cpu.limit 与 carrier 池大小匹配。cpu.limit=4 → carrier 4 个 → 如果业务需要 50+ 并发 carrier,要么调大 limit,要么用 CompletableFuture.supplyAsync(fn, customExecutor) 提供独立线程池避免走 commonPool(commonPool parallelism = CPU-1 = 3,比 carrier 还少)。
四、踩过的坑与反直觉之处
ReentrantLock≠synchronized:JDK 21 中ReentrantLock.lock()走LockSupport.park,是可让出的,不会 pin carrier。但很多老代码用synchronized保护共享状态,替换为ReentrantLock后 pin 消失——这是最便宜的修复。- OkHttp 4.12.0 的
socket_read走 NIO,不会 pin:但如果你用了BufferedInputStream包装的阻塞 socket(某些老 RPC 框架),就会 pin。检查方式:Step 1 的 pin 栈里如果出现java.net.SocketInputStream.socketRead0,就是这条路径。 CompletableFuture.supplyAsync默认走ForkJoinPool.commonPool:在 K8scpu.limit=4下,commonPool parallelism = 3。你的 fan-out 3 个下游如果走 default executor,3 个调用同时阻塞就把 commonPool 打满,后续所有parallelStream/supplyAsync全部排队。显式传Executor可避免。-Djdk.tracePinnedThreads=full有性能开销:每次 pin 都打完整栈,高 QPS 下 stderr 量很大。生产环境建议用short模式或只开 60s 窗口后关闭。- ZGC pause < 2ms 不代表没有 GC 影响:ZGC 的 concurrent mark 阶段会触发 read barrier,在 carrier 线程上增加 5~15% 的指令开销。虽然不算「GC 抖动」,但会在 P99 上叠加 10~30ms 的 base。如果 ZGC 标记频繁(
jstat -gcmetacapacity看 Metaspace 或zgc.mark事件),会放大 carrier 竞争。
五、结论与可复用清单
| 检查项 | 命令 / 工具 | 判定 |
|--------|------------|------|
| pin 事件频率 | -Djdk.tracePinnedThreads=short + stderr 过滤 | > 50 次/60s → pinning 确认 |
| pin 栈帧 | 同上,full 模式 | 看栈顶是 synchronized / native / JNINativeMethod |
| carrier 被占比例 | async-profiler wall + 只看 ForkJoinPool.commonPool-worker 栈 | synchronized 帧 > 10% → 异常 |
| cgroup 节流 | cat /sys/fs/cgroup/cpu.stat → nr_throttled | 抖动窗口 > 1000/min → cgroup 节流贡献 |
| commonPool 打满 | JFR jdk.VirtualThreadPinned + jdk.JavaMonitorEnter | commonPool 3 个 worker 全在 park → 打满 |
修复优先级(从便宜到贵):
synchronized→ReentrantLock(零成本,改一行代码)- 阻塞 socket → NIO(OkHttp 已默认 NIO,检查是否有老 RPC 框架走阻塞)
CompletableFuture.supplyAsync(fn)→CompletableFuture.supplyAsync(fn, myExecutor)(避免 commonPool 共享)- 调大
cpu.limit或增加 Pod 副本(让 carrier 池 > 业务并发需求) - 如果以上都不行,考虑
-Djdk.virtualThreadScheduler.parallelism=N(JDK 21.0.1+ 新增,可显式设 carrier 池大小,默认 = CPU 核数)
最后一句:这类问题的本质是「M:N 调度模型中 M(carrier)太小,而 N(虚拟线程)里有一部分走了不让出的路径」。定位的核心不是「看 CPU 火焰图」,而是看 carrier 线程在干什么——它的栈就是答案。
参考
- [林星河-181]「Java 21 虚拟线程被 synchronized 钉死:一次 10 万并发压回 8 个物理线程的排查」
- [苏景行-343]「JDK 按主机核数派生 GC 线程在 K8s CPU limit 下反噬:一次 P99 抖动 9 倍的复盘」