上周四下午三点,监控大盘上P99延迟从50ms飙到5s,持续了大概四十秒又自己恢复了。我盯着屏幕看了半天,第一反应是上游服务挂了------查了一圈,上游稳如老狗。 然后看了一眼线程dump,整个人都不好了。 800多个虚拟线程,状态清一色是TIMED_WAITING,但底下只有16个载体线程(carrier thread),全部被钉死。整个系统表面上CPU 0%,实际上已经瘫痪了。 当时脑子里就一个念头:完了,上周灰度的虚拟线程出事了。 这事儿得从两周前说起。
升级动机:一行配置的神话
我们是个中等体量的ToB服务,日均请求量大概800万。技术栈是Spring Boot 3.2 + JDK 21,标准的2024年新建项目。之前用平台线程,Tomcat线程池设的200,一直跑得还行。 年初技术评审,leader提了一嘴:JDK 21虚拟线程在生产环境已经有不少落地案例了,要不要试试?
当时心动的原因有点丢人------嫌线程池调参烦。每次大促前都要算corePoolSize、maxPoolSize、队列容量,算错了就OOM或者拒绝请求。虚拟线程的宣传语是"每个请求一个线程,不用池化",听起来就像从此不用洗碗。 查了一些资料,Spring Boot 3.2开虚拟线程只需要一行:
yaml
spring:
threads:
virtual:
enabled: true
开完之后,Tomcat的请求处理、@Async方法、@Scheduled定时任务,全部自动跑在虚拟线程上。业务代码一行不改。 我在测试环境跑了一轮JMH基准测试,模拟200ms的外部API调用,500并发的场景下,虚拟线程比平台线程快了将近8倍。内存占用降了72%。 "这不就是白捡的性能吗?" 当时我是真这么想的。年轻,太年轻。
第一次翻车
灰度上线第一天,没什么问题。第二天流量高峰,开始出现零星超时。第三天,就是我开头描述的那个场景。 排查过程挺痛苦的。常规的jstack、jmap上来一套,看到的现象非常反直觉:线程数量暴增(虚拟线程嘛,正常),但干活的好像只有那么几个。大量虚拟线程在等锁,而持有锁的那几个虚拟线程又绑死在载体线程上,动都动不了。
我对着jstack的输出愣了快十分钟。以前平台线程出问题,线程池满、队列堆了,原因很好定位。虚拟线程完全不按套路来------800多个线程全在等那16个载体线程释放。 旁边工位的老周凑过来看了一眼我的屏幕,说了句:"你查JFR了没?看jdk.VirtualThreadPinned事件。"
我之前压根没想到JFR。装上去一查,每分钟15000多次pinning事件,平均pin持续时间45ms。数字摆在那,心里基本有数了。 问题就出在synchronized上。 虚拟线程有个核心限制:当它进入synchronized块或者调用native方法时,会被"钉"在载体线程上,无法卸载。如果synchronized块里面刚好有IO操作(查数据库、调HTTP接口),那载体线程就跟着一起卡住。 载体线程数量默认等于CPU核心数。我们机器是16核,所以只有16个载体线程。一旦被pin住的虚拟线程够多,后面所有请求都得排队。 我写了个快速排查脚本,扫了一遍代码里的synchronized使用:
bash
# 找出所有synchronized块,按调用链深度排序
grep -rn "synchronized" --include="*.java" src/main/java/ | \
grep -v "test" | wc -l
# 结果:247处
247处。看到结果的时候我骂了一句------其中大概有30多处是在IO操作的路径上。 最要命的几个:
vbnet
// 缓存加载方法------老代码,不知道谁写的
public synchronized String getConfig(String key) {
if (configCache.get(key) == null) {
// 💀 这里会查数据库!synchronized里面查数据库!
configCache.put(key, configDao.selectByKey(key));
}
return configCache.get(key);
}
这个方法是典型的"双重灾难":用synchronized保护缓存,但缓存miss的时候要查数据库。在平台线程时代,200个线程排队等锁,虽然慢但不至于崩。换成虚拟线程,一个请求pin住一个载体线程,16个请求就把所有载体线程吃满了。 修复方案是把synchronized换成ReentrantLock。ReentrantLock基于AQS,虚拟线程等待锁的时候会正确卸载,不会pin住载体线程:
vbnet
private final ReentrantLock lock = new ReentrantLock();
public String getConfig(String key) {
lock.lock();
try {
if (configCache.get(key) == null) {
configCache.put(key, configDao.selectByKey(key));
}
return configCache.get(key);
} finally {
lock.unlock();
}
}
但这只是最明显的一个。第三方库里的synchronized你根本改不了------Guava Cache的某些加载方法、Logback的异步appender、老版本的数据库驱动,里面都有。这些问题不会在测试环境暴露,因为测试环境的并发量不够高,pinning的概率低。 启动参数加个-Djdk.tracePinnedThreads=short,pinning发生时会打印线程栈,方便你逐个排查。这招救了我不少时间。 第一波修了大概17处热路径上的synchronized,重新上线,P99恢复了正常。 我以为完事了。太天真了。
第二次翻车:ThreadLocal的复仇
昨天早上收到GC告警:老年代内存持续增长,Full GC频率从每天1次变成每3小时1次。 这次排查方向一开始就跑偏了。我以为是哪个新加的缓存没设过期时间,或者某个SQL慢查询导致结果集堆积。折腾了一上午,越查越懵。 中午吃饭的时候,同事小陈突然来了一句:"你查过ThreadLocal没有?虚拟线程创建那么多,每个都带一份ThreadLocalMap,万一有泄漏呢?"
我当时筷子都没放下就跑回去看heap dump了。果然,ThreadLocal$ThreadLocalMap的实例数涨到了6位数。 我们的请求链路里用ThreadLocal传了请求上下文------TraceId、TenantId、UserId,很常见的用法。每个请求进来,Filter里set(),请求结束时remove()。在平台线程时代,200个线程,最多200个ThreadLocal条目,完全不是问题。
但虚拟线程不一样。每个请求创建一个全新的虚拟线程,虚拟线程的数量可以是几万、几十万。每个虚拟线程都会创建自己的ThreadLocalMap。如果remove()漏掉了(或者因为异常没走到finally),这些条目就一直挂在堆里,GC回收不掉。
更要命的是,ThreadLocal本身的设计就没考虑过"百万级线程"这种场景。它的内部是个ThreadLocalMap,每个线程持有一份完整的map副本。200个平台线程的时候,200份副本无所谓。几万个虚拟线程的时候,这就是纯粹的内存灾难。 检查所有ThreadLocal的使用点,确保remove()在finally块里------果然找到了几处漏掉的。某个中间件的Filter在异常路径上没清ThreadLocal,而且这代码连个TODO注释都没有。
vbscript
// 修复前:异常路径不会remove
try {
RequestContext.set(buildContext(request));
chain.doFilter(request, response);
RequestContext.clear(); // ← 如果doFilter抛异常,这行不执行
} catch (Exception e) {
log.error("filter error", e);
// RequestContext没清!
throw e;
}
// 修复后
try {
RequestContext.set(buildContext(request));
chain.doFilter(request, response);
} finally {
RequestContext.clear(); // ← finally保证一定执行
}
补完finally算是堵住了出血点,但ThreadLocal本身的内存开销问题还在。几万个虚拟线程,每个复制一份ThreadLocalMap,这设计不适合虚拟线程。 后来我们决定用Java 21的ScopedValue替换ThreadLocal。
swift
// 旧的ThreadLocal方式
private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();
CTX.set(context);
try {
processRequest();
} finally {
CTX.remove();
}
// 新的ScopedValue方式
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
ScopedValue.runWhere(CTX, context, () -> {
processRequest();
// 出这个作用域就自动释放,不用手动remove
});
ScopedValue的核心区别是:它把数据和作用域绑定,而不是和线程绑定。作用域结束,数据自动消失。不会泄漏,不需要remove(),而且子线程可以继承父线程的ScopedValue,不会复制数据,只是引用。 不过ScopedValue在JDK 21还是预览特性,需要--enable-preview。如果你们用的是JDK 25,ScopedValue已经转正,直接就能用。 替换完之后,上下文对象的内存占用降了大概60%。Full GC频率回到了每天0-1次。看到监控曲线平稳的那一刻,真有种劫后余生的感觉。
第三次差点翻车:连接池没跟上
修完前两个问题,性能数据好看了很多。但压测的时候又发现一个隐性问题。 虚拟线程让你的应用能承受更高的并发------以前200个线程就是上限,现在可以轻松处理几千个并发请求。但问题是,数据库连接池、HTTP连接池、Redis连接池的上限没变。 大量虚拟线程同时去拿数据库连接,HikariCP的maximum-pool-size设的20,几千个虚拟线程排队等20个连接。它们等的时候不消耗载体线程,但等待时间长,用户体验差。而且等待中的虚拟线程持有的请求对象、响应缓冲区全留在堆里,GC压力反而更大。
说白了,虚拟线程解除了"线程数量"这个瓶颈,但下游资源(数据库连接、HTTP连接)的物理上限还在。你只是把排队的人从"等线程"变成了"等连接",队伍更长了。 最终的方案是在应用层加了个Semaphore做入口限流:
java
private final Semaphore concurrentLimit = new Semaphore(500);
public Response handle(Request request) throws InterruptedException {
if (!concurrentLimit.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return Response.tooManyRequests();
}
try {
return doHandle(request);
} finally {
concurrentLimit.release();
}
}
同时把HikariCP的connection-timeout从默认30秒改成2秒------与其让请求等30秒然后超时,不如快速失败让客户端重试。配合连接池从20调到30,整体P99稳住了。
虚拟线程值不值得开?
搞了这两次事故之后,团队内部对虚拟线程的态度直接分裂了。周会上吵过一次,老周那派觉得这玩意就是个坑,生产环境稳定性第一,不如老实回去用平台线程;小陈这边觉得踩完坑之后确实爽,代码简洁了很多,而且那波排查下来大家对虚拟线程的理解也深了不少。
吵到最后leader拍板:别非黑即白,按场景切。 **虚拟线程的真正价值不是让程序跑得更快,而是让编程模型更简单。**以前写并发IO,要么用同步阻塞(简单但浪费线程),要么用异步回调/CompletableFuture(不浪费线程但代码丑得要命)。现在虚拟线程给了你第三条路:写同步代码,但底层自动帮你做异步调度。
不过你的代码得满足几个条件:
- 热路径上没有
synchronized(或者愿意花时间改掉) ThreadLocal使用规范,或者愿意迁移到ScopedValue- 连接池等外部资源做了合理限流
- CPU密集型任务仍然走平台线程池
我们最后的做法是混合策略:IO密集型的接口(调外部API、查数据库)走虚拟线程,纯计算的接口(报表生成、数据聚合)走专门的平台线程池。不是所有东西都必须切,按场景来。
如果你也在考虑开虚拟线程,我的建议是先做两件事:
一,跑一下grep -rn "synchronized" src/,看看你们代码里有多少。如果超过50处,而且很多在IO路径上,那迁移成本不低,做好心理准备。
二,在测试环境用JFR跑一轮高并发压测,看jdk.VirtualThreadPinned事件的数量。如果每分钟超过100次,先解决pinning再上线。