记一次线上 CPU 飙升排查:虚拟线程时代的 synchronized 钉住(Pinning)问题

举报
yd_210610071 发表于 2026/10/22 21:16:40 2026/10/22
【摘要】 Java 21 发布后,虚拟线程(Virtual Threads)几乎是所有后端团队都在关注的新特性。几行代码的改动,就能把「一请求一线程」的并发模型重新变成可行方案。我们在服务里灰度了虚拟线程之后,压测数据非常漂亮——直到线上流量高峰到来,CPU 使用率毫无征兆地飙到了 90%。这篇文章完整复盘了这次排查过程,以及一个很容易被忽视的结论:虚拟线程不是银弹,synchronized 在 JD...

Java 21 发布后,虚拟线程(Virtual Threads)几乎是所有后端团队都在关注的新特性。几行代码的改动,就能把「一请求一线程」的并发模型重新变成可行方案。我们在服务里灰度了虚拟线程之后,压测数据非常漂亮——直到线上流量高峰到来,CPU 使用率毫无征兆地飙到了 90%。

这篇文章完整复盘了这次排查过程,以及一个很容易被忽视的结论:虚拟线程不是银弹,synchronized 在 JDK 21 里会把它打回原形。

一、背景:我们是怎么用上虚拟线程的

改动本身非常简单,Tomcat 场景下只需要替换线程池:

// 改造前:平台线程池
ExecutorService executor = Executors.newFixedThreadPool(200);

// 改造后:虚拟线程
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

如果是 Spring Boot 3.2+,一行配置即可:

spring:
  threads:
    virtual:
      enabled: true

压测结果显示,I/O 密集接口(查库 + 调下游 RPC)的吞吐量提升了约 3 倍,P99 延迟明显下降。看起来很美好,于是我们上了灰度。

二、现象:高峰期 CPU 飙升,但线程数并不多

上线第三天,晚高峰监控报警:

  • CPU 使用率从日常 40% 飙到 90%+;
  • 线程总数却比改造前的平台线程池时代更少;
  • top -H 看不到大量 RUNNABLE 的业务线程;
  • GC 日志正常,没有频繁 Full GC。

第一反应是死循环,但 dump 出来的栈里没有任何业务方法在自旋。

三、排查:jstack 里看不到的「消失的线程」

这里遇到了虚拟线程排查的第一个坑:jstack / thread dump 默认看不到正在运行的虚拟线程。

虚拟线程挂载(mount)在少量的载体线程(carrier thread,即 ForkJoinPool 的 worker)上,而且调度切换非常频繁。传统的 thread dump 只能看到载体线程本身,状态大多是 RUNNING 或 WAITING,栈信息却是「此刻恰好挂在上面的那个虚拟线程」的,快照之间完全对不上。

我们改用 JDK 21 增强后的 jcmd:

# 查看所有虚拟线程的栈(包括未挂载的)
jcmd <pid> Thread.dump_to_file -format=json vd.json

在导出的 JSON 里过滤 mountState == "pinned",很快定位到问题:大量虚拟线程处于 pinned(钉住) 状态——它们被固定在载体线程上,无法在阻塞时让出载体。

四、根因:synchronized 块内的阻塞调用

钉住的原因出在下面这段「祖传代码」里:

@Service
public class RateLimitService {

    private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();

    // 问题代码:synchronized 内部发生了阻塞
    public synchronized boolean tryAcquire(String key) {
        TokenBucket bucket = buckets.computeIfAbsent(key, k -> loadFromRedis(k)); // 阻塞 I/O!
        return bucket.tryAcquire();
    }
}

在平台线程时代,这段代码毫无问题——阻塞的只是那 200 个线程中的一个。但在虚拟线程模型下,JDK 21 的虚拟线程不允许在 synchronized 块(或 native 帧)内部发生阻塞:一旦阻塞,整个载体线程会被一起钉住(pinned),无法去执行其他就绪的虚拟线程。

