毫无征兆的线程池打满
新上线的网关用 JDK 21 虚拟线程处理请求。压测到 8000 并发时,吞吐量突然从 1.2 万 req/s 跌到谷底,响应从 30ms 飙到 8 秒,CPU 却只有 40%——典型的"线程都在等,CPU 没事干"。jstack 一看,200 个平台线程(carrier)全卡在 monitor 上 BLOCKED,而虚拟线程堆了 4 万个在等锁。这是载体线程被 pin 住的典型现场,也是很多人第一次上虚拟线程必踩的坑。
排查过程
第一反应是虚拟线程没生效,但线程名明明是 VirtualThread 开头。怀疑 pin,开 JDK 21 提供的诊断开关:
java -Djdk.tracePinnedThreads=full -jar gateway.jar
日志立刻吐出栈,钉死的位置一览无余:
Thread[#142,ForkJoinPool-1,5,Carrier]:
- locked <0x0000000702a1c0> (a com.zixin.gw.RateLimiter)
- locked <0x0000000702a1d8> (a java.lang.Class)
at com.zixin.gw.RateLimiter.tryAcquire(RateLimiter.java:47)
at com.zixin.gw.AuthFilter.doFilter(AuthFilter.java:12) <== monitors 2
注意最后那行 monitors 2——说明这段被两层 synchronized 包住,虚拟线程无法在这期间卸载到别的载体线程,整段执行期都被"钉"死。当时压测曲线是阶梯上升的:2000 并发时还正常,到 5000 并发开始抖动,8000 直接崩,因为载体被钉的比例随并发上升。
根因:synchronized 的 pin 机制
虚拟线程的调度靠 ForkJoinPool 的平台线程当载体(carrier)。当虚拟线程进入 synchronized 块或执行类初始化锁(Class 锁)时,会钉在当前载体上,整个块执行期间载体不能去跑别的虚拟线程。我们的限流逻辑写在 synchronized 方法里,平均持有 4ms,但高并发下 200 个载体全被钉死,4 万个虚拟线程干等。我后来在测试环境单测验证:把这段代码单独跑,4ms 的 synchronized 在 8000 并发下能让 200 载体全部利用率 100% 且零吞吐。
更准确地说,JDK 21 的 pin 只发生在 synchronized 和 Class 初始化锁上。换成 java.util.concurrent 的锁就不会 pin,因为 ReentrantLock 的等待是 parking,虚拟线程可以在 park 时卸载,把载体让给别的虚拟线程跑。这一点是去年迁移虚拟线程时文档讲得最多的坑,但落到自己代码上还是容易忘,尤其是第三方库和老代码里的 synchronized。
解决方案:把 synchronized 换成 ReentrantLock
限流那段改成:
private final ReentrantLock lock = new ReentrantLock();
public boolean tryAcquire() {
lock.lock();
try {
// 计数逻辑,平均 4ms
return counter.get() < limit;
} finally {
lock.unlock();
}
}
改完重压:8000 并发下 P99 回到 42ms,载体线程利用率从 100% 卡死降到平稳的 70%,虚拟线程可以正常在载体间迁移。我们还顺手把另一处读写计数用 StampedLock 替代——写少读多,读不阻塞,那一段的争用几乎消失,P99 又降了 5ms。
顺带发现的第二个 pin 源
tracePinnedThreads 还报了一处 Class 锁:某个工具类在静态方法里初始化配置,触发了类初始化锁的 pin。虽然只发生在启动后第一次调用,但高并发冷启动时也卡了一批虚拟线程,导致冷启动前 30 秒吞吐异常。解决办法是把配置初始化提前到应用启动时做一次,避免首次请求时触发类加载锁。这个改动单独给冷启动吞吐提升了约 18%。
监控上怎么提前发现
事后我在 Grafana 加了两个看板:一是载体线程(ForkJoinPool 的 carrier)的 CPU 利用率,正常应随负载平滑上升;二是虚拟线程被 pin 的次数,靠 JFR 的 jdk.VirtualThreadPinned 事件统计。一旦 carrier CPU 接近 100% 但业务吞吐没涨,基本就是 pin。上线这个监控后,另一个服务在灰度阶段就提前暴露了类似的 synchronized 问题,没等到压测崩才被发现。
还有几个细节
- 不要在线程池里用虚拟线程:Executors.newVirtualThreadPerTaskExecutor() 才是正道,别套 ThreadPoolExecutor,否则失去调度意义;
- pin 诊断开关有开销,只在排查时开,别留在生产;
- JDK 21 里 synchronized 的 pin 仍不可避免,Class 锁的钉要等到后续版本才逐步放开,迁移前务必用 tracePinnedThreads 扫一遍全路径;
- 不要用 Thread.sleep 在 synchronized 里,那是把 pin 时间放大的最坏写法。
写在后面
现在回头看,《一次虚拟线程导致的载体线程耗尽问题》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。