商品详情页是个典型的聚合接口。一个请求进来,得同时去五个下游拿数据:商品基础信息、价格、库存、前五条评价、八个相似推荐。谁先回来先不算,反正最后要拼成一个对象返回。
这段代码在项目里躺了两年多,一直是 Executors.newFixedThreadPool(50) 加五个 Future。平时没问题,大促一压流量,监控上就开始冒红线。排查下来根源不复杂:一个请求吃掉 5 个线程,50 个线程撑死同时处理 10 个请求。第 11 个请求的五个任务只能排队等着,而队列里排队的请求,它自己的 800ms 超时还在滴答滴答地走。等轮到它执行的时候,预算已经烧光了。
虚拟线程正好治这个病,改造量也不大。但真动手才发现,把线程池换掉只是第一步,后面还有三个更隐蔽的问题在等着。这篇把整个过程捋一遍。
一、先看看老代码到底卡在哪
改造前的样子,估计很多人都不陌生:
@Service
public class ProductDetailService {
private static final long BUDGET_MS = 800;
private final ExecutorService pool = Executors.newFixedThreadPool(50);
public ProductDetail detail(long skuId) throws Exception {
Future<Product> pf = pool.submit(() -> productClient.get(skuId));
Future<BigDecimal> prf = pool.submit(() -> priceClient.get(skuId));
Future<Integer> sf = pool.submit(() -> stockClient.get(skuId));
Future<List<Review>> rf = pool.submit(() -> reviewClient.top(skuId, 5));
Future<List<Product>> rcf = pool.submit(() -> recommendClient.similar(skuId, 8));
ProductDetail d = new ProductDetail();
d.setProduct(pf.get(BUDGET_MS, TimeUnit.MILLISECONDS));
d.setPrice(prf.get(BUDGET_MS, TimeUnit.MILLISECONDS));
d.setStock(sf.get(BUDGET_MS, TimeUnit.MILLISECONDS));
d.setReviews(rf.get(BUDGET_MS, TimeUnit.MILLISECONDS));
d.setRecommends(rcf.get(BUDGET_MS, TimeUnit.MILLISECONDS));
return d;
}
}
这段代码有三个毛病,只是平时流量小看不出来。
毛病的核心是线程池容量和请求并发被绑死了。五个下游各自阻塞在 IO 上,平台线程也跟着一起阻塞。线程池只有 50 个坑,就意味着系统同时最多只能处理 10 个详情请求。想扛 100 个并发?把池子开到 500。可 500 个平台线程意味着 500 个栈,每个栈默认 1MB,光栈内存就吃掉 500MB,而且线程切换的开销会肉眼可见地变大。这就是典型的”用线程数换并发”,代价是内存和调度。
第二个毛病是这个 800ms 是逐个累加的。五个 get 串着写,最坏情况下总耗时能拖到 4 秒——第一个卡了 790ms,第二个卡了 790ms,一路累下去。写代码的时候想着”每个调用最多 800ms”,但业务方看到的是整个接口的响应时间。
第三个毛病是异常路径没人管。假设 pf.get() 抛了异常,方法直接往外冒,后面四个 Future 的任务照跑不误。它们的返回值永远没人取,连接池里的连接也要等任务自己跑完才还回去。低峰期无所谓,高峰期这就是白白的资源占用。
二、第一步:把平台线程换成虚拟线程
虚拟线程在 JDK 21 已经转正(JEP 444),不需要加 --enable-preview,直接就能用。改起来就一行:
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
对,就这一行。整个 detail 方法一个字都不用动,它调的还是 ExecutorService 那套接口。
这里有个细节得留意。newVirtualThreadPerTaskExecutor() 名字里带 Executor,但它不是线程池,它不池化任何东西。每提交一个任务就开一个新的虚拟线程,任务跑完线程就结束。之所以还保留 ExecutorService 的接口,纯粹是为了让老代码能平滑迁移。所以千万别再写 newFixedThreadPool(50) 那种思路去调它的并发度——那等于把虚拟线程当平台线程用,好处一点没捞着。
虚拟线程为什么能撑住?因为它阻塞的时候会把底下的载体线程(carrier thread)让出来。载体线程的数量默认等于 Runtime.getRuntime().availableProcessors(),8 核机器上就 8 个。你这 5 个任务全都在等 HTTP 响应,载体线程早被让出来去跑别的虚拟线程了。所以从 10 个并发请求跃升到几万个,内存代价只是每个虚拟线程那几百字节的初始栈——注意是初始栈,虚拟线程的栈是按需增长的,不是一上来就分配 1MB。
改完之后压了一遍。单次请求的耗时没什么变化,这是意料之中的:本来五个调用就是并行的,最慢的那个决定总时长,换线程模型不会让它变快。真正变化的是高并发下的 p99 和吞吐天花板。之前 300 并发的时候 p99 已经飘到 2.3 秒,改造后同样的压力下稳在 900ms 出头,QPS 从 320 提到了 1100 左右。瓶颈已经从”线程不够用”转移到了下游服务本身。
三、坑一:下游被自己的虚拟线程打穿了
QPS 上去的当天下午,价格服务那边找过来了。他们的接口 QPS 涨了四五倍,有几台机器 CPU 直接打满。
问题出在虚拟线程把背压彻底去掉了。
以前有线程池挡着,50 个线程就是硬上限,超出的请求在队列里排队,下游收到的并发量是可控的。现在虚拟线程来者不拒,来 2000 个请求就开 10000 个虚拟线程,全都往价格服务上招呼。这不是虚拟线程的错,这是我自己的错——并发控制这个东西,不能指望线程池帮你做,得自己显式地管。
最简单的办法是给每个下游加一个信号量,做成舱壁隔离:
private final Semaphore priceGate = new Semaphore(40);
private final Semaphore recommendGate = new Semaphore(60);
private final Semaphore reviewGate = new Semaphore(80);
private BigDecimal fetchPrice(long skuId) {
try {
priceGate.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while waiting for price gate", e);
}
try {
return priceClient.get(skuId);
} finally {
priceGate.release();
}
}
数字怎么定?不是拍脑袋想的,是照着下游连接池的最大连接数往下压一点。价格服务的 HTTP 连接池 maxTotal 是 50,那这边就给 40,留点余量给其他调用方。如果拍脑袋写个 200,那你只是把压垮下游的位置从线程池挪到了信号量,本质没变。
这里还有个更隐蔽的问题:你的 800ms 超时,是从 fork 那一刻开始算的,不是从真正发起 HTTP 请求那一刻开始算的。虚拟线程在 priceGate.acquire() 上等待的那段时间,也在消耗你的预算。极端情况下,一个请求等了 700ms 才拿到通行证,剩下 100ms 去请求下游,必然超时。
所以信号量的数量必须留足,让等待时间控制在一个很短的水平。上线前我是这么验的:给信号量加了个等待耗时的埋点,压测时观察 p99,如果它超过 50ms,说明闸门开小了。
四、坑二:CompletableFuture.cancel 不会中断你的任务
把并发度控制好之后,我开始处理前面提到的”异常路径无人回收”的问题。首先想到的是把五个 Future 收拢起来统一取消。因为要处理超时和异常两种路径,顺手改写成了 CompletableFuture:
public ProductDetail detail(long skuId) {
var pf = CompletableFuture.supplyAsync(() -> productClient.get(skuId), pool);
var prf = CompletableFuture.supplyAsync(() -> priceClient.get(skuId), pool);
var sf = CompletableFuture.supplyAsync(() -> stockClient.get(skuId), pool);
var rf = CompletableFuture.supplyAsync(() -> reviewClient.top(skuId, 5), pool);
var rcf = CompletableFuture.supplyAsync(() -> recommendClient.similar(skuId, 8), pool);
try {
CompletableFuture.allOf(pf, prf, sf, rf, rcf)
.get(BUDGET_MS, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
cancelAll(pf, prf, sf, rf, rcf);
throw new DetailTimeoutException(skuId);
} catch (ExecutionException e) {
cancelAll(pf, prf, sf, rf, rcf);
throw new DetailFetchException(skuId, e.getCause());
} catch (InterruptedException e) {
cancelAll(pf, prf, sf, rf, rcf);
Thread.currentThread().interrupt();
throw new DetailFetchException(skuId, e);
}
ProductDetail d = new ProductDetail();
d.setProduct(pf.join());
d.setPrice(prf.join());
d.setStock(sf.join());
d.setReviews(rf.join());
d.setRecommends(rcf.join());
return d;
}
private void cancelAll(CompletableFuture<?>... fs) {
for (CompletableFuture<?> f : fs) {
f.cancel(true);
}
}
然后就被现实教育了。
我特意加了个日志观察取消的效果,结果发现:接口早早返回了 504,可后台的虚拟线程还在一个个往日志里写”请求成功”。查了下才发现,CompletableFuture.cancel(boolean mayInterruptIfRunning) 的 javadoc 里有一句话写得很直白——这个 mayInterruptIfRunning 参数在实现里是不生效的。也就是说 cancel(true) 和 cancel(false) 行为一样,它只是把 Future 标记成已取消、让还在 get 上等的线程立刻拿到 CancellationException,但它不会去中断那个正在跑的任务。
后果就是:主线程早就返回了,下面五个虚拟线程还在慢悠悠地把 HTTP 请求跑完。请求打到了下游、占着连接池的连接、写日志、最后把结果丢弃。原本想省资源,结果一点没省。
要真正中断,只能靠底层客户端自己的超时。你用的 HTTP 客户端是什么,就去它那边配:
// Apache HttpClient 5
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(300))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(200))
.setResponseTimeout(Timeout.ofMilliseconds(600))
.build();
这三个超时都要配,缺一个都能出问题。connectTimeout 管 TCP 握手,connectionRequestTimeout 管从连接池里拿连接,responseTimeout 管数据读取。特别是中间那个,很多人会漏掉——池子满了的时候,虚拟线程会在拿连接这一步一直等下去,而这一等是没有上限的。
顺带说一句,ExecutorService.invokeAll(tasks, timeout, unit) 在超时的时候是真的会调用每个未完成 Future 的 cancel(true) 的,行为比 CompletableFuture 那套清晰。但同样地,底层任务能不能真的停下来,还是取决于客户端有没有超时。这一点上没有捷径。
五、坑三:synchronized 把虚拟线程钉在了载体上
改完并发和超时,我开了个 JFR 想看看虚拟线程的整体情况,结果发现了第三个问题。
监控里有个明显反常的现象:明明大部分时间都在等 IO,可载体线程池的活跃度一直很高,甚至偶尔出现虚拟线程排队等载体的告警。理论上虚拟线程在阻塞时会卸载,载体不应该这么忙才对。
JDK 有个专门的 JFR 事件 jdk.VirtualThreadPinned,默认只记录阻塞超过 20ms 的情况,开启命令是:
-XX:StartFlightRecording:jdk.VirtualThreadPinned#enable=true,filename=app.jfr,duration=300s
跑完用 jfr print --events jdk.VirtualThreadPinned app.jfr 一看,栈顶重复出现同一个方法——一个加在缓存工具类上的 synchronized 方法,方法体里调了一次远程调用。
这就是经典的线程钉住(pinning)。虚拟线程在 synchronized 块里发起阻塞操作时,它无法从载体线程上卸载,只能把载体一起拖住。载体总共就那么几个(等于 CPU 核数),被钉住几次,整个调度器的吞吐就塌了。这种情况下虚拟线程不但没有优势,反而比平台线程更糟——平台线程至少不会因为”载体不够”而互相拖累。
修起来不难,把 synchronized 换成 ReentrantLock 就行。ReentrantLock 走的是 java.util.concurrent 那套,虚拟线程在 lock() 上等待时会正常卸载。
不过这里有个版本相关的信息很重要。如果你用的是 JDK 21 到 23,synchronized 导致的钉住是真实存在的,必须一个个清理。但从 JDK 24 开始,JEP 491 已经解决了这个问题,虚拟线程在 synchronized 块里阻塞时也能正常卸载了。所以升级 JDK 本身就是一个解法,不一定要改代码。
另外还有几种情况仍然会钉住,这些在 JDK 24 之后依然存在:调用 JNI 或者本地方法的时候(比如某些老的加密库、压缩库);类初始化阶段(<clinit>)里的阻塞操作;以及一些用了 Object.wait() 的老代码。这几类没法通过换锁解决,只能从调用链上绕开。
除了 JFR,还有个更快的排查手段:启动时加上 -Djdk.tracePinnedThreads=full,一旦发生钉住就直接把栈打出来。这东西开销不小,只适合在测试环境用,排查完记得去掉。
六、把 ThreadLocal 也顺手换掉
改造过程中还顺手处理了一个隐患。项目里用 ThreadLocal 存链路追踪的 traceId。以前平台线程是复用的,一个线程池里就那 50 个实例,ThreadLocal 副本也就 50 份,无所谓。
现在换成虚拟线程,每个请求都是全新的线程,用完就丢。ThreadLocal 那个 ThreadLocalMap 也跟着线程一起创建和销毁。并发量一上来,这个创建/销毁的频率就变成了实打实的开销,而且如果代码里忘了调 remove(),虽然线程结束后会被 GC,但那个 ThreadLocalMap 里的值是强引用,容易造成短暂的内存压力。
JDK 25 已经把 Scoped Value 转正了(JEP 506),正好适合这个场景。它天生就是为虚拟线程设计的:不可变、不用手动清理、子任务自动继承,而且不会给每个虚拟线程复制一份。
public final class TraceContext {
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
private TraceContext() {}
}
// 在入口处绑定
ScopedValue.where(TraceContext.TRACE_ID, generateTraceId()).run(() -> {
// 这个 lambda 里面,以及它派生出去的所有子任务,都能读到同一个 traceId
return detailService.detail(skuId);
});
// 在下游调用里读取
String traceId = TraceContext.TRACE_ID.get();
要注意 ScopedValue 是只读的,绑定之后在作用域内没法改。如果业务上真的需要可变的状态,那还是得用 ThreadLocal,但一定要在 finally 里 remove()。
七、如果愿意吃预览特性,结构化并发会更顺手
前面那版 CompletableFuture 的写法能跑,但说实话读起来别扭。五个 Future 得手动收集、手动取消、手动 join,异常还得一层层 catch。这活儿本质上就是 StructuredTaskScope 要解决的问题。
需要说明的是,截至 JDK 25,结构化并发还是预览特性(JEP 505),API 在几个预览版本之间改过名字,用之前务必对照当时的 Javadoc。所以下面这段我只在内部工具和测试环境用,核心链路还在跑上面那版。
public ProductDetail detail(long skuId) throws Exception {
try (var scope = StructuredTaskScope.open(
Joiner.<ProductDetail>awaitAllSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(BUDGET_MS)))) {
Subtask<Product> pt = scope.fork(() -> productClient.get(skuId));
Subtask<BigDecimal> prt = scope.fork(() -> priceClient.get(skuId));
Subtask<Integer> st = scope.fork(() -> stockClient.get(skuId));
Subtask<List<Review>> rt = scope.fork(() -> reviewClient.top(skuId, 5));
Subtask<List<Product>> rct = scope.fork(() -> recommendClient.similar(skuId, 8));
scope.join();
ProductDetail d = new ProductDetail();
d.setProduct(pt.get());
d.setPrice(prt.get());
d.setStock(st.get());
d.setReviews(rt.get());
d.setRecommends(rct.get());
return d;
}
}
它好在哪?
第一,超时是整个作用域共享的,不再是每个 get 各算各的。五个任务共享同一个 800ms 的截止时间,语义清晰多了。
第二,任何一个任务失败,其余任务会被自动取消。awaitAllSuccessfulOrThrow 这个 Joiner 看到第一个异常就触发 shutdown,剩下的任务收到取消信号。不用再手写 cancelAll。
第三,try-with-resources 保证了不管怎么退出,作用域都会被正确关闭。忘了 join() 也没关系,close() 会替你补上。
如果希望的语义是”五个里只要三个成功就能拼出一个可用的降级页面”,那就换成 Joiner.awaitAll() 配自定义的 Joiner,比用 CompletableFuture 手搓要干净得多。
八、上线前的检查清单
这套改造目前在线上跑了三个多月,把容易翻车的地方整理成一个清单,改类似接口的时候可以照着过一遍。
- 信号量数量对齐下游连接池,别拍脑袋定。加个等待耗时埋点,p99 超过 50ms 就说明开小了。
- HTTP 客户端的三个超时都要配,尤其是
connectionRequestTimeout,它决定了虚拟线程在连接池排队时能等多久。 - 别指望
CompletableFuture.cancel能中断任务,它做不到。真正的停止靠的是底层客户端的超时。 - JDK 21 到 23 用 JFR 的
jdk.VirtualThreadPinned事件扫一遍,把热路径上的synchronized换成ReentrantLock。JDK 24 之后这项可以放宽。 - 监控里的
jvm.threads.live只统计平台线程,虚拟线程不在里面。压测时别看着这个指标纹丝不动就以为限流生效了,得用 JFR 或者jcmd <pid> Thread.dumpToFile来看。 - 虚拟线程不要池化。看到
newFixedThreadPool的写法就改掉,一个任务一个虚拟线程才是正确用法。 - 给虚拟线程起个名字,比如
Thread.ofVirtual().name("detail-", 0).factory()。出问题 dump 下来的时候,有名字和没名字是两个排查难度。
最后说个体会。虚拟线程解决的是”阻塞型代码写起来自然,但平台线程太贵”这个矛盾,它让现有的同步代码原封不动地获得了高并发能力。但它不会自动帮你做背压,也不会自动帮你处理超时。这两件事以前是线程池顺手帮你干了,现在得自己接手。想明白这一点,改造就不会走偏。