我们的载体线程池默认大小等于 CPU 核数(8 核机器 = 8 个载体线程)。高峰期每秒上千个请求涌入,全都在这段 synchronized 里等 Redis 返回,8 个载体线程被全部钉死。结果是:

  1. 新虚拟线程无法挂载,请求排队;
  2. ForkJoinPool 检测到载体线程饥饿,尝试补偿性创建新载体线程;
  3. 频繁的挂载/卸载/补偿调度让 CPU 空转在调度开销上——这就是 CPU 飙升的来源。

五、修复:三个方案

方案 1:synchronized 换成 ReentrantLock(推荐)

虚拟线程对 ReentrantLock 是完全友好的,锁内阻塞会正常让出载体线程:

@Service
public class RateLimitService {

    private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    public boolean tryAcquire(String key) {
        // 阻塞 I/O 挪到锁外面,锁内只做内存操作
        TokenBucket bucket = buckets.get(key);
        if (bucket == null) {
            bucket = loadFromRedis(key); // 不在锁内
            buckets.putIfAbsent(key, bucket);
        }
        lock.lock();
        try {
            return bucket.tryAcquire(); // 纯内存操作,瞬时完成
        } finally {
            lock.unlock();
        }
    }
}

注意这里其实做了两件事:换锁 + 把阻塞 I/O 移出临界区。后者才是根本,锁的粒度越小,虚拟线程被钉住的影响面也越小。

方案 2:缩小 synchronized 的范围

如果暂时不方便换锁实现(比如涉及第三方库),至少保证 synchronized 内部不发生阻塞:

// 反例:锁内查数据库
synchronized (this) {
    config = queryFromDb();   // ❌ 阻塞,会 pin
}

// 正例:先查后锁
Config tmp = queryFromDb();   // ✅ 锁外阻塞
synchronized (this) {
    config = tmp;             // ✅ 内存赋值,不阻塞
}

方案 3:监控先行,把 pinning 暴露出来

JDK 21+ 内置了钉住事件的 JFR 打点,生产环境建议常开:

# 打印每次 pinning 事件的栈
-Djdk.tracePinnedThreads=full

# 或用 JFR 持续采集
jcmd <pid> JFR.start name=pinning settings=profile
jcmd <pid> JFR.dump name=pinning filename=pinning.jfr

在 JFR 里重点看 jdk.VirtualThreadPinned 事件,任何超过 20ms 的钉住都会被记录。

六、验证与踩坑总结

修复上线后复现了晚高峰流量模型:CPU 稳定在 35% 左右,jcmd Thread.dump_to_file 里不再出现 pinned 状态,P99 延迟回到压测水平。

几条经验总结:

  1. 虚拟线程的收益主要在 I/O 密集场景,CPU 密集型任务换成虚拟线程没有任何收益,瓶颈本来就在算力。
  2. JDK 21 的两大 pinning 来源:synchronized 块内阻塞、JNI/native 帧内阻塞。JEP 491 在 JDK 24 已经解决了 synchronized 的问题,如果深度受困于此,升级 JDK 是最彻底的方案。
  3. 不要用线程数思维做容量规划:Thread.sleep()、BlockingQueue.take() 在虚拟线程里是安全的,但 Object.wait() 在 21 里同样会 pin。
  4. ThreadLocal 慎用:百万级虚拟线程意味着百万级 ThreadLocal 副本,内存开销不可忽视,结构化并发(StructuredTaskScope)或作用域值(Scoped Values,JEP 446/464)是更合适的方向。
  5. 可观测性要提前改造:传统 jstack 排障套路对虚拟线程基本失效,JFR + jdk.tracePinnedThreads 才是新武器。

七、写在最后

这次事故本质上不是虚拟线程的 bug,而是我们的代码里藏着「锁内做 I/O」这种在平台线程时代就存在、只是被 200 线程池规模掩盖掉的坏味道。虚拟线程把并发量放大了两个数量级之后,这些问题被原形毕露地暴露了出来。

如果你正准备上虚拟线程,建议先在预发环境开着 -Djdk.tracePinnedThreads=full 跑一轮全链路压测——它会诚实地告诉你,代码里还有哪些角落不适合这个新时代。


【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。