等了两年的正式版
JDK 21 在 9 月正式 GA,虚拟线程(Project Loom)终于从预览转正。作为常年和线程池、异步回调搏斗的人,我第一时间把内部一个 IO 密集的采集服务迁了过去。结果不算惊艳到颠覆,但确实把"一个请求一个线程"这种直观写法重新变可行了,迁移成本也比预期低。这里把原理、坑和收益一并记下来,也算是给还在观望的同事一个参考。
虚拟线程到底是什么
传统平台线程(Thread)直接映射操作系统线程,创建成本高,一个 JVM 开几千个就到顶了,所以我们才被迫用线程池复用、用异步回调减少占用。虚拟线程是 JVM 层的轻量实体,由调度器挂载到少量平台线程(叫载体线程,carrier)上运行。阻塞时,虚拟线程从载体卸载,载体立刻去跑别的虚拟线程。核心公式:平台线程数约等于 CPU 核数,虚拟线程数可以是百万级。
// 老写法:平台线程池,上限卡死
ExecutorService pool = Executors.newFixedThreadPool(200);
// 新写法:每个任务一个虚拟线程,按需创建
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
vt.submit(() -> fetchFromDb(orderId));
注意第二行是"每个任务一个新虚拟线程",不是池化。虚拟线程创建极便宜,池化反而错失了它的意义——你不需要池,因为创建成本可以忽略。
载体线程与 pin
调度器用 ForkJoinPool,默认并行度等于 CPU 核数。上面那批虚拟线程大部分时间在等数据库 IO,所以几十个载体就能喂饱。但有个坑叫 pin:当虚拟线程卡在 synchronized 块或类初始化锁里,它无法卸载,载体被占住。我们的采集逻辑恰好在 synchronized 里做批量写,压测时 64 个载体全被 pin 死,吞吐不升反降 15%。换成 ReentrantLock 后恢复。我后来在测试环境单测验证:4ms 的 synchronized 在 5000 并发下能让载体利用率到 100% 而吞吐趋近于零。
我的经验:迁移前用 jdk.tracePinnedThreads 扫一遍同步块密集的路径,优先改掉。否则省下的线程成本会被 pin 加倍还回去。另外,ThreadLocal 在虚拟线程下数量可能爆炸,别往里塞大对象,否则内存随虚拟线程数线性增长。
迁移成本
成本比想象低。因为我们早就遵循"不要池化虚拟线程"和"阻塞就交出去"的写法,框架层 Spring WebMvc 在 3.2 也能挂虚拟线程执行器。改动点主要是:
- 把 newFixedThreadPool 换成 newVirtualThreadPerTaskExecutor;
- 排查并替换关键路径上的 synchronized;
- 线程局部变量 ThreadLocal 用得多的地方要小心,虚拟线程量很大时别往里塞大对象,否则内存暴涨;
- 调度器不要用 ThreadPoolExecutor 包虚拟线程,那会破坏卸载语义。
收益实测
| 指标 | 平台线程池 | 虚拟线程 |
|---|---|---|
| 并发能力 | 200 上限 | 实测 5 万无压力 |
| P99 延迟 | 210ms | 95ms |
| 内存占用 | 每线程约 1MB | 每虚拟线程约 200B |
| 载体线程 CPU | 95% | 55% |
采集服务峰值从 200 并发提到 3 万,机器没加一台,CPU 反而降了,因为少了上下文切换。监控上载体线程 CPU 从 95% 降到 55%,说明调度余量充足,没有因为换实现而把 CPU 吃满。我还顺手测了下错误率,两者都是 0%,虚拟线程没有引入新的正确性问题。
哪些场景不适合
虚拟线程对 IO 密集型、大量并发等待的场景收益最大,但对计算密集型帮助有限——计算不阻塞,虚拟线程没有卸载的机会,优势无从发挥。我拿一个纯计算的报表接口测,吞吐几乎没变,反而因为调度层多了点开销略慢一点点。另外,重度依赖 ThreadLocal 做上下文传递的老框架(比如某些老版本的日志 MDC 传递)在虚拟线程下要重新验证,这也是我们迁移时费时间的地方。
小结
虚拟线程适合 IO 密集型、大量并发等待的场景,对计算密集型帮助有限。它最大的价值是让"一个请求一个线程"这种直观写法重新变得可行,不用再写回调地狱。但 pin 和 ThreadLocal 两件事,迁移前必须亲自验一遍,别被"免费并发"的标题骗了。我们已把它推广到三个 IO 密集服务,下一步看计算型服务是否值得上。如果你也在考虑,建议先挑一个 IO 等待明显的服务做试点,见效最快。