去年把服务升级到 Java 21 之后,我一直想找个合适的场景把虚拟线程真正用起来。看文档觉得挺简单,换个 Executor 就完事,但真到代码里改的时候,发现要注意的地方比想象中多得多。这篇文章把整个过程记下来,包括最后发现的那几个坑。
先说场景,不然后面没法聊
我们有个商品详情聚合接口。前端传一个商品 ID 进来,后端要同时去四个地方拿数据:
- 商品库,走 JDBC,单条查询十几毫秒
- 库存服务,HTTP 调用,平均 40 到 80 毫秒
- 促销服务,HTTP 调用,这个最慢,偶尔飙到 300 毫秒以上
- 评价服务,HTTP 调用,60 毫秒左右
四个调用互相没有依赖,串行做加起来就是四五百毫秒。并发做的话,总耗时取决于最慢的那个,也就是促销服务。
原来的代码用的是固定大小 32 的线程池,每个请求里再嵌套提交三个 Callable,用 Future 收集结果。这套写法在线程池被占满的时候会出问题,而且代码本身也啰嗦。
原来的代码长什么样
private static final ExecutorService POOL = Executors.newFixedThreadPool(32);
public ProductDetail load(long productId) throws Exception {
Future<BaseInfo> baseF = POOL.submit(() -> repo.find(productId));
Future<Integer> stockF = POOL.submit(() -> inventory.query(productId));
Future<Promo> promoF = POOL.submit(() -> promo.active(productId));
Future<String> reviewF = POOL.submit(() -> reviews.summary(productId));
try {
return new ProductDetail(
baseF.get(800, TimeUnit.MILLISECONDS),
stockF.get(800, TimeUnit.MILLISECONDS),
promoF.get(800, TimeUnit.MILLISECONDS),
reviewF.get(800, TimeUnit.MILLISECONDS)
);
} catch (TimeoutException e) {
baseF.cancel(true);
stockF.cancel(true);
promoF.cancel(true);
reviewF.cancel(true);
throw e;
}
}
这段代码有几个说不上好但一直忍着的点。
第一,超时是逐个 get 的,如果第一个就超时了,后面三个还在跑,虽然 cancel 了但到底有没有真的取消掉,取决于下游客户端有没有响应中断,通常是没有的。
第二,四个任务抢 32 个线程。单个请求占 4 个,那这个池子同时只能服务 8 个请求。第 9 个请求进来就得排队,排队的这段时间里它一个线程都没占上,但因为池子满了,连排队的资格都没有——它会直接卡在 submit 上,而且这个卡是没有超时的,可能在队列里躺几秒钟。
第三,32 这个数字是拍脑袋定的。定小了扛不住并发,定大了线程上下文切换开销上来,而且每个线程默认 1MB 栈,32 个还好,你要是敢调到 500,光栈就吃掉 500MB。
换成虚拟线程:先做最简单的替换
Java 21 里虚拟线程已经转正,Executors 提供了一个新方法:
private static final ExecutorService POOL =
Executors.newVirtualThreadPerTaskExecutor();
就这一行,其他代码完全不用动。理论上,每个 submit 会立刻创建一个新的虚拟线程,不再有排队等待的情况。
跑了一遍压测,QPS 从 800 左右涨到了 2400,P99 从 1.2 秒降到了 380 毫秒。看起来效果不错,但别急着上线,因为问题才刚开始冒头。
第一个坑:数据库连接池成了新瓶颈
QPS 涨上去之后,日志里开始出现大量 Connection is not available, request timed out after 30000ms。
原因很直白:HikariCP 的最大连接数配的是 20。以前线程池只有 32 个线程,最多也就 32 个并发查询在抢 20 个连接,排队还能接受。现在虚拟线程可以无限创建,几千个并发请求同时涌进来,每一个都要从连接池里借一个连接,剩下的全在 getConnection 上死等。
这里有个反直觉的地方:用了虚拟线程之后,你反而更容易打垮自己的数据库。因为请求不再被线程池挡住,压力会实打实地传到下游。数据库连接池的容量,从”反正也到不了”的上限,变成了整个系统真正的吞吐天花板。
解决办法有两个方向。
一是把 HikariCP 的 connectionTimeout 调短,比如 800 毫秒,让它快速失败而不是排 30 秒的队。同时把这个超时纳入整体超时预算里。
二是在数据库这层加信号量,主动限流。我最后两个都做了:
private final Semaphore dbPermits = new Semaphore(20);
private BaseInfo findWithPermit(long id) throws Exception {
if (!dbPermits.tryAcquire(600, TimeUnit.MILLISECONDS)) {
throw new DbOverloadException("数据库繁忙,请稍后重试");
}
try {
return repo.find(id);
} finally {
dbPermits.release();
}
}
用一个定时任务在低峰期把 HikariCP 的实际空闲连接数打印出来,配着调整,最后定在 30 个连接加 30 个许可,比原来干巴巴配一个 20 要靠谱得多。
第二个坑:ThreadLocal 变成了内存炸弹
服务里有个 MDC 工具类用来打 traceId,用的是 ThreadLocal。升级之后有一天发告警说老年代内存持续上涨,dump 下来一看,堆里躺着几十万个 MDC$MDCContext 对象。
原因:ThreadLocal 是绑在线程对象上的。平台线程会被复用,你把变量清一下它就没了;虚拟线程不复用,一个任务一个线程,用完就丢。听起来应该更干净才对——问题出在”用完就丢”之前的这段时间。
如果在一个虚拟线程里 set 了 MDC,然后这个虚拟线程因为某个阻塞操作挂着(比如等下游响应),那么这个虚拟线程对象连同它上面挂的所有 ThreadLocal 引用都还活着。几千个这样挂着的虚拟线程,就是几千份 MDC。每个 MDC 里还有几个字符串,累积起来就不少了。
更麻烦的是,虚拟线程的栈本身是存在堆里的(通过 StackChunk 对象),所以虚拟线程的对象占用比你在脑子里估算的要大。一个空转的虚拟线程大概几百字节,但一旦它进了阻塞状态、栈被物化了,就是几 KB 起步。
这里的处理方式是两条腿走路。
短期:把 MDC 的写入点收敛。不要在调用链上层就 set,而是在真正需要打日志的那一层临时 set,打完立刻 remove,用 try-finally 包住。
长期:迁移到 ScopedValue。这是 Java 21 里跟着虚拟线程一起引入的(当时还是 preview),Java 25 已经转正。它的语义是”值只在某个代码块范围内可见,块执行完自动失效”,而且它是靠栈结构而不是线程对象来维持的,天然适合虚拟线程。
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
// 入口处
ScopedValue.where(TRACE_ID, generateTraceId()).run(() -> {
handleRequest(request);
});
// 日志里
logger.info("trace={} 商品加载完成", TRACE_ID.get());
要注意 ScopedValue 是单向的,绑定之后在作用域里不能改,也不能把值传递到作用域外启动的线程里。如果你的日志框架深度依赖 MDC,那迁移成本不低,可能得先写一个适配层,把 ScopedValue 的值在打日志前塞进 MDC,打完清掉。
第三个坑:synchronized 会把虚拟线程钉住
这个坑在 Java 21 上存在,Java 24 的 JEP 491 把它修掉了。但如果你跟我一样跑在 21 上,就得当心。
虚拟线程跑在一组叫”载体线程”的平台线程上。当虚拟线程里遇到阻塞操作时,JVM 会把它从载体线程上摘下来,把载体线程腾出去跑别的虚拟线程——这是它高效的核心机制。
但有个例外:如果阻塞发生在 synchronized 块里面,JVM 没法安全地把虚拟线程摘下来,因为监视器锁是绑在平台线程上的。这种情况叫”pinning”。被钉住的虚拟线程会一直占着载体线程不放,如果钉住的多了,整个调度器就没有可用的载体线程了,其他虚拟线程全部停摆。
我们出问题的地方是一个老的本地缓存工具类:
public class LocalCache<K, V> {
private final Map<K, V> map = new HashMap<>();
public synchronized V get(K key, Supplier<V> loader) {
V v = map.get(key);
if (v == null) {
v = loader.get(); // 这里可能是一次远程调用,几毫秒到几百毫秒
map.put(key, v);
}
return v;
}
}
这个 synchronized 是方法级的,锁住整个缓存对象。里面调 loader.get() 的时候持着锁,一旦走到这一步,当前虚拟线程就被钉在载体线程上。如果同时有几百个 key 没命中缓存,几百个虚拟线程全被钉住,载体线程池(默认只有 CPU 核数个)瞬间耗尽。
检测方法很简单,加个启动参数:
-Djdk.tracePinnedThreads=full
它会在虚拟线程被钉住时打印完整堆栈,一眼就能找到罪魁祸首。生产环境不建议常开,日志量会很大,用在压测环境里排查就够。
更精细的做法是用 JFR,打开虚拟线程相关的事件:
-XX:StartFlightRecording:filename=vt.jfr,settings=profile
然后用 JDK Mission Control 打开,看 jdk.VirtualThreadPinned 事件的采样图和堆栈。
修法是换成 ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
public V get(K key, Supplier<V> loader) {
V v = map.get(key);
if (v != null) return v;
lock.lock();
try {
v = map.get(key);
if (v == null) {
v = loader.get();
map.put(key, v);
}
return v;
} finally {
lock.unlock();
}
}
这里顺手改成双检锁了,因为 synchronized 版本在并发未命中的情况下会让所有线程排队等第一个加载完,其实没必要。这种写法下,拿到锁之后的第一件事是重新查一遍缓存,大概率已经被前面的线程填好了。
需要注意 map 用的是 HashMap,双检锁的写法在线程安全上是有前提的——map.get 在并发读的情况下不会出错,但要保证 map.put 对其他线程可见。稳妥起见换成 ConcurrentHashMap,或者干脆整个缓存用 ConcurrentHashMap.computeIfAbsent 重写一遍,连锁都省了。上面这个写法是为了保留原有结构,实际项目里我重写了。
用结构化并发重写业务流程
前面这些坑处理完之后,聚合部分本身还停留在原来的 Future 写法上。趁着这次改造,把它一起换掉了。
结构化并发在 Java 21 里是 preview 特性,需要加 --enable-preview 编译和运行。它的核心想法是:并发任务应该像代码块一样有明确的生命周期边界,父任务不结束,子任务不能活着;任何一个子任务失败,其他子任务自动取消。
public ProductDetail load(long productId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<BaseInfo> base = scope.fork(() -> findWithPermit(productId));
Subtask<Integer> stock = scope.fork(() -> inventory.query(productId));
Subtask<Promo> promo = scope.fork(() -> promo.active(productId));
Subtask<String> review = scope.fork(() -> reviews.summary(productId));
scope.joinUntil(Instant.now().plusMillis(700));
scope.throwIfFailed();
return new ProductDetail(base.get(), stock.get(), promo.get(), review.get());
}
}
跟原来的 Future 版本比,变化主要在几处。
超时是一处设置的。joinUntil 给定一个绝对时间点,四个任务共享同一个截止时间。任何一个超时,整个 scope 结束,剩下的子任务会被自动中断。
错误传播是自动的。ShutdownOnFailure 意味着任意一个子任务抛异常,其他子任务会被取消。throwIfFailed() 会把那个异常重新抛出来,不用逐个检查。
子任务的结果不共享。每个 fork 返回的 Subtask 只能被创建它的代码块读取,其他代码拿不到。这看着是个限制,其实防止了很多乱七八糟的跨任务数据传递。
还有一个变体叫 ShutdownOnSuccess,适合”多个数据源谁先返回用谁的”这种场景。比如我们后来接了两个促销服务做互备,用这个就很合适,第一个成功的任务结果会被保留,其余的立刻取消。
要注意 API 变化。Java 21 到 24 之间,StructuredTaskScope 的接口改过几轮。到了 Java 25,ShutdownOnFailure 这个具体类被去掉了,改成通过 StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow()) 来构造,join 也不需要显式调用了,close 时会自动处理。如果你的项目跨版本升级,这段代码是要重写的。这也是它迟迟没有转正的原因之一。
并发上限还是得有
说了半天虚拟线程的好处,但有个认识得摆正:虚拟线程解决的是”线程太贵”的问题,不是”下游打不垮”的问题。
以前线程池限制并发,其实是在做一件它不该做的事——用线程数量间接限流。换成虚拟线程之后,这个保护没了,你得自己补上。
最直接的方式是 Semaphore。给每个下游服务配一个独立的信号量,容量参考对方能承受的并发上限,或者干脆参考你们之间的服务协议。
public class HttpClients {
private final Semaphore inventoryPermits = new Semaphore(200);
private final Semaphore promoPermits = new Semaphore(100);
private final Semaphore reviewPermits = new Semaphore(150);
public int query(long id) throws InterruptedException {
if (!inventoryPermits.tryAcquire(500, TimeUnit.MILLISECONDS)) {
throw new DownstreamBusyException("inventory");
}
try {
return doQuery(id);
} finally {
inventoryPermits.release();
}
}
}
tryAcquire 带超时这个细节很重要。用无参的 acquire() 会让虚拟线程无限等待,虽然不占载体线程,但用户请求就挂在那儿了,还不如快速失败给个降级结果。
信号量的容量定多少,取决于下游的承受能力。我们这边库存服务的响应时间在 40 毫秒左右,容忍并发 200,按利特尔法则算下来理论吞吐是 200 / 0.04 = 5000 QPS,比我们实际需求高,所以 200 是安全的。促销服务本身响应慢,给 100 就够,多了对方反而会超时。
怎么观测
虚拟线程的观测和平台线程不太一样,沿用老办法会看不到东西。
线程 dump。jstack 对虚拟线程基本没用,它只打印平台线程。要看虚拟线程得用:
jcmd <pid> Thread.dump_to_file -format=json /tmp/vt-dump.json
输出是一个 JSON 文件,里面按容器分组,能看到每个虚拟线程的状态和栈。文件可能很大,几万个虚拟线程的情况建议先 grep 一下状态分布。
JFR 事件。几个值得关注的事件:
jdk.VirtualThreadStart和jdk.VirtualThreadEnd:启动数量。如果这个数字远超你的 QPS 乘以平均并发数,说明有哪里在疯狂创建虚拟线程,可能是循环里提交任务之类的写法。jdk.VirtualThreadPinned:被钉住的次数和时长。这个数值不为零就要查。jdk.VirtualThreadSubmitFailed:调度器提交失败,通常是资源不足。
调度器并行度。虚拟线程的载体线程数默认等于 CPU 核数,可以通过系统属性调整:
-Djdk.virtualThreadScheduler.parallelism=16
-Djdk.virtualThreadScheduler.maxPoolSize=64
这两个值一般不用调。只有在你的任务里有大量非阻塞但 CPU 密集的计算时,才会考虑调高 parallelism。纯 IO 型的服务保持默认就行。
几个不应该做的事
不要池化虚拟线程。这是最常见的误解。虚拟线程的创建成本极低,一个大概几百纳秒,比你去池子里借一个还快。写一个虚拟线程池,除了给自己找麻烦没有别的作用。
不要在虚拟线程里做长时间 CPU 计算。虚拟线程的调度器只有 CPU 核数个载体线程,一个虚拟线程跑了 5 秒的密集计算,等于占住了一个载体线程 5 秒,其他虚拟线程全得等。CPU 密集的任务该用平台线程池还是用平台线程池。
不要依赖线程局部状态做跨请求缓存。平台线程池时代确实有人把 ThreadLocal 当缓存用(反正线程会复用),虚拟线程下这招彻底失效。缓存就老老实实用 ConcurrentHashMap 或者 Caffeine。
不要盲目调大下游连接池。前面那个 Hikari 的例子已经说明了,虚拟线程把瓶颈推到了下游,你加连接数只是把问题推给数据库。真正的解法是在自己的服务边界做限流和降级。
上线之后的实际数据
最终版本跑了一个月的生产环境,几个数字供参考。
平均响应时间从 420 毫秒降到 180 毫秒,P99 从 1.6 秒降到 520 毫秒。QPS 峰值从 900 提到 3100,机器配置没变。GC 方面,年轻代回收频率略微上升,因为虚拟线程的栈对象会在堆里分配和回收,但单次时间更短,整体停顿时间反而是下降的。
内存占用从 1.2GB 涨到了 1.6GB,主要多出来的就是虚拟线程的对象。这个代价可以接受。
唯一没变的是数据库负载。因为它本来就在满负荷跑,虚拟线程只是让上游不再排队,数据库该多忙还是多忙。
写在最后
虚拟线程不是银弹,它把”线程”这个资源的成本降下来之后,你的系统里其他所有资源的成本都会暴露出来。数据库连接、下游服务的吞吐、DNS 解析、日志写入、甚至操作系统文件描述符的数量,这些以前被线程池掩盖住的瓶颈,现在会一个个浮上来。
所以迁移的过程,本质上是在重新梳理一遍服务的资源依赖。这个过程会很烦,但梳理完之后,你对这个系统的理解会比之前清楚得多。我个人觉得这是比性能提升更有价值的部分。
如果手里的项目还在 Java 11 或者 17,建议先升到 21。虚拟线程、Record Pattern、Pattern Matching for switch 这些特性组合起来,对日常写业务代码的影响是实打实的。

