Administrator
发布于 2023-09-12 / 24137 阅读
455

一次虚拟线程导致的载体线程耗尽问题

毫无征兆的线程池打满

新上线的网关用 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 时间放大的最坏写法。

写在后面

现在回头看,《一次虚拟线程导致的载体线程耗尽问题》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考