Spring Boot 3开虚拟线程,我把生产服务搞崩了两次

上周四下午三点,监控大盘上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换成ReentrantLockReentrantLock基于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再上线。

相关推荐
SimonKing1 小时前
别再盲目跑测试了,用 JaCoCo 告诉你哪些代码根本没被覆盖
java·后端·程序员
唐青枫1 小时前
Java Grails 实战详解:用 Groovy 和 GORM 快速开发 Web 应用
java·groovy
SemiTris2 小时前
Java 异常体系深度解析:从误区到精通
java
荣码2 小时前
AI应用部署上线:Docker打包+API服务+监控告警,我踩了4个坑
java·python
xiaotianyuanma2 小时前
【计算机毕业设计】基于java web的社区养老服务管理系统设计与实现
java·开发语言·课程设计
青山木2 小时前
Hot 100 --- 电话号码的字母组合
java·数据结构·算法·leetcode·逻辑回归
霸道流氓气质2 小时前
Java中集成Weka 技术教程:从入门到工程实践
java·开发语言·数据挖掘
Full Stack Developme2 小时前
SpringBoot 内嵌 Tomcat 的启动流程
spring boot·后端·tomcat
Full Stack Developme2 小时前
Tomcat 如何处理HTTP请求
java·http·tomcat