420请求秒杀一切:你的Loom应用在CPU 9%时精准卡死,还敢说虚拟线程没毛病?
Java 21的虚拟线程本应撑起百万并发,但一段看似无害的同步代码就能把吞吐量钉死在CPU核心数上。这场性能灾难的元凶叫Pinning,它能让你的应用在无报错、无警告、CPU空闲时彻底罢工。本文用一场420 RPS的故障现场,拆解Pinning的两大触发机制、JDK 24的颠覆性修复,以及一套无须升级JVM也能自救的硬核排查方案。
同步锁成罪魁祸首
那个让我跪服Pinning的夜晚,场景干净得不像话。服务刚迁移到虚拟线程,压测曲线跑到420请求每秒就拉成一根直线,CPU占用才9%,日志干净得像新装的系统。机器是8核,下游一个HTTP调用耗时19毫秒。小学数学算一下:8核乘以每秒能处理的请求数,1000毫秒除以19毫秒约等于52.6,再乘以8,正好是421。这个本该扛住百万虚拟线程的服务,此刻精准地每核心只处理一个请求。Loom悄悄把我们的并发大杀器换回了一个绑死在线程池上的老古董,而代码看起来毫无破绽。这个故障模式有个响亮的名字——固定,更通俗点叫Pinning。
虚拟线程的牛逼之处在于它不霸占操作系统线程。它跑在一小撮平台线程上,这些线程叫载体的线程,背后是一个专属的ForkJoinPool,线程组名字土得掉渣叫CarrierThreads,默认并发数等于你机器的CPU核心数。当虚拟线程遇到阻塞操作——比如等IO、等锁、等队列——正常情况下它会卸载:把当前栈帧存好,从载体线程上跳下来,把载体让给别的虚拟线程跑。这套卸载机制就是Loom用几个操作系统线程撑起百万虚拟线程的全部魔法。
固定就是这套卸载机制失效了。虚拟线程堵在那里但死活不下车,它的载体线程就傻等着,啥也干不了,全程陪绑。一个固定载体可以忽略不计,但默认载体池就那么大,跟你CPU核心数一样多,所以一旦热点路径频繁固定,你会一口气固定所有载体——然后整个系统的虚拟线程全卡死,谁都动不了。这还不是单纯的变慢,这叫调度器饥饿,从外面看跟死锁长得一模一样。你可以用-Djdk.virtualThreadScheduler.parallelism=N把载体池调大,但这只是把饿死的时刻往后推,解决不了根本问题。
两大元凶浮出水面
JVM在两种情况下打死都不卸载阻塞的虚拟线程。
第一种,在synchronized里面阻塞(JDK 21到23)。 从JDK 21一直到23,对象监视器是绑在进入它的载体线程身上的。如果虚拟线程在持有监视器的时候阻塞了——或者调用了Object.wait()——JVM没法把它挪走,因为一挪监视器所有权就乱套了,所以只能固定。这是真实代码里最常见的祸根,因为在一个synchronized方法里埋一个阻塞调用太容易写了,而且调用方根本看不出来。而且这个监视器不一定是你自己写的:库里面的synchronized,或者JDK内部的,固定效果一模一样。ConcurrentHashMap.computeIfAbsent会在内部桶锁下执行你的映射函数——往里面塞一个阻塞调用,你一行synchronized关键字都没写,照样固定一个载体。
第二种,本地方法栈帧。 当虚拟线程的调用栈上挂着本地方法或外部下调用,并且它阻塞了,JVM没法捕获和恢复本地栈帧,所以只能固定。这口锅跟synchronized没关系——而且JDK 24也修不了它。它还藏在一个谁都想不到的地方:类初始化走的是本地栈帧,所以静态初始化器里的阻塞调用,在最新的JDK上照样固定。
更要命的是看明白什么不在这个列表里:JDK提供的普通阻塞IO、BlockingQueue、ReentrantLock、CompletableFuture、Thread.sleep()——所有这些都为了Loom重新铺过管道,能干净地卸载。固定的原因就那么短一个列表,这恰恰说明它可检测。
教科书级故障现场
我读过的几乎所有固定现场转储,都是缓存或限流器用synchronized护着一个慢调用:
java
public class PriceService {
private final Map cache = new HashMap<>();
// 看着人畜无害。在JDK 21-23上,整个HTTP调用期间都会固定载体。
public synchronized BigDecimal lookup(String symbol) {
return cache.computeIfAbsent(symbol,
s -> httpClient.quote(s)); // <-- 持着监视器阻塞
}
}
每次缓存未命中都会在持有监视器的情况下去网络上堵着。在JDK 21到23上,这个虚拟线程在整个往返过程中死死焊在它的载体上。跑几百个并发请求,你就把每个载体都固定了;剩下的工作负载全堵在一个永远不卸载的监视器后面排队。我那晚420请求每秒的惨案,五行代码就说清楚了。
JDK 24砸了重锤
JDK 24发布了JEP 491,标题就叫“不固定的虚拟线程同步”。它重写了监视器所有权的实现,让监视器跟虚拟线程本身绑定,而不是跟载体线程绑定——这意味着虚拟线程现在可以在synchronized内部阻塞时、在等待进入监视器时、或者在Object.wait()里停着时,干净地卸载。最常见的固定原因在JDK 24及以上版本直接消失,一行代码都不用改。
两个实际影响:
- 在JDK 24及以上,仅存的固定来自本地栈帧——JNI、FFM下调用,还有类初始化。
- 老式检测开关-Djdk.tracePinnedThreads在JDK 24里被删了。别把依赖它的运维手册带到新版本。
如果你还在JDK 21到23上,synchronized固定依然生猛,升级往往就是最干净的修复方案。
抓现行的方法
JDK 21到23——用遗产开关。 跑起来加这个参数:
bash
java -Djdk.tracePinnedThreads=full -jar app.jar
JVM每次虚拟线程固定时都会打印栈轨迹,带<== monitors:1注释的那一帧就是元凶。这一行就是全部诊断。记住这个开关在JDK 24上已经不存在了。
全版本通用——JFR事件。 从JDK 21开始,JVM在虚拟线程阻塞且被固定时会发射jdk.VirtualThreadPinned飞行记录器事件。默认是开启的——但带了一个20毫秒阈值,所以短时间的固定你看不见,除非把阈值调低。在JDK 24里这个事件升级了:每次固定都会发射,而且携带了固定原因和载体身份。既然本地栈帧固定照样会触发它,这应该是你接到生产环境里的检测手段:
bash
java -XX:StartFlightRecording=filename=rec.jfr,settings=profile -jar app.jar
jfr print --events jdk.VirtualThreadPinned rec.jfr
从线程转储里看。 普通的jstack根本看不到虚拟线程。用支持虚拟线程的转储命令:
bash
jcmd Thread.dump_to_file -format=json dump.json
它会列出载体线程以及每个载体上挂载的虚拟线程。一个属于CarrierThreads组的载体,如果它自己被阻塞了,而挂载的虚拟线程卡在synchronized栈帧里,这就是固定的视觉特征。数一数有多少载体呈现这个状态,对比你的池子大小——这个比例告诉你离全盘饥饿还有多远。
动手修,三条路
1. 把synchronized换成ReentrantLock。 java.util.concurrent.locks.ReentrantLock是懂Loom的:虚拟线程在它上面阻塞,或者在持有它的时候阻塞,都能干净卸载。这是直接、不挑JDK版本的修复方案。
2. 升级到JDK 24及以上。 JEP 491把synchronized固定整锅端了。本地栈帧固定依然存在。
3. 别在跨外部调用时抱着锁不放。 更诚实的修复是结构性调整:在临界区外面把值算好,只锁住Map的更新操作。
看下面这个重写版本。有个坑要躲:别只是把阻塞调用挪进ConcurrentHashMap.computeIfAbsent——前面说过,它的映射函数跑在内部桶锁下面,在JDK 21到23上你等于把同一个固定点往深层挪了一层。
java
public class PriceService {
private final Map cache = new ConcurrentHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public BigDecimal lookup(String symbol) {
BigDecimal cached = cache.get(symbol);
if (cached != null) return cached;
lock.lock(); // 懂Loom:阻塞就卸载
try {
cached = cache.get(symbol); // 持锁再查一次
if (cached != null) return cached;
BigDecimal quote = httpClient.quote(symbol); // 阻塞了;载体被释放
cache.put(symbol, quote);
return quote;
} finally {
lock.unlock();
}
}
}
这段代码现在哪儿都不固定了——不过它依然用一把锁把缓存未命中串行化了,这是第三条路的范畴:下一步优化是连网络调用都不持锁。
手工看烦了,我写了工具
手工做这套分析——开开关、复现、导转储、找载体、对栈帧——搞一次还行。搞到第十次就烦透了,更糟的是,一半工具链依赖你在出问题之前就记得把开关打开。所以我写了个工具,给它任何线程转储它都帮你读:找出载体,检查每个上面挂载了什么,标出固定的那些并指出肇事栈帧,然后报出固定载体数比池子大小——这个数字告诉你离饿死还差几步。工具叫ThreadMine,网页版分析器免费,不用注册就能传转储。坦白说这是我自己的项目——手工读转储读到吐,就把重复的那部分自动化了。
也得说句公道话:转储是快照,所以对于间歇性固定,JFR依然是更好的信号。
总结:
固定是Loom唯一一个能让你扩展性故事全线崩塌、但日志里连一个错误都没有的故障模式。规则很短:只有synchronized(JDK 24之前)和本地栈帧会固定;检测用jdk.tracePinnedThreads(21到23)和jdk.VirtualThreadPinned JFR事件(全版本);修复用ReentrantLock、升级JDK,或者别在慢调用期间持有锁。认清这个形状,它就不再隐身了。
固定是Loom留给你的唯一暗雷——无报错、无日志、CPU空闲,但你的并发神话当场破功。记住两个原因、一套JFR检测、三条修复路径,你就能在它卡死生产之前把它揪出来。
作者单位背景:ThreadMine创始人,JVM性能与线程转储分析专家