记一次线上 CPU 飙升排查:虚拟线程时代的 synchronized 钉住(Pinning)问题
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 个载体线程被全部钉死。结果是:
- 新虚拟线程无法挂载,请求排队;
- ForkJoinPool 检测到载体线程饥饿,尝试补偿性创建新载体线程;
- 频繁的挂载/卸载/补偿调度让 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 延迟回到压测水平。
几条经验总结:
- 虚拟线程的收益主要在 I/O 密集场景,CPU 密集型任务换成虚拟线程没有任何收益,瓶颈本来就在算力。
- JDK 21 的两大 pinning 来源:
synchronized块内阻塞、JNI/native 帧内阻塞。JEP 491 在 JDK 24 已经解决了 synchronized 的问题,如果深度受困于此,升级 JDK 是最彻底的方案。 - 不要用线程数思维做容量规划:
Thread.sleep()、BlockingQueue.take()在虚拟线程里是安全的,但Object.wait()在 21 里同样会 pin。 ThreadLocal慎用:百万级虚拟线程意味着百万级 ThreadLocal 副本,内存开销不可忽视,结构化并发(StructuredTaskScope)或作用域值(Scoped Values,JEP 446/464)是更合适的方向。- 可观测性要提前改造:传统 jstack 排障套路对虚拟线程基本失效,JFR +
jdk.tracePinnedThreads才是新武器。
七、写在最后
这次事故本质上不是虚拟线程的 bug,而是我们的代码里藏着「锁内做 I/O」这种在平台线程时代就存在、只是被 200 线程池规模掩盖掉的坏味道。虚拟线程把并发量放大了两个数量级之后,这些问题被原形毕露地暴露了出来。
如果你正准备上虚拟线程,建议先在预发环境开着 -Djdk.tracePinnedThreads=full 跑一轮全链路压测——它会诚实地告诉你,代码里还有哪些角落不适合这个新时代。
- 点赞
- 收藏
- 关注作者
评论(0)