虚拟线程上线后吞吐反而跌了?一次 pinning 性能回退的完整排查与 JDK 24 修复实测

2026-09-19 0 583

先把事情经过说清楚。

手上有一个做第三方数据同步的服务,逻辑很直白:接收一批任务 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.sleepLockSupport.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,那依然是它自己占着载体线程,只是不再阻塞别的虚拟线程了——并发度还是上不去。所以严格来说,改法二只是缓解,改法一才是根治。

另外提醒一句,ReentrantLocksynchronized 之后要小心忘记 unlockfinally 块一定要写对。这个道理谁都懂,但线上事故还是不少。

改法三:升级到 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,这种事不常有。

虚拟线程上线后吞吐反而跌了?一次 pinning 性能回退的完整排查与 JDK 24 修复实测
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java 虚拟线程上线后吞吐反而跌了?一次 pinning 性能回退的完整排查与 JDK 24 修复实测 https://www.taomawang.com/server/java/2785.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务