从 2023 年 9 月到今天,虚拟线程我们用了两年半
JDK 21 发布是 2023 年 9 月,我们当年 11 月就把一个内部服务切到了虚拟线程,算是比较早的一批。到今天两年半,虚拟线程跑在我们 14 个服务上,处理过峰值 4.7 万 QPS 的流量,也引发过两次 P2 故障。
这篇不写原理,只写结论:哪些场景我们确认好用,哪些坑我们踩过,以及现在和传统线程池怎么共存。
先说结论:适用场景收敛到两类
两年下来,虚拟线程在我们这里的适用场景收敛得非常窄,就两类:
| 场景 | 收益 | 我们的实际数据 |
|---|---|---|
| 阻塞式 IO 密集型(HTTP 调用、DB 查询、RPC) | 极大 | 网关聚合服务 800 线程 → 虚拟线程,P99 从 840ms 降到 130ms |
| 批量并行任务(扇出调用、数据同步) | 大 | 对账批处理从 47 分钟降到 6 分钟 |
| CPU 密集型计算 | 无收益,有害 | 规则引擎压测反而降了 3% |
| 长连接保持(WebSocket、SSE) | 不适合 | 换回 EventLoop,内存降 62% |
中间那行「CPU 密集型无收益」值得展开。虚拟线程在 CPU 密集场景下不会变快,只会白白多一层调度开销。我们有个规则引擎服务,同事看虚拟线程「很火」,一把全切了,压测结果:
# 32 核机器,纯计算任务,各跑 5 分钟
平台线程池(32) : 吞吐量 18,400 ops/s CPU 96% P99 42ms
虚拟线程(无界) : 吞吐量 17,850 ops/s CPU 99% P99 118ms
降了 3% 吞吐,P99 涨了近三倍。原因很简单:虚拟线程数量不受控,几万个任务同时抢 32 个核,调度队列变长,尾延迟恶化。这个服务我们一周后就切回去了。
坑一:synchronized 导致的线程固定
这是 JDK 21 时代最著名的问题,JDK 24 之后(JEP 491)基本解决了。但我们是在 JDK 21 上线的,踩得很实。
现象是网关服务切到虚拟线程后,压测到 3000 QPS 就上不去了,CPU 才 40%,线程数卡在 256 不动。那 256 看着眼熟,就是 ForkJoinPool 的默认并行度,也就是承载虚拟线程的平台线程数。
原因:代码里有个本地缓存用了 synchronized 方法,虚拟线程进入 synchronized 块后无法卸载,会一直占用平台线程(pinning)。定位手段是 JFR 的 jdk.VirtualThreadPinned 事件:
java -XX:+EnableDynamicAgentLoading \
-Djdk.tracePinnedThreads=full \
-jar gateway.jar
# 输出
Thread[#82,ForkJoinPool-1-worker-3,5,CarrierThreads]
com.internal.cache.LocalCache.get(LocalCache.java:47) <== monitors:1
com.internal.gateway.RouteFilter.doFilter(RouteFilter.java:88)
或者在压测时开 JFR 更准:
jcmd <pid> JFR.start name=vt \
settings=profile \
duration=60s filename=/tmp/vt.jfr
jfr summary /tmp/vt.jfr | grep -i pinned
# jdk.VirtualThreadPinned 184,722 events duration sum = 41.2s
60 秒内有 41 秒处在 pinning 状态,等于白切了。修复方式是把 synchronized 换成 ReentrantLock:
// 改前
public synchronized V get(K key) { ... }
// 改后
private final ReentrantLock lock = new ReentrantLock();
public V get(K key) {
lock.lock();
try { return doGet(key); }
finally { lock.unlock(); }
}
改完之后同样压测,QPS 从 3000 上到 1.2 万,CPU 68%,pinning 事件归零。
现在(JDK 21/25 生产基线)这个问题已经不是问题了。JEP 491 在 JDK 24 里改写了 synchronized 的实现,虚拟线程在监视器里也能卸载。我们 JDK 25 的服务上再跑 JFR,jdk.VirtualThreadPinned 事件数量是 0。但如果你还在 JDK 21,这仍然是第一排查项。
坑二:ThreadLocal 的内存放大
这个坑比 pinning 隐蔽,我们付出的代价也更大。
2024 年 6 月,一个批处理服务切到虚拟线程后,堆内存从 2G 涨到 7G 然后 OOM。这个服务一次处理 40 万条数据,每条起一个虚拟线程。
堆转储分析下来,是 ThreadLocal。我们有个链路追踪的自定义 ThreadLocal,存了一个约 3 KB 的上下文对象。平台线程时代线程池只有 50 个线程,50 × 3 KB = 150 KB,完全无所谓。虚拟线程时代同时存在 40 万个虚拟线程,40 万 × 3 KB = 1.2 GB。
$ jcmd <pid> GC.heap_dump /tmp/dump.hprof
$ jhat ... # 或者直接用 MAT
# MAT 的 dominator tree 顶部
java.lang.Thread 4.1 GB (38%)
└─ java.lang.VirtualThread 4.0 GB
└─ java.lang.ThreadLocal$ThreadLocalMap 3.9 GB
└─ com.internal.trace.TraceContext 1.2 GB (412,318 instances)
更麻烦的是虚拟线程本身也有成本:每个虚拟线程的栈是按需增长的 StackChunk 对象,40 万个并发时这部分占了另外 2 GB 多。
我们做了两件事。第一,把 ThreadLocal 换成 ScopedValue(JDK 25 已转正):
// ScopedValue 的生命周期是「作用域」,不是「线程」
private static final ScopedValue<TraceContext> TRACE = ScopedValue.newInstance();
void handle(Record record) {
ScopedValue.where(TRACE, buildContext(record))
.run(() -> process(record)); // run 结束时自动解绑
}
void process(Record record) {
TraceContext ctx = TRACE.get(); // 只在作用域内可见
}
ScopedValue 是不可变的,且绑定随作用域结束自动释放,不会跟着虚拟线程的生命周期一直挂着。
第二,也是更关键的:不要无节制地起虚拟线程。「虚拟线程便宜所以可以随便起」这句话害人不浅。便宜是相对的,起几十万个照样撑爆内存。我们加了个信号量控制并发上限:
// 批处理并发控制在 5000,不是 40 万
try (var executor = Executors.newVirtualThreadPerTaskExecutor();
var sem = new Semaphore(5000)) {
for (Record r : records) {
sem.acquire();
executor.submit(() -> {
try { handle(r); }
finally { sem.release(); }
});
}
}
并发从 40 万降到 5000,内存峰值从 7G 降到 2.3G,总耗时反而从 38 分钟降到 31 分钟——因为原来 40 万个任务抢下游连接池,反而大量超时重试。
坑三:连接池才是真瓶颈,虚拟线程不解决它
这是最容易误解的一点。很多人以为切了虚拟线程,并发能力就无限了。不对,瓶颈只是从「线程数」转移到了「下游连接数」。
我们的订单聚合服务切完之后压测:
| 并发 | QPS | P99 | DB 连接池等待 |
|---|---|---|---|
| 500 | 1,840 | 96ms | 0.4ms |
| 2,000 | 3,120 | 210ms | 18ms |
| 5,000 | 3,090 | 1,240ms | 412ms |
| 10,000 | 2,760 | 3,800ms | 1,860ms |
QPS 在 2000 并发就到顶了,再往上不仅不涨,P99 爆炸。原因就是 HikariCP 连接池只有 50 个连接,5000 个虚拟线程排队等 50 个连接,等待时间直接进了 P99。
这件事的本质是:虚拟线程把「线程不够用」这个瓶颈消除了,然后立刻暴露出下一个瓶颈。我们最后把连接池从 50 提到 200,同时给数据库加了读写分离,QPS 才上到 6800。
顺便说一句,HikariCP 在虚拟线程下的一个坑:connectionTimeout 默认 30 秒,高并发下大量虚拟线程堆积在等待队列,30 秒后集体超时,形成「超时风暴」。我们把它降到 3 秒,配合快速失败,反而更稳:
spring:
datasource:
hikari:
maximum-pool-size: 200
connection-timeout: 3000 # 从默认 30000 降下来
validation-timeout: 1000
leak-detection-threshold: 10000
共存策略:两套线程池,按任务类型分派
现在我们服务的标准配置是虚拟线程和平台线程并存,不是二选一。分工很清楚:
@Configuration
public class ExecutorConfig {
/** IO 密集:HTTP 调用、DB 查询、RPC —— 走虚拟线程 */
@Bean("ioExecutor")
ExecutorService ioExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/** CPU 密集:加解密、规则计算、序列化 —— 走固定平台线程池 */
@Bean("cpuExecutor")
ExecutorService cpuExecutor() {
int n = Runtime.getRuntime().availableProcessors();
return Executors.newFixedThreadPool(n,
new ThreadFactoryBuilder()
.setNameFormat("cpu-worker-%d")
.setUncaughtExceptionHandler((t, e) -> log.error(t.getName(), e))
.build());
}
}
选型的判断标准只有一个:任务会不会阻塞等待。会阻塞(等 IO)就用虚拟线程,纯计算就用平台线程。这条规则简单到近乎粗暴,但两年下来没出过错。
还有一类特殊场景:需要 ThreadLocal 做线程池隔离的(比如某些老的三方库依赖 ThreadLocal 传递上下文),我们也保留平台线程池,不做强迁。
结构化并发:治好了我们的超时泄漏
最后说一个 2025 年才用上的东西。扇出调用我们用 CompletableFuture 写了两年,一直有个问题:子任务超时了,父任务不知道,还在傻等。
// 老写法:taskA 超时抛异常后,taskB 仍在后台跑,没人管
CompletableFuture<A> fa = supplyAsync(() -> callA(), ioExecutor);
CompletableFuture<B> fb = supplyAsync(() -> callB(), ioExecutor);
A a = fa.get(2, TimeUnit.SECONDS); // 超时了,fb 还在跑
B b = fb.get(2, TimeUnit.SECONDS);
换成结构化并发(JDK 25 已第五次预览,API 稳定可用):
try (var scope = StructuredTaskScope.open(
Joiner.<Result>allSuccessfulOrThrow()
.timeout(Duration.ofSeconds(2)))) {
Subtask<A> ta = scope.fork(() -> callA());
Subtask<B> tb = scope.fork(() -> callB());
scope.join(); // 任一失败或超时,整个作用域取消
return combine(ta.get(), tb.get());
}
// 出了 try 块,所有子任务保证已结束,不会泄漏
压测下的差异很明显:在 20% 下游超时的故障注入场景,老写法会有残留任务持续占用连接,30 秒后连接池耗尽;结构化并发下任务被及时取消,连接池水位稳定在 40% 左右。
唯一的问题是 API 还在预览,需要 --enable-preview。我们现在的做法是:新代码用结构化并发,但集中封装在一个 FanoutTemplate 类里,API 真变了只改一处。
先到这
《虚拟线程三年:生产落地的经验与教训》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。