登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java 21 虚拟线程为什么越开越慢:从 synchronized pinning 到 JFR 定位

来源:17golang原创

时间:2026-08-09 02:35:24 343浏览 收藏

压测 Java 21 写的HTTP服务时,请求从平台线程切到虚拟线程运行,初期压测曲线表现正常;并发从500逐步拉到5000,吞吐反而开始下跌,此时CPU没跑满,总线程数也没出现异常暴涨。统计慢请求的共性,会发现这些请求全都卡在同一个 synchronized 方法里,等待外部I/O返回。

实践要点
  • Java 21 中,虚拟线程在 synchronized 保护区内执行阻塞I/O操作,可能直接pin住承载它的平台线程。
  • 先用 jdk.VirtualThreadPinned-Djdk.tracePinnedThreads=full 定位到具体问题调用栈,再评估要不要调整锁逻辑。
  • 代码量小、触发频率低的内存临界区完全不用动;调用频繁且内部可能触发网络、磁盘等待的临界区,优先评估 ReentrantLock 改造方案。
  • JDK 24 通过 JEP 491 优化了 synchronized 对应的虚拟线程运行行为,但native方法或者外部函数调用导致的pinning问题,仍然需要单独排查。

吞吐下降不是因为虚拟线程“开太多”

线上排查最容易踩的误区,就是看到虚拟线程数量很高,先直接把并发上限调小。这么做只是临时降低了服务压力,根本不能证明虚拟线程本身存在缺陷。Java 21 虚拟线程的运行机制是把轻量虚拟线程映射到操作系统平台线程上跑;遇到支持自动挂起的阻塞操作时,虚拟线程会主动让出绑定的平台线程,调度器可以把其他待运行的虚拟线程调度到这个空出来的平台线程上继续执行。

但在 synchronized 方法或者同步代码块内部发生长时间阻塞时,虚拟线程没法正常释放绑定的平台线程,这个被占用的承载线程就会彻底卡死。这就是常说的pinning现象。此时从外部看平台线程池资源根本没耗尽,但真正能用来调度虚拟线程的可用carrier线程,全都被卡在等远端接口响应的位置。

时间线:一个锁把全链路请求拖进了等待链

这次问题的最小复现代码并不复杂,就是一段给本地缓存刷新加锁的逻辑:

final class ProfileCache {
    private final HttpClient client = HttpClient.newHttpClient();

    synchronized String refresh(String userId) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://profile.example.test/users/" + userId))
            .build();
        return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
    }
}

请求进入 refresh 之后,整个等待链路非常清晰:线程先拿到对象监视器,接下来触发了网络I/O等待;后面进来的其他虚拟线程连监视器入口都挤不进去。反映到线上监控里,往往会出现接口平均耗时看起来正常,但P99延迟跟着并发量上涨突然陡升。

Java 21 虚拟线程在 synchronized 内等待网络响应,平台承载线程被 pin 住并形成等待链的时间线
观察到的现象更可能的解释优先排查方向
CPU占用不高,P99延迟持续上涨carrier线程被阻塞,或者锁竞争持续加剧JFR 里的 VirtualThreadPinned 事件
总线程数很多,但吞吐完全不上涨大量请求同时卡在同一个临界区里锁持有总时长、对应操作的调用栈
偶发长尾请求,重试之后直接恢复正常慢I/O把锁的持有时间成倍放大外部依赖耗时、锁范围内的所有操作

触发条件:synchronized 作用域里同时出现锁占用和长等待

完全没必要把所有 synchronized 都当成性能问题来改。只要临界区里只做几个简单的内存字段更新,执行速度极快,pinning持续的时间短到不可能形成任何明显的性能影响。真正会出问题的是两个条件同时叠加:对应方法的调用频率非常高,并且锁的保护范围里包了网络请求、文件读写、数据库操作或者native方法调用。

我们可以先把锁范围内的代码按操作类型拆分。Map 查找、状态位更新和计数器递增这类操作属于短耗时操作;HTTP 请求、Files.readString、JDBC 查询和第三方外部SDK调用都要归到可能产生长等待的操作里。先别急着改JVM配置,先确认阻塞操作真的发生在持有锁的时间段里。

JFR 怎么把 pinning 从主观猜测变成实锤证据

启动测试服务的时候先开一段短时录制,只要复现30秒左右的压测流量就足够拿到足够的样本:

java \
  -XX:StartFlightRecording=filename=vt-pinning.jfr,dumponexit=true \
  -Djdk.tracePinnedThreads=full \
  -jar profile-service.jar

录制结束后过滤出所有和虚拟线程相关的事件:

