去年年底接了一个老项目的改造,Spring Boot 2.7 加 Tomcat,接口里大量调用第三方 HTTP 服务,平均响应 300ms 上下。压测到 400 并发的时候,Tomcat 那 200 个线程就全泡在等待里了,QPS 死活上不去,CPU 却闲着。典型的 IO 密集场景,虚拟线程刚好对症。
于是升级到 Spring Boot 3.2,一行配置切过去:
spring.threads.virtual.enabled=true
压测结果确实漂亮,200 并发下的 QPS 从 900 涨到了 3400,P99 也降了。团队挺高兴,直接上了预发环境。然后问题就来了——而且不是一个,是三个,一个接一个地冒出来。这篇文章就是把这三次排查的过程记下来,代码都是当时用来复现的最小样例。
一、先把「虚拟线程不是线程池」这件事说透
很多人第一次接触虚拟线程,会下意识地把它理解成「更轻量的线程池」。这个类比不太对,会误导后续所有的判断。
平台线程是操作系统线程的一对一封装。创建一个平台线程,就是让内核创建一个调度实体,栈空间按 MB 计,上下文切换要走内核。所以线程池的逻辑是:线程太贵,那就建一小撮反复用,把任务排队塞进去。
虚拟线程完全是另一个层次的东西。它由 JVM 管理,栈存在堆里,初始只有几百字节,可以按需增长。一个虚拟线程在执行时,会被安排到一个「载体线程」(carrier thread,本质是 ForkJoinPool 里的平台线程)上跑。一旦这个虚拟线程遇到阻塞操作,比如 Socket.read()、LockSupport.park()、Thread.sleep(),JVM 会把它从载体线程上卸下来(unmount),把栈帧存进堆里,然后让载体线程去跑别的虚拟线程。等 IO 就绪了,再找个载体线程把它挂回去(mount)继续跑。
关键在于「卸下来」这个动作。平台线程阻塞时,它占着内核线程干等;虚拟线程阻塞时,它会主动让出载体线程。所以载体线程的数量不需要随着并发量增长。默认情况下,载体线程池的大小等于 CPU 核数——我的机器是 8 核,那 8 个载体线程理论上就能支撑十万个虚拟线程同时等待。
想明白这一层,后面的三个坑就有了共同的解释:只要虚拟线程没法正常 unmount,它就会退化成平台线程,把你的载体池占满。
二、第一个坑:synchronized 把载体线程钉住了
上线第三天,监控报警说服务响应变慢,线程 dump 出来一看,ForkJoinPool 里的 8 个载体线程全部卡在 WAITING 状态,而且堆栈顶上都停在同一个方法上。
那个方法长这样:
public class RemoteConfigCache {
private final Map<String, Config> cache = new HashMap<>();
public synchronized Config get(String key) {
Config cached = cache.get(key);
if (cached != null) {
return cached;
}
// 这一步是 HTTP 调用,可能耗时几百毫秒
Config fresh = remoteClient.fetch(key);
cache.put(key, fresh);
return fresh;
}
}
问题就出在那个 synchronized 上。在 JDK 21 到 JDK 23 这几个版本里,虚拟线程如果在 synchronized 块内部发生阻塞,它是没法被卸下来的。原因在于 synchronized 的监视器锁和对象头是绑定的,而对象头记录的是载体线程的身份。如果一个虚拟线程抱着监视器锁被卸载,另一个虚拟线程挂到同一个载体线程上再想获取这把锁,JVM 没法区分它们——监视器的所有权模型根本不支持这种「跨载体线程持有」的语义。
所以 JVM 的选择是:干脆不卸了。虚拟线程在 synchronized 块里阻塞时,会一直钉在载体线程上,这个状态就叫 pinning。
我的场景更糟——synchronized 修饰的是实例方法,锁的粒度是整个对象,同一时刻只有一个虚拟线程能进去。其他几百个虚拟线程全在外面排队等锁,而进去的那个在慢慢做 HTTP 调用。8 个载体线程,只要 8 个这类调用同时发生,整个调度器就瘫痪了。
怎么发现的。JDK 提供了一个诊断开关:
java -Djdk.tracePinnedThreads=full -jar app.jar
只要有虚拟线程因为 pinning 而阻塞,控制台就会打印一段堆栈,明确指出是哪一行 synchronized 导致的。我加上这个参数跑了一次压测,日志里刷屏的都是 RemoteConfigCache.get,问题定位得很快。
怎么改。把 synchronized 换成 ReentrantLock:
public class RemoteConfigCache {
private final Map<String, Config> cache = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public Config get(String key) {
lock.lock();
try {
Config cached = cache.get(key);
if (cached != null) {
return cached;
}
} finally {
lock.unlock();
}
// 关键:把网络调用挪到锁外面
Config fresh = remoteClient.fetch(key);
lock.lock();
try {
return cache.computeIfAbsent(key, k -> fresh);
} finally {
lock.unlock();
}
}
}
这里做了两件事。ReentrantLock 的底层是 AbstractQueuedSynchronizer,它的等待队列是纯粹的 Java 对象,不依赖对象头,所以虚拟线程持锁阻塞时可以安全卸载。另一件事更重要:把 HTTP 调用移出临界区。就算锁的问题解决了,让几百个虚拟线程排着队等一个网络请求,吞吐量也好不到哪去。
顺带提一句版本。JDK 24 通过 JEP 491 重构了 synchronized 的实现,虚拟线程在 synchronized 块里阻塞也能正常卸载了。所以如果你是 JDK 24 及以上,这个坑基本不存在。但在 JDK 21 这个 LTS 上,它是个必须处理的问题,而且因为不报错、不抛异常,只是悄悄变慢,很容易被漏掉。
三、第二个坑:ThreadLocal 从 200 份变成 50 万份
pinning 修完,接口确实稳了,但过了两天运维反馈说容器内存涨得厉害,GC 频率也上去了,Full GC 从一天两次变成一小时三次。
堆 dump 拉下来分析,发现占用最大的是一堆 ThreadLocalMap$Entry,加起来有几百 MB。
原因是这样。项目里有个自研的链路追踪组件,用 ThreadLocal 存 traceId 和调用链上下文:
public final class TraceContext {
private static final ThreadLocal<Trace> CURRENT = new ThreadLocal<>();
public static void set(Trace trace) {
CURRENT.set(trace);
}
public static Trace get() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}
用平台线程池的时候,这个写法没什么问题。线程池里就 200 个线程,不管处理多少请求,ThreadLocalMap 始终只有 200 份,而且每当复用线程处理下一个请求时,set 会覆盖旧值,老对象很快就能被回收。
换成虚拟线程之后,情况完全不同。Executors.newVirtualThreadPerTaskExecutor() 是每个任务创建一个新虚拟线程。也就是说,一个请求进来就多一个虚拟线程,多一份 ThreadLocalMap。压测跑十分钟,创建了五十万个虚拟线程,就产生了五十万份 ThreadLocalMap。虽然虚拟线程执行完就被回收了,但 ThreadLocalMap 里的 Trace 对象持有的是业务上下文,里面还挂着一些不小的集合,这些对象在 Young GC 里活不过几轮就被晋升到老年代,堆积起来就是几百 MB。
这个问题本质上是「ThreadLocal 的语义」和「线程复用」之间的矛盾。ThreadLocal 之所以能工作,前提就是线程会被反复使用。一旦线程不再复用,ThreadLocal 就退化成了「随任务创建、随任务销毁」的临时变量——那还不如直接方法传参。
短期修法是严格保证清理。把 remove() 放进 try-finally,确保任何路径都会执行:
public void handle(Request req) {
TraceContext.set(new Trace(req.traceId()));
try {
doHandle(req);
} finally {
TraceContext.clear();
}
}
长期方案是换用 ScopedValue。它是为虚拟线程时代设计的,写法和 ThreadLocal 类似,但作用域是结构化、不可变的,绑定关系会随作用域自动解除,不需要手动清理:
private static final ScopedValue<Trace> TRACE = ScopedValue.newInstance();
public void handle(Request req) {
ScopedValue.where(TRACE, new Trace(req.traceId()))
.run(() -> doHandle(req));
}
public Trace currentTrace() {
return TRACE.isBound() ? TRACE.get() : Trace.empty();
}
ScopedValue 在 JDK 21 到 JDK 24 都还是预览特性,JDK 25 转正。如果暂时用不了,至少要把 ThreadLocal 的清理做到位,另外可以给线程池加个包装,在任务执行前后统一做 clear。
四、第三个坑:8000 个虚拟线程在等 10 个连接
前两个坑填完,我以為可以收工了。结果做全链路压测的时候发现,QPS 曲线在 800 并发之后就不涨了,反而开始出现大量超时,日志里满是 HikariCP 的 Connection is not available, request timed out after 30000ms。
这个现象挺有迷惑性。按理说虚拟线程那么多,吞吐量应该无限增长才对,怎么会卡在这个数上?
答案在连接池的配置里:
spring.datasource.hikari.maximum-pool-size=10
这是很多项目的默认值,来源于 HikariCP 作者的一个经典建议:「池子大小应该等于 CPU 核数加磁盘数」。这个建议本身没错,它的前提是每个请求很快就能拿到连接、很快归还,池子只是一个复用缓冲。
但虚拟线程改变了压力的形态。之前平台线程池只有 200 个线程,最多也就 200 个请求同时抢 10 个连接,排队是必然的,但队列长度可控。换成虚拟线程之后,Tomcat 不再限制并发数,一个瞬时流量打进来可能瞬间开出一万个虚拟线程,全都堵在 getConnection() 那一行上。HikariCP 内部用的是 ReentrantLock 加超时机制,10 个连接被占满之后,后面 9990 个虚拟线程全部在等待队列里排着,等满 30 秒之后一起抛异常。
结果就是:连接池变成了整个系统的实际并发上限,而虚拟线程把等待的队伍拉长了 50 倍,超时时间一到,大批请求同时失败。
修法不是简单地把池子调大。数据库能承受的连接数是有限的,把 maximum-pool-size 从 10 调到 500,数据库那边就先撑不住了,而且会引发大量上下文切换,反而更慢。
正确做法是在业务层加一道显式的限流,把「能进到数据库这一层」的并发数控制住,多出来的请求快速失败或者降级:
public class DbGuard {
private final Semaphore permits;
public DbGuard(int concurrency) {
this.permits = new Semaphore(concurrency);
}
public <T> T withPermit(Callable<T> action) throws Exception {
if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new TooManyRequestsException("数据库繁忙,请稍后重试");
}
try {
return action.call();
} finally {
permits.release();
}
}
}
几个参数的意义需要拿捏一下。concurrency 一般设成连接池大小的 1.5 到 2 倍,让池子保持饱和但不至于排队过长。tryAcquire 的超时时间不能太长,200ms 是个合理的起点——超过了就说明已经过载,让请求带着明确的错误信息回去,比让它等 30 秒然后失败要体面得多。
归根结底一句话:虚拟线程解决的是「线程不够用」,解决不了「资源不够用」。CPU、数据库连接、下游服务的 QPS 配额,这些都是真实存在的瓶颈。虚拟线程让瓶颈更容易被暴露出来,而不是让它消失。
五、多个调用能不能并行:StructuredTaskScope
改造过程中还有一个意外收获。有个接口需要同时调用用户服务和订单服务,然后拼装结果。原来的写法是串行调用,两个各 200ms 左右,加起来 400ms。
用 CompletableFuture 当然可以并行,但它的异常处理确实有点绕——两个 future 一个成功一个失败,要保证把成功那个也取消掉,代码写起来很啰嗦。JDK 21 引入的 StructuredTaskScope 就是干这个的:
public Profile loadProfile(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> userTask =
scope.fork(() -> userClient.fetch(userId));
Supplier<List<Order>> orderTask =
scope.fork(() -> orderClient.fetchByUser(userId));
scope.join();
// 任意一个失败,就抛异常,同时取消另一个
scope.throwIfFailed();
return new Profile(userTask.get(), orderTask.get());
}
}
这个 API 最舒服的地方在于「结构化」三个字。子任务的生命周期被严格限制在 try 块里。join() 会一直等到所有子任务结束;只要有任何一个抛异常,throwIfFailed() 就会抛出,同时 ShutdownOnFailure 策略已经帮你把还在跑的其他子任务取消掉了。不会再有「主流程已经返回了,后台还有一个 future 在悄悄跑」这种事。
另外还有一种策略叫 ShutdownOnSuccess,适合「多个数据源同时查,谁先返回用谁」的场景,比如从三个镜像节点里取同一份数据。
需要提醒的是,StructuredTaskScope 的 API 从 JDK 21 到 JDK 25 之间改动过好几次——ShutdownOnFailure 在后期版本里被 joinAll() 返回结果的写法取代了。所以上面的代码请以你实际使用的 JDK 文档为准。目前它仍需加 --enable-preview 才能编译,用在生产环境要谨慎评估,但作为技术预研方向是值得的。
六、什么情况下别用虚拟线程
踩完这三个坑之后,我反而对虚拟线程的适用边界清楚了很多。它不是什么场景都能救的银弹。
CPU 密集型的任务不要用。计算密集任务的瓶颈就是核数,虚拟线程再多也得排队等 CPU。相反,虚拟线程的创建和调度本身还有开销,在高强度计算下反而比平台线程慢。这类任务应该用 ForkJoinPool 或者固定大小的线程池,让线程数量和核数对齐。
依赖线程局部变量的老代码要慎重。刚才说的 ThreadLocal 膨胀是一个例子。另外像 SecurityManager、InheritableThreadLocal、基于 Thread.currentThread() 做身份识别的代码,在虚拟线程下都可能有意外行为。
用了本地方法或者 JNI 的地方。本地方法的栈帧不受 JVM 管理,如果它长时间阻塞,载体线程依然会被钉住。这类调用通常只能靠调大载体线程池(jdk.virtualThreadScheduler.maxPoolSize)来缓解。
反过来说,如果你的服务特征是「请求量大、每个请求都在等 IO、线程数经常被打满、CPU 使用率却不高的」,那虚拟线程的收益会非常明显。判断标准就这么简单,不用想太复杂。
七、改造的落地顺序
如果让我重新走一遍这个流程,我会按这个顺序来,能少踩不少坑。
第一步,升级 JDK 到 21 或者更高,先把 -Djdk.tracePinnedThreads=full 挂上,别急着改业务代码,压测一轮,把所有的 pinning 点都找出来。这一步纯观察,不改代码,但能拿到最真实的问题清单。
第二步,把同步块从 synchronized 换成 ReentrantLock,并且检查临界区里有没有 IO 调用,有的话挪出去。这一步做完,虚拟线程才算真正用起来了。
第三步,审计所有的 ThreadLocal 使用,加上 finally 里的 remove()。有条件的话开始规划向 ScopedValue 迁移。
第四步,给所有外部资源加上显式限流。数据库、Redis、每个上游服务,都应该有一个 Semaphore 守着。这一步是防雪崩的关键,也是虚拟线程改造里最容易被忽略的一步。
第五步才是打开 spring.threads.virtual.enabled=true,小流量灰度,观察载体线程的队列深度和连接池的等待时间。这两个指标如果都在正常范围,基本就稳了。
写在最后
虚拟线程的好,不在于它是新东西,而在于它把「一个请求一个线程」这个最直观的编程模型还给了我们。过去十几年,我们从同步模型退到异步回调,又从回调进化到 Reactor 那样链式的表达,本质上都是在用复杂度的代价去换线程资源的节省。虚拟线程把这个权衡拿掉了——你可以继续写阻塞的、顺序的代码,同时拿到高并发的能力。
但有一个前提不能忘:它优化的只是「线程」这一种资源。总吞吐量的上限,永远由系统里最稀缺的那个资源决定。所以改造的时候,注意力不该全放在虚拟线程本身,而要花一半时间去看那些真正的瓶颈——数据库连接、下游服务的承载能力、外部 API 的配额。这些地方做扎实了,虚拟线程的收益才能完整兑现。

