上周项目从 JDK17 升级到 JDK21,兴冲冲地用上了虚拟线程,结果压测一跑,IO 密集接口的 QPS 居然比原来用平台线程的时候还低。折腾了两天才找到原因------原来我把虚拟线程用成了"穿了马甲的平台线程"。这篇文章不教你什么是虚拟线程,只讲我踩过的三个坑,以及怎么绕过。
背景交代:为什么我想用虚拟线程
先说说我踩坑的背景。项目是个典型的微服务,调用的下游接口多、IO 等待时间长。以前用 ThreadPoolExecutor,200 个线程撑死,再多 JVM 就开始报警。
看到 JDK21 的虚拟线程介绍,我的第一反应是:终于不用纠结线程池大小了。
官方文档说虚拟线程的创建成本极低,底层阻塞 IO 时会自动让出平台线程,不需要池化复用。我当时想当然地认为,只要把 ThreadPoolExecutor 换成虚拟线程,问题就解决了。
结果证明,我too young too simple。
第一个坑:我把虚拟线程塞进了线程池
踩坑过程
看到项目里到处是这种代码:
ini
ExecutorService executor = Executors.newFixedThreadPool(200,
Thread.ofVirtual().factory());
我想都没想,直接把平台线程池换成了虚拟线程池。心想:反正都是线程池,换个实现方式而已。
然后兴冲冲地部署、压测。结果------
diff
压测结果:
- 平台线程池(200线程):QPS 1200,平均响应时间 85ms
- 虚拟线程池(200虚拟线程):QPS 1150,平均响应时间 92ms
WTF?虚拟线程反而更慢了?
排查过程
翻了半天才找到官方文档里这句话:
Virtual threads should not be pooled. A new virtual thread should be created for each task.
翻译成人话就是:虚拟线程设计出来就不是给你池化用的。
虚拟线程的核心机制是:阻塞 IO 时自动把底层的平台线程(叫 Carrier Thread)让出来,让它去执行其他虚拟线程。如果你把虚拟线程池化,池里的虚拟线程被固定复用,阻塞时载体线程也被卡住,根本没法动态调度。
简单说就是:你用池化的方式,把虚拟线程"用成了"平台线程,还多了一层调度开销。
正确写法
ini
// 错误写法(我就是这么写的)
ExecutorService pool = Executors.newFixedThreadPool(100,
Thread.ofVirtual().factory());
// 正确写法:不需要池化
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
每个任务创建一个虚拟线程,用完即弃,JVM 自己调度。
第二个坑:synchronized 导致的线程"钉住"
又一个翻车现场
换了写法之后,QPS 确实上去了,跑到了 1800。但过了一会儿,运维过来说:机器 CPU 使用率异常。
我去查线程 dump,发现一堆线程处于 Parking 状态,堆栈里全是 synchronized 关键字。
原因分析
虚拟线程有个"钉住"(Pinning)机制:如果在 synchronized 块里执行阻塞操作,虚拟线程无法从载体线程上卸载,导致载体线程被"钉住"。
来看个典型场景:
typescript
public class MyService {
// 很多老代码里都有这种写法
private final Object lock = new Object();
public String callExternalAPI(String request) {
synchronized (lock) { // 这里钉住了!
return httpClient.post()
.uri("/api")
.body(request)
.retrieve()
.body(String.class); // 阻塞 IO
}
}
}
在 synchronized 块里调 HTTP 接口,虚拟线程无法卸载,载体线程被占着,CPU 空转。
正确做法
- 用 ReentrantLock 替代 synchronized
csharp
private final Lock lock = new ReentrantLock();
public String callExternalAPI(String request) {
lock.lock();
try {
return httpClient.post()
.uri("/api")
.body(request)
.retrieve()
.body(String.class);
} finally {
lock.unlock();
}
}
ReentrantLock 支持可中断获取、超时获取,虚拟线程调用时会正确挂起。
- 缩短 synchronized 临界区
如果没法改锁,把 IO 操作移到临界区外面:
scss
// 错误
synchronized (lock) {
result = httpClient.post().uri("/api").retrieve().body();
}
// 正确:临界区只保护共享状态
String response = httpClient.post().uri("/api").retrieve().body();
synchronized (lock) {
sharedState = parse(response);
}
- 开启钉住检测
启动参数加这一行,可以看到哪些代码触发了钉住:
ini
java -Djdk.tracePinnedThreads=full -jar app.jar
运行时遇到钉住,控制台会打印具体堆栈。
第三个坑:ThreadLocal 跨请求残留
诡异的事故
换了写法、调了锁之后,线上突然出现了一些诡异的数据错乱。有个接口,A 用户查出来的数据是 B 用户的。
排查了半天,发现是 ThreadLocal 捣的鬼。
原因分析
以前一个用户请求绑定一个平台线程,ThreadLocal 里存的用户上下文不会串。
但虚拟线程是 M:N 调度,同一个载体线程可能被多个虚拟线程共享。虚拟线程用完即弃,如果 ThreadLocal 没清理干净,可能被下一个虚拟线程读到。
更坑的是,虚拟线程数量多、生命周期短,ThreadLocal 的内存占用会被放大。
正确做法
JDK 21 提供了 ScopedValue,专门替代 ThreadLocal:
arduino
public class RequestContext {
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public static String currentUserId() {
return USER_ID.get();
}
public static void main(String[] args) {
// 在 ScopedValue的作用域内执行
ScopedValue.runWhere(USER_ID, "user123", () -> {
ScopedValue.runWhere(TRACE_ID, "trace-abc", () -> {
// 这里是同一个作用域
System.out.println("User: " + RequestContext.currentUserId());
System.out.println("Trace: " + RequestContext.currentTraceId());
});
});
}
}
ScopedValue 的特点是:
- 作用域绑定:值只在当前作用域有效,出了作用域自动清理
- 不可继承:子线程/子作用域拿不到
- 内存安全:不存在跨请求残留问题
如果暂时不想迁移,InheritableThreadLocal 加上虚拟线程工厂也能用,但有隐患,不推荐。
最终效果
修复完三个坑之后,压测结果:
yaml
平台线程池(200线程):QPS 1200,平均响应时间 85ms
虚拟线程(错误用法):QPS 1150,平均响应时间 92ms
虚拟线程(正确用法):QPS 3200,平均响应时间 28ms
提升还是很明显的,前提是用对。
总结:虚拟线程的正确打开方式
| 错误做法 | 正确做法 |
|---|---|
| 把虚拟线程塞进线程池 | 用 newVirtualThreadPerTaskExecutor(),每个任务一个线程 |
| synchronized 块里做 IO | 用 ReentrantLock,或把 IO 移出临界区 |
| 继续用 ThreadLocal | 迁移到 ScopedValue(JDK 21+) |
还有一个原则:虚拟线程只适合 IO 密集型场景 。CPU 密集型任务(加密、计算、压缩),老老实实用 ForkJoinPool,虚拟线程帮不上忙。