jfr print --events jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed vt-pinning.jfr

jdk.VirtualThreadPinned 默认筛选出耗时超过20ms的pinning事件。输出结果里优先看虚拟线程的调用栈、对应的承载线程编号,还有被阻塞的具体操作;不要抓到单个异常事件就下结论,还要对应上同时间段的外部依赖耗时指标和锁竞争监控数据交叉验证。

如果暂时没法生成完整JFR录制文件,开启指定参数之后,-Djdk.tracePinnedThreads=full 会在pinning事件发生时把完整调用栈直接打印到标准错误流里。这个方案适合本地复现问题和短时间压测,不建议在生产环境长期开启全量打印。

JFR jdk.VirtualThreadPinned 事件把 Java 21 虚拟线程、carrier 和锁内阻塞调用栈串起来的证据面板

修复动作:把等待逻辑移出锁范围,只替换真正有问题的锁

最稳妥的第一步优化,是把远端请求逻辑直接移出临界区,锁范围内只做极短时间的内存状态交换:

final class ProfileCache {
    private final ReentrantLock lock = new ReentrantLock();
    private volatile String value;

    String refresh(String userId) throws Exception {
        String fresh = loadRemoteProfile(userId); // 不占用共享锁等待网络
        lock.lock();
        try {
            value = fresh;
            return value;
        } finally {
            lock.unlock();
        }
    }
}

如果业务逻辑必须保证“同一个用户同一时间只能发起一次缓存刷新请求”,可以把锁的范围缩小到状态检查和状态变更这两步,把网络请求的结果暂时放到独立的刷新状态对象里暂存。直接把 synchronized 全改成 ReentrantLock 不是万能方案:它只是能让虚拟线程在等待锁的过程中主动卸载让出平台线程,没法让一个本身就很慢的阻塞native方法凭空变快。

不要混淆 JDK 21 和 JDK 24 的虚拟线程行为边界

Java 21 官方文档明确把 synchronized 代码块内部的长时间阻塞列为需要重点关注的pinning场景,并且给出建议:确认存在频繁、长时间的pinning问题时,可以考虑换成 ReentrantLock 实现。JEP 491 在JDK 24正式交付之后,虚拟线程阻塞在synchronized相关路径的可扩展性有明显改善,同一套压测用例在升级之后得到的结果可能完全不同。

这不代表我们可以直接删掉所有相关监控。JFR排查的习惯仍然值得保留;native方法调用、外部函数调用、过长的锁持有时间,还有下游依赖本身的延迟波动,仍然有可能制造出长尾请求。两次验证要使用完全相同的并发参数、完全相同的远端响应延迟和完全同一套JFR事件采集规则,测出来的结果才有可比性。

上线前的防复发检查清单

  • 把所有可能访问网络、磁盘、数据库或者native方法的调用,从 synchronized 类型的临界区里全部标记出来。
  • 用固定并发数和固定的下游依赖延迟做压测,对比改造前后的P95、P99延迟,锁等待总时长和carrier线程使用率。
  • 留存一份短时JFR录制文件,检查所有 jdk.VirtualThreadPinned 事件是不是集中出现在同一个方法里。
  • 升级JDK大版本之后必须重新做一遍验证,不要直接把Java 21下得到的结论直接套用到JDK 24及以上版本。

相关问题

所有 synchronized 都要替换成 ReentrantLock 吗?

不需要。短小、低频、只操作内存的临界区,完全没必要为了理论上存在的pinning概率去改写代码;优先处理调用频率高、内部包含阻塞操作的路径就可以。

VirtualThreadPinned 事件超过 20ms 就一定是故障吗?

不一定。它只是用来定位问题的线索,不能单独作为故障判定标准。要结合请求尾延迟、调用频率、锁竞争情况和外部依赖耗时几个维度综合判断,才知道它会不会真的影响服务吞吐。

JDK 24 之后还需要检查 pinning 吗?

需要。JEP 491只是优化了synchronized相关的pinning场景,native方法调用或者外部函数调用导致的其他pinning场景仍然存在,升级之后还是要做一次相同条件下的压测验证。

把一次慢请求还原成可验证的完整等待链

虚拟线程的价值,是用更轻量的方式承载大量等待型请求,不是用来绕开锁、网络和native调用本身的固有成本。排查问题的时候先理清楚三个点:是哪个虚拟线程被pin了、它占住了哪个承载线程、阻塞操作是不是真的处在高频率调用的临界区里。把JFR采集到的调用栈、锁的范围边界和下游依赖耗时三个信息串起来,修复方案才不会只停留在“把线程池大小调大”的表面操作上。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>