去年底一个订单查询接口要做压测,那个接口调了下游三个服务,每个调用平均 80ms,加起来 240ms 的大部分时间都在等网络。老架构用的线程池是 200 个平台线程,压到 800 QPS 就开始排队,平均响应从 240ms 涨到接近 3 秒。
改造成虚拟线程只花了半天——Tomcat 换了个 ProtocolHandler,异步任务换了 Executors.newVirtualThreadPerTaskExecutor(),压测结果立刻升到 3000+ QPS,平均响应掉到 260ms 左右。当时看数据确实挺爽。
问题出在上线第二天下午。订单高峰时段并发冲到 4000,结果整个服务直接失去响应,CPU 不高、GC 正常、日志也没报错。运维同事以为是网络问题,一路排查到凌晨。最后用 jcmd Thread.dump_to_file 导出线程快照,才发现问题所在——有两万多条虚拟线程全部卡在同一个 synchronized 块上,载体线程池被彻底堵死了。
虚拟线程不是银弹。这篇文章就把那次事故的排查过程和后续几周的踩坑记录整理出来。
虚拟线程的模型一句话讲清楚
虚拟线程是 JVM 层实现的、由若干个平台线程(叫 carrier thread,载体线程)承载的轻量线程。它的调度由 JVM 负责,只在真正需要阻塞时(IO 等待、sleep、park)把载体线程让出来,去跑其他的虚拟线程。
关键点在于”真正需要阻塞时”这几个字。虚拟线程只会在 JVM 能识别的阻塞点上让出载体线程。如果阻塞发生在 JVM 管不到的地方——比如一个 native 方法、一个 synchronized 块里的临界区、或者一个用 Unsafe.park 之外的手工等待——虚拟线程就会被”钉”(pin)在载体线程上,直到阻塞结束才能松手。
这就是 pinning。
pinning 本身不是问题,问题在于当它大量出现时,载体线程会迅速耗尽。ForkJoinPool 默认的并行度是 CPU 核数,一个 8 核的机器只有 8 个载体线程。哪怕你创建了十万个虚拟线程,只要它们同时卡在 pinning 上,实际能跑的也就 8 个。看起来”并发十万”的架构,实际退化成”并发 8″。
什么会导致 pinning
官方文档里列了两大类:
1. synchronized 块
这是最常见的一种。来看那次事故里的具体代码:
public class OrderCache {
private final Map<Long, Order> cache = new HashMap<>();
public synchronized Order get(Long id) {
Order o = cache.get(id);
if (o == null) {
// 这里会发起网络调用,慢的时候几百毫秒
o = remoteClient.fetch(id);
cache.put(id, o);
}
return o;
}
}
这个缓存的意图是好的:查不到本地缓存就去远程取,取回来放进 Map。但 synchronized 加在方法上意味着同一时刻只有一个线程能进入。remoteClient.fetch() 是网络调用,会阻塞几百毫秒。用虚拟线程跑的时候,进入这个方法的虚拟线程会一直占着载体线程直到 fetch 返回。
高并发下,成千上万个虚拟线程挤在方法入口等着进入临界区,也就意味着同时有成千上万个载体线程被占住。而载体线程池只有 8 到 32 个,很快就会耗尽。
修复方案是把 synchronized 换成 ReentrantLock。ReentrantLock 的等待是通过 LockSupport.park 实现的,而 park 是 JVM 显式支持的调度点,虚拟线程在这上面等待时会正常让出载体线程。
public class OrderCache {
private final ReentrantLock lock = new ReentrantLock();
private final Map<Long, Order> cache = new HashMap<>();
public Order get(Long id) {
lock.lock();
try {
Order o = cache.get(id);
if (o != null) return o;
} finally {
lock.unlock();
}
// 移出临界区,避免持锁做 IO
Order o = remoteClient.fetch(id);
lock.lock();
try {
// 双重检查
Order existing = cache.get(id);
if (existing != null) return existing;
cache.put(id, o);
return o;
} finally {
lock.unlock();
}
}
}
真正把”持锁做 IO”这件事解决掉,比单纯换个锁类型更重要。持锁 IO 就算用平台线程也是反模式,只是平台线程下影响没这么明显。
2. 老版本的 native 调用
JDK 里有很多 native 方法会 pin 虚拟线程,比如老版本的 ZipFile、InetAddress.getByName()(某些平台上)、以及使用 Object.wait 的部分路径。JDK 21 之后陆续修了一批,但如果你项目里用了一些 native 依赖(比如老式的图像处理库、某些 JNI 封装的工具类),那 pining 就躲不掉。
排查的方法是用 -Djdk.tracePinnedThreads=full 启动参数。启动之后,JVM 会在检测到 pinning 时打印堆栈,能从里面直接看出是哪一行代码造成的。
生产环境不建议一直开着这个参数,因为打印堆栈本身就是个开销。压测环境或者排查阶段打开,找到问题点后关掉。
第二个坑:ThreadLocal 在虚拟线程下会膨胀
ThreadLocal 是一个”每线程一份”的存储。平台线程池时代大家习惯用它存请求上下文——用户信息、traceId、租户 id、数据库事务上下文。因为线程池里就那两三百个线程,每个线程一份 ThreadLocal 也就占几百 KB。
虚拟线程时代,线程数量可能是几十万上百万。同样的 ThreadLocal,会让内存占用成倍膨胀。
更糟的是,很多人用的 InheritableThreadLocal。InheritableThreadLocal 会在创建新线程时把父线程的所有值复制一份。虚拟线程的创建非常频繁(每次请求一个新线程),每一份都要复制所有 InheritableThreadLocal,这个成本在大并发下会很可观。
之前项目里有个服务用了 InheritableThreadLocal<Map<String, Object>> 存日志上下文,每个线程复制一个包含 10 个 key 的 HashMap。改成虚拟线程之后,每小时创建 300 万虚拟线程,光这个复制动作就吃掉了 20% 的 CPU。
官方的建议很直接:能用 ScopedValue 就用 ScopedValue。ScopedValue 是 JDK 21 引入的替代方案,它用栈作用域绑定值,而不是线程绑定。虚拟线程切换栈的时候,值会跟着栈走,不需要复制,也不需要显式清理。
// 老的写法
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
public void handle(Request req) {
CTX.set(new UserContext(req));
try {
doBusiness();
} finally {
CTX.remove(); // 别忘,忘了就泄漏了
}
}
// ScopedValue 写法
private static final ScopedValue<UserContext> CTX = ScopedValue.newInstance();
public void handle(Request req) {
ScopedValue.where(CTX, new UserContext(req)).run(this::doBusiness);
// 作用域结束自动清理
}
如果在 try 里手动 CTX.set() 却忘了 remove(),平台线程下只影响那一个线程,虚拟线程下会随着线程对象一起被 GC 掉——看起来还好。但如果虚拟线程被复用了(比如在某些执行器里),泄漏的值会被下一次任务读到,这是比内存泄漏更严重的正确性问题。
如果没有条件立刻迁到 ScopedValue,至少要做到:ThreadLocal 只放轻量对象(String、Integer),finally 里必须 remove(),InheritableThreadLocal 能不用就不用。
第三个坑:旧连接池会阻塞载体线程
HikariCP 在很长一段时间里是用 synchronized 加 Object.wait 来实现连接等待的。虽然 HikariCP 从 5.1 版本开始宣称自己”虚拟线程友好”,但如果项目里的连接池版本比较旧,或者用了一些更老的池子(比如 DBCP2 的老版本、c3p0),阻塞行为会 pin 虚拟线程。
具体表现是:数据库连接池设了 20 个连接,跑虚拟线程之后并发 100 个查询请求,前面 20 个正常执行,后面 80 个在等连接的过程中把载体线程占满,导致整个服务卡死。
解决的思路有两个层面。
第一,升级连接池。HikariCP 5.1+ 用 ReentrantLock 和 Condition 重写了等待路径,虚拟线程会正确让出载体线程。
第二,把连接池大小调大。这是反直觉的一条——平台线程时代大家拼命”把连接池调小,避免 DB 压力”,虚拟线程时代这个原则依然成立,但原因变了。虚拟线程的数量不再是限制因素,数据库才是。连接池大小仍然是资源上限,不该盲目放大。
很多团队改虚拟线程之后”什么都没配”就跑起来了,结果第二天发现连接池被耗尽、等待队列飙到几千——因为虚拟线程让请求的吞吐从 500 QPS 涨到了 3000 QPS,同样的连接池自然不够用了。
顺带说,Redis 客户端的连接池也一样。Lettuce 用的是 Netty 异步连接,天然友好;Jedis 的连接池如果版本老,会有同样的 pinning 问题。
第四个坑:线程池换错了就白改
虚拟线程上线的时候,最常见的错误是这么改:
// 错误:把池子换成虚拟线程执行器,但保留了池子的语义
private static final ExecutorService POOL =
Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// 正确:每次任务一个新虚拟线程,不池化
private static final ExecutorService POOL =
Executors.newVirtualThreadPerTaskExecutor();
虚拟线程的设计前提是”用完即弃”。它们极其轻量,创建成本跟创建一个对象差不多。把虚拟线程放进 newFixedThreadPool 里限制成 200 个,等于告诉 JVM”你只能同时跑 200 个任务”——那就跟平台线程没区别了,虚拟线程的并发优势一点都发挥不出来。
同样的错误还有 newCachedThreadPool(Thread.ofVirtual().factory()),看起来是”无限扩”,但缓存池内部有 SynchronousQueue,会把任务移交给现有线程去排队,在虚拟线程下会产生意料之外的行为。
另一条容易搞错的是 ThreadPoolTaskExecutor(Spring 的)。它内部包了一层 ThreadPoolExecutor,就算你把 ThreadFactory 换成虚拟线程工厂,队列和核心线程数这些参数依然生效,实际行为会非常拧巴。要用虚拟线程的话直接换 SimpleAsyncTaskExecutor 配合虚拟线程工厂,或者直接用 JDK 原生的 newVirtualThreadPerTaskExecutor()。
有一点需要特别提醒:信号量、限流器、连接池这些”限并发”的机制,仍然需要保留。虚拟线程能让你的服务同时处理几万个请求,但下游的数据库、Redis、第三方接口未必能扛住。用法是:创建一个几万虚拟线程的吞吐能力,但在进入下游调用前过一道 Semaphore 限流。
private static final Semaphore DB_PERMITS = new Semaphore(50);
public Order queryOrder(Long id) {
DB_PERMITS.acquire();
try {
return orderMapper.selectById(id);
} finally {
DB_PERMITS.release();
}
}
这段代码在虚拟线程下没有 pinning 问题(Semaphore.acquire 底层是 park),而且起到了保护下游的作用。
第五个坑:监控指标消失了
改成虚拟线程之后,很多人第一时间会发现监控平台的”活跃线程数”曲线变成了一条几乎贴地的平线,JMX 上的线程信息也几乎全空。这是因为虚拟线程不是平台线程,多数监控工具(Prometheus 的 JVM 导出器、Spring Actuator)默认只看平台线程。
排查问题的时候会非常被动。原来 GC 一波、连接池满了、线程卡住了,看个线程数就能判断方向,现在什么都没了。
正确的做法有几个:
1. 用 JFR 或 JCMD 现场取样。jcmd <pid> Thread.dump_to_file -format=json /tmp/vt.json 会把当前所有虚拟线程的堆栈导出。故障时执行一次,拿到 JSON 慢慢分析。
2. 打点关键位置。用 Micrometer 或者直接埋点,把”当前正在执行的请求数””等待连接池的请求数”这些业务指标报出来。虚拟线程本身不可监控,但业务上的并发量是可以监控的。
3. 打开 JDK 的虚拟线程事件。JFR 里有 jdk.VirtualThreadStart 和 jdk.VirtualThreadEnd 两种事件,可以统计虚拟线程的创建速率和存活时间。开启方式:
jcmd <pid> JFR.start name=vt duration=5m filename=/tmp/vt.jfr settings=profile
压测的时候用这个,能看到虚拟线程的创建峰值、平均存活时长,配合堆栈采样基本能定位到各种异常。
第六个坑:调试困难
虚拟线程会打乱调试器的调用栈视图。IDE 里断点停在一个虚拟线程上,看到的调用栈经常有一层 VirtualThread.run 的包装,看不太清业务代码的调用关系。这是 JDK 层面的限制,短期没有完美方案。
缓解的办法是给虚拟线程起有意义的名字:
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
// 每个任务的线程有自己的名字
exec.submit(() -> {
Thread.currentThread().setName("order-" + orderId);
return doOrder(orderId);
});
线程名会出现在异常堆栈、日志和 jcmd 输出里。名字起得好的话,定位问题时能省不少时间。
更彻底的做法是通过 Thread.ofVirtual().name("order-", 0).factory() 在创建时统一命名,但这样就区分不出具体的任务了。按业务场景来选,长期的服务我倾向按固定前缀命名,短期的批量任务按 id 命名。
Spring Boot 3.2 怎么开虚拟线程
Spring Boot 3.2 之后开虚拟线程只需要一行配置:
spring:
threads:
virtual:
enabled: true
这行配置做了三件事:把 Tomcat 的请求处理换成虚拟线程执行器、把 Spring MVC 的异步任务执行器换成虚拟线程、把 @Async 注解默认的执行器换成虚拟线程执行器。
但要注意几点。
第一,只对 Servlet 栈生效,对 WebFlux 没意义。WebFlux 本来就不阻塞,自带响应式调度,换成虚拟线程只是把 Reactor 的调度从平台线程换成了虚拟线程,没有实质收益。
第二,对定时任务没影响。@Scheduled 走的是 ScheduleTaskExecutor,默认只有单线程,跟虚拟线程无关。想要并发跑多个定时任务,还是得手动配置执行器。
第三,对 @Transactional 的事务传播没有影响。事务上下文存在 ThreadLocal 里(TransactionSynchronizationManager),虚拟线程下依然生效。但要注意如果开启了 INHERITABLE 传播行为(很少见),事务上下文会随线程创建而复制,可能引发意外问题。
迁移的正确顺序
总结下来,把现有服务迁到虚拟线程,我建议按这个顺序做。
第一步,压测环境全量打开 -Djdk.tracePinnedThreads=short,跑 24 小时。观察是否有 pinning 日志输出。有的话逐个修掉,没有的话才进入下一步。这一步能拦住 80% 的坑。
第二步,审计 synchronized。重点看那些跨网络调用、sleep、锁竞争激烈的 synchronized 块。把它们换成 ReentrantLock,或者重构逻辑把 IO 移出临界区。
第三步,审计第三库。数据库连接池升到 HikariCP 5.1+,Redis 客户端用 Lettuce 或者新版 Jedis,消息队列 SDK 也要确认。任何在关键路径上用 synchronized 做内部锁的库,都是定时炸弹。
第四步,清理 ThreadLocal。把 InheritableThreadLocal 尽量消灭,能改 ScopedValue 的改掉。改不了的确保 finally 里有 remove()。
第五步,监控埋点补上。把业务并发量、下游等待时间、连接池等待队列长度这些关键指标报出来。别等出故障的时候两眼一抹黑。
第六步,限流保留。各个下游的并发限制(Semaphore、RateLimiter、连接池大小)一个都别删。虚拟线程让你有能力发出海量请求,但不代表下游接得住。
第七步,小流量灰度。先在一个非核心服务上跑,观察两周。没问题再逐步扩大。全量切换之后要盯着 TPS、P99、CPU、GC 这几条曲线至少一周。
写在最后
虚拟线程带来的收益是真实的——那次事故后修完 pinning 问题,重新压测到 5000 QPS,平均响应稳定在 250ms 左右,机器数量从 4 台减到 2 台。但它不是”零成本升级”,更像是给系统换了一套调度机制,所有依赖”线程稀有”这个假设的代码都得重新审视一遍。
最反直觉的一点是:虚拟线程让”什么会阻塞线程”这件事变得极其重要。平台线程时代,一个请求卡住一个线程,影响面是 1/N(N 是线程池大小)。虚拟线程时代,pin 住一个载体线程,可能影响面是整个池子,因为池子本来就小。放大器既能放大吞吐,也能放大错误。
如果现在项目里正在考虑迁虚拟线程,建议先做第一步——打开 pinning 追踪跑一天。数据会告诉你这个项目到底准备好了没有。

