先把事情经过说清楚。
手上有一个做第三方数据同步的服务,逻辑很直白:接收一批任务 ID,对每个 ID 调一次外部 HTTP 接口拉数据,写库,返回。接口平均响应 180 毫秒,属于典型的 IO 密集场景。原来的实现是一个固定大小 32 的 ThreadPoolExecutor,配合一个有界队列,QPS 大概稳定在 170 左右,再往上调线程数效果就不明显了,CPU 也上不去。
虚拟线程出来之后,这个场景看起来就是为它准备的。把线程池去掉,每个任务直接 Thread.ofVirtual().start(),代码改动不超过二十行。压测之前我心里预期是 QPS 翻好几倍,结果跑出来 130,比原来还低,CPU 使用率从 40% 直接飙到 95%,GC 次数倒是没什么变化。
这个结果非常反直觉。虚拟线程没减少 IO 的耗时,但至少不该让吞吐下降。花了两天时间排查,根子出在一个谁都写得出来的 synchronized 上。
一、先搞清楚虚拟线程到底在调度什么
很多人对虚拟线程的理解停留在“更轻量的线程”,这个说法不够准确,容易导致误用。它的准确描述是:由 JDK 自己调度、而不是由操作系统调度的线程。
平台线程(也就是传统的 Thread)是一对一映射到操作系统内核线程的,创建一个就是一个内核线程,成本高,能同时存在的数量被内存和内核参数卡死,通常几千个就到顶了。虚拟线程则是一对多:成千上万个虚拟线程跑在少量平台线程上,这些承载虚拟线程的平台线程叫载体线程(carrier thread)。
载体线程的数量默认等于 CPU 核心数,由一个内部使用的 ForkJoinPool 管理,可以通过系统属性 jdk.virtualThreadScheduler.parallelism 调整。
关键机制在这里:虚拟线程遇到阻塞操作(网络 IO、Thread.sleep、LockSupport.park 等)时,它不会占着载体线程干等,而是把自己挂起,让载体线程腾出来去跑别的虚拟线程。这个动作叫卸载(unmount)。等阻塞结束,虚拟线程被重新安排到一个(可能是另一个)载体线程上继续跑,这叫挂载(mount)。
所以虚拟线程的“便宜”不是凭空来的,它便宜在挂起的时候不占用任何操作系统线程资源。一旦某个虚拟线程在阻塞期间没法被卸载,那它就跟一个平台线程没区别了——载体线程被它占着,其他虚拟线程排队等,虚拟线程的优势瞬间归零。
这个“没法卸载”的状态,就是 pinning(钉住)。
二、什么情况下会被钉住
在 JDK 24 之前,主要有两类原因:
- 虚拟线程在
synchronized块或方法里发生了阻塞。因为对象监视器(monitor)在当时的实现里是绑定在载体线程上的,虚拟线程不能带着锁跑路,只能死等。 - 虚拟线程的调用栈里存在 native frame,比如 JNI 调用、
Foreign Function & Memory API的 downcall。native frame 在栈上是不可分割的,没法被保存和恢复,因此无法卸载。
第二类在业务代码里其实不多见,除非你用了某些依赖 JNI 的库,比如早期的压缩库、加密库、或者某些数据库驱动里的原生部分。绝大多数团队踩的坑都是第一类。
顺带纠正一个常见误解:Thread.sleep() 不会造成 pinning。很多人用 Thread.sleep(1000) 去模拟阻塞来测虚拟线程性能,测出来效果很好,就以为自己的代码没问题。但 sleep 是被虚拟线程调度器明确识别并正确卸载的,它测不出 synchronized 里的真实阻塞。这个坑我踩过,浪费了半天时间。
三、把问题抓出来
JDK 提供了一个专门用来抓 pinning 的诊断开关:
java -Djdk.tracePinnedThreads=full -jar sync-service.jar
或者用 short,输出会简洁一些,只打印钉住的位置和栈顶几帧。这个属性从 JDK 21 就有,一直沿用至今。
打开之后压测,控制台里很快就刷出了一大堆类似这样的堆栈:
Thread[#34,ForkJoinPool-1-worker-5,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:185)
java.base/jdk.internal.misc.InnocuousThread.run(InnocuousThread.java:73)
com.demo.sync.RemoteClient.fetch(RemoteClient.java:41) <== monitors:1
com.demo.sync.SyncService.syncOne(SyncService.java:27)
com.demo.sync.SyncService.lambda$syncBatch$0(SyncService.java:19)
java.base/java.util.concurrent.ThreadPerTaskExecutor$ThreadBoundFuture.run(ThreadPerTaskExecutor.java:352)
java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
注意最后那行 <== monitors:1,这是决定性证据:它说明这个位置持有了一个 monitor 并且发生了阻塞。定位到了 RemoteClient.fetch。
四、问题代码长什么样
打开这个类,问题一目了然:
public class RemoteClient {
private final HttpClient httpClient = HttpClient.newHttpClient();
private final Map<String, String> tokenCache = new HashMap<>();
public synchronized String fetch(String url) throws Exception {
String token = tokenCache.get(url);
if (token == null) {
token = login(url);
tokenCache.put(url, token);
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.GET()
.build();
// 这一行是罪魁祸首:整个网络往返都在 synchronized 里面
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
}
这段代码用 synchronized 修饰整个方法,本意是想让 tokenCache 这个 HashMap 的读写安全。但在 JDK 21 上,httpClient.send 这个网络阻塞发生在持有对象锁期间,虚拟线程无法卸载,只能把载体线程一直占住。
后果就是:不管你有多少虚拟线程,同一时刻真正能跑 fetch 的只有载体线程那么多,也就是 CPU 核心数个并发。核心数是 8,这个上限就是 8。而原来的固定线程池是 32 个并发。所以吞吐从 170 掉到 130 完全不奇怪,甚至还应该更低——多出来的那部分开销,是成千上万个虚拟线程在调度器里反复挂起、排队带来的。
CPU 被打满的原因也很清楚:剩下那些抢不到锁的虚拟线程并没有真的在等,它们在不停地尝试获取 monitor,被挂起、被唤醒、再被挂起,这些调度动作全都要消耗 CPU。
五、三种改法,看情况选
改法一:把临界区缩到最小
最直接也最正确的做法是——锁只保护它该保护的那一小段数据,不要把 IO 包进去:
public class RemoteClient {
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ConcurrentHashMap<String, String> tokenCache = new ConcurrentHashMap<>();
private final Map<String, Object> tokenLocks = new ConcurrentHashMap<>();
private String getToken(String url) throws Exception {
String cached = tokenCache.get(url);
if (cached != null) {
return cached;
}
// 同一个 url 只让一个线程去登录,避免缓存击穿
Object lock = tokenLocks.computeIfAbsent(url, k -> new Object());
synchronized (lock) {
String again = tokenCache.get(url);
if (again != null) {
return again;
}
String fresh = login(url);
tokenCache.put(url, fresh);
return fresh;
}
}
public String fetch(String url) throws Exception {
String token = getToken(url);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.GET()
.build();
// 这里已经不在任何锁里了
HttpResponse<String> response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
}
改动之后,synchronized 保护的只有缓存读写的几纳秒,几乎不可能在里面阻塞,也就不会钉住载体线程。tokenCache 换成 ConcurrentHashMap 之后,连那点锁都可以省掉。这才是推荐姿势:锁的临界区里不要出现任何 IO,这个原则在平台线程时代就成立,只不过在虚拟线程时代,违反它的代价从“锁竞争”变成了“整套并发模型失效”。
改法二:换成 ReentrantLock
如果确实因为某些原因(比如代码结构太复杂一时改不动)必须在锁里做阻塞操作,那就把 synchronized 换成 ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
public String fetch(String url) throws Exception {
lock.lock();
try {
// ... 原来的逻辑
} finally {
lock.unlock();
}
}
原因在于实现机制不同。ReentrantLock 基于 AQS,线程等待时走的是 LockSupport.park,而 LockSupport.park 是虚拟线程调度器明确支持的可卸载点,所以虚拟线程在等锁的时候能正常让出载体线程。
但要注意,这只解决了“等其他线程释放锁”时的钉住问题。如果拿到锁的虚拟线程自己在锁里做 IO,那依然是它自己占着载体线程,只是不再阻塞别的虚拟线程了——并发度还是上不去。所以严格来说,改法二只是缓解,改法一才是根治。
另外提醒一句,ReentrantLock 换 synchronized 之后要小心忘记 unlock,finally 块一定要写对。这个道理谁都懂,但线上事故还是不少。
改法三:升级到 JDK 24
这是最舒服的方案。JDK 24 交付的 JEP 491(Synchronize Virtual Threads without Pinning)从 JVM 层面重写了对象监视器的实现,让虚拟线程在 synchronized 里阻塞时也能正常卸载。
同一个服务、同一份原始代码,只把 JDK 从 21 换成 24,其他什么都不动,压测结果是 QPS 大概 760,CPU 稳定在 70% 上下,-Djdk.tracePinnedThreads=full 的日志里干干净净,一条 pinning 记录都没有。
把几组数据放在一起对比,会更直观:
- 固定线程池(32 线程):QPS 约 170,CPU 约 40%
- 虚拟线程 + JDK 21 + synchronized:QPS 约 130,CPU 约 95%
- 虚拟线程 + JDK 21 + 缩小临界区:QPS 约 780,CPU 约 70%
- 虚拟线程 + JDK 24 + 原始 synchronized:QPS 约 760,CPU 约 70%
可以看到,JDK 24 的修复效果和手工缩小临界区基本持平。这意味着一个新项目如果直接跑在 JDK 24 及以后的版本上,就不用为了虚拟线程去大规模清理存量代码里的 synchronized 了。
六、JDK 24 到底改了什么
简单说一下原理,理解了之后你对边界情况会有更准确的判断。
在旧实现里,Java 对象监视器的持有者被记录在一个和平台线程强绑定的结构上。虚拟线程挂载到某个载体线程上运行时,它实际持有的 monitor 也就“借”在这个载体线程身上。一旦虚拟线程要阻塞,JVM 没法把“借出去”的锁从载体线程上摘走,所以只能不让它卸载。
JEP 491 做的核心改动是把监视器的归属从载体线程改成虚拟线程自己。虚拟线程阻塞时,JVM 会把它的监视器状态保存下来,把载体线程释放掉。等它被重新调度时,再把监示状态恢复回去。对于同步方法、同步块、以及 Object.wait() / notify() 这些都做了处理。
语义上仍然保持了 Java 语言规范要求的互斥和内存可见性,所以正常业务代码不需要做任何改造。少数依赖“锁的持有者一定是当前平台线程”这种假设的底层工具(比如某些用 JVMTI 做分析的 agent)可能需要升级版本。
七、JDK 24 之后还会被钉住的场景
JEP 491 只解决了 synchronized 那一类,还有一个大类它管不了:native frame。
- JNI 调用。调用 native 方法期间,栈上有 native frame,虚拟线程无法卸载。常见于某些压缩、序列化、图像处理、国密算法的本地库里。
- Foreign Function & Memory API 的 downcall,原理上同理。
- 部分类初始化逻辑。静态初始化块
<clinit>在初始化过程中会涉及 JVM 内部的锁,如果初始化逻辑里有耗时阻塞,保守起见还是建议把它挪出去。 - 某些第三方库内部自己实现的“锁 + 阻塞”组合,尤其是那种用
while循环加synchronized自旋的等待队列。
所以即便升级到 JDK 24,也建议在压测环境里开着 -Djdk.tracePinnedThreads=full 跑一轮,确认没有意外。毕竟 native 调用在业务代码里是隐形的,你不翻第三方库的源码根本不知道它有没有用 JNI。
八、用虚拟线程时另外几个别踩的坑
第一,不要池化虚拟线程。
见到过有人写一个“虚拟线程池”,理由是“线程池能复用资源、能限流”。这个思路从根上就错了:虚拟线程的创建成本极低,创建一百万个也就占几百 MB 堆内存,池化带来的收益接近于零,反而会破坏“每个任务一个虚拟线程”这个简单模型,让调度器无法做出最优决策。JDK 自己也明确不建议池化虚拟线程。
第二,ThreadLocal 要小心。
平台线程时代,ThreadLocal 的实例数量等于线程数,最多几百个。现在你开了十万个虚拟线程,每个线程里塞一个 ThreadLocal,那就是十万份数据。如果每份数据里还挂着一个几百 KB 的缓冲数组,内存直接就爆了。所以在虚拟线程场景下,能不用 ThreadLocal 就不用,非用不可的话,要么控制单个值的大小,要么换成 JDK 25 中正式发布的 ScopedValue——它的生命周期被限定在一个作用域内,天然不会随着线程数量膨胀。
第三,别用虚拟线程跑 CPU 密集任务。
虚拟线程的优势来自“阻塞时让出载体线程”。如果你的任务是纯计算,压根不会阻塞,那么把一百万个虚拟线程丢给 8 个载体线程,除了增加上下文切换成本,不会有任何收益。CPU 密集任务用 ForkJoinPool 或者固定大小的平台线程池依然是对的选择。
第四,限流不能用线程池大小来做了。
原来我们经常靠“线程池只有 32 个线程”来隐式限制对下游的并发压力。换成虚拟线程之后这个天然上限没了,几万个请求可能同时打到下游接口上,把人家的服务打挂,自己的超时和重试也会雪崩。正确做法是显式加一层 Semaphore:
private static final Semaphore DOWNSTREAM_LIMIT = new Semaphore(50);
public String fetch(String url) throws Exception {
DOWNSTREAM_LIMIT.acquire();
try {
// 真正的调用
} finally {
DOWNSTREAM_LIMIT.release();
}
}
Semaphore.acquire 是虚拟线程能正确卸载的阻塞点,所以哪怕把许可数设成 10,也不会影响几十万个虚拟线程的调度,只是控制了对下游的瞬时压力。这一点比线程池优雅得多。
顺便说,Semaphore 的公平模式在虚拟线程下有性能损耗,如果不需要严格 FIFO,用默认的非公平模式就好。
九、迁移过程中的检查清单
- 先确认 JDK 版本。21 到 23 需要主动清理
synchronized里的阻塞;24 及以后基本可以不动存量代码,但仍要处理 native 调用。 - 全局搜一遍
synchronized,逐个看临界区里有没有 IO、sleep、外部调用、以及会触发懒加载的方法调用。有的话要么缩小范围,要么换ReentrantLock。 - 搜一下代码里有没有把
synchronized加在静态方法上的,那是锁 Class 对象,持有时间往往比想象中长。 - 检查第三方依赖里的 native 部分。压测时挂上
-Djdk.tracePinnedThreads=full,日志里出现<== monitors:N说明是锁,出现 native frame 相关的帧说明是 JNI,两者处理方式不同。 - 把所有隐式的并发限制显式化。原来依赖线程池大小的地方,换成
Semaphore或者信号量式的限流组件。 - 把
ThreadLocal的使用点列出来评估,尤其是那种缓存了大对象的。 - 压测的时候观察 CPU,而不是只看 QPS。虚拟线程跑得好,CPU 应该是平稳的;如果 CPU 打满而 QPS 上不去,八成就是 pinning 或者自旋等待。
十、最后
这件事给我最大的启发不是“虚拟线程有坑”,而是旧的并发习惯会以意想不到的方式污染新的模型。synchronized 加个整方法,在平台线程时代最多是锁粒度粗了点,性能差一点;到了虚拟线程时代,它直接把你整个并发模型打回原形。同样的道理也适用于线程池、ThreadLocal、以及靠池大小做限流这些沿用多年的惯性写法。
好消息是 JDK 24 把这些坑填掉了大半。如果项目的 JDK 版本还在 21 附近,而且业务是 IO 密集型,那迁移之前先做一次 synchronized 的清理是值得的;如果能直接上到 24 或者更高的 LTS,迁移会轻松非常 多——同一份代码,什么都不改,性能从 130 跳到 760,这种事不常有。

