Administrator
发布于 2023-10-09 / 11711 阅读
289

JDK 21 虚拟线程正式发布:一次彻底的并发模型升级

等了两年的正式版

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 延迟210ms95ms
内存占用每线程约 1MB每虚拟线程约 200B
载体线程 CPU95%55%

采集服务峰值从 200 并发提到 3 万,机器没加一台,CPU 反而降了,因为少了上下文切换。监控上载体线程 CPU 从 95% 降到 55%,说明调度余量充足,没有因为换实现而把 CPU 吃满。我还顺手测了下错误率,两者都是 0%,虚拟线程没有引入新的正确性问题。

哪些场景不适合

虚拟线程对 IO 密集型、大量并发等待的场景收益最大,但对计算密集型帮助有限——计算不阻塞,虚拟线程没有卸载的机会,优势无从发挥。我拿一个纯计算的报表接口测,吞吐几乎没变,反而因为调度层多了点开销略慢一点点。另外,重度依赖 ThreadLocal 做上下文传递的老框架(比如某些老版本的日志 MDC 传递)在虚拟线程下要重新验证,这也是我们迁移时费时间的地方。

小结

虚拟线程适合 IO 密集型、大量并发等待的场景,对计算密集型帮助有限。它最大的价值是让"一个请求一个线程"这种直观写法重新变得可行,不用再写回调地狱。但 pin 和 ThreadLocal 两件事,迁移前必须亲自验一遍,别被"免费并发"的标题骗了。我们已把它推广到三个 IO 密集服务,下一步看计算型服务是否值得上。如果你也在考虑,建议先挑一个 IO 等待明显的服务做试点,见效最快。

参考