Administrator
发布于 2024-06-22 / 5389 阅读
34

JDK 22/23 虚拟线程改进与 pin 问题根治

去年用 JDK 21 虚拟线程接重构一个 HTTP 聚合服务,压测时吞吐死活上不去。翻 JFR 发现大量"虚拟线程被 pin 在载体线程上"的事件——罪魁是代码里随处可见的 synchronized。JDK 22/23 的改进,正好把这块骨头啃了下来。

Pin 是什么,为什么是性能杀手

虚拟线程的精髓是"阻塞就卸载",载体平台线程可以去跑别的虚拟线程。但一旦进入 synchronized 块,虚拟线程会被钉(pin)在载体线程上,阻塞时无法卸载,载体线程被白占。高并发下,成千上万个被 pin 的虚拟线程把平台线程池占满,吞吐量崩盘。JFR 里 jdk.VirtualThreadPinned 事件一抓一把。

JEP 444:虚拟线程正式转正

JDK 21(JEP 444)把虚拟线程从预览拉为正式特性,API 稳定,不用再加 --enable-preview。我们就是在这版上线虚拟线程的。但正式版里 synchronized 依然会 pin,这是当时最大的使用约束,文档里写得明白,我当初没当回事,吃了亏。

JEP 480 与 JDK 23:synchronized 不再 pin

关键变化在 JDK 23(彼时还是早期构建,预计 9 月 GA)的相关改进:虚拟线程进入 synchronized 时不再被 pin 住。底层把监视器(monitor)的实现与载体线程解耦,虚拟线程阻塞在 synchronized 上也能正常卸载。我用 JDK 23 早期构建重跑了同样的压测:

版本pin 事件/秒吞吐(聚合 QPS)
JDK 21(synchronized 仍 pin)约 42009,800
JDK 23 EA(synchronized 不 pin)041,000

同一份代码,吞吐翻了 4 倍多。之前为了绕 pin,我们手动把热点 synchronized 改成 ReentrantLock

// 旧:会 pin 虚拟线程
synchronized (cache) { return cache.get(k); }
// 新:ReentrantLock 不 pin
lock.lock();
try { return cache.get(k); } finally { lock.unlock(); }

JDK 23 之后,这种"为虚拟线程改锁"的妥协可以撤掉了,synchronized 重新能用,代码更简洁。

迁移收益不止于锁

pin 问题解决后,两个衍生收益:

  • 数据库驱动:以前 JDBC 里 synchronized 多的老驱动会 pin,现在无碍,连老一点的连接池都能直接上虚拟线程;
  • 代码可维护性:不用满屏找 synchronized 改成 Lock,新人也不会再踩 pin 的坑,心智负担降一截。

仍要注意的边界

不 pin 不等于能乱用。在 synchronized 块里做 IO 或长时间计算,虽然虚拟线程能卸载,但锁本身还是互斥的——它依然会阻塞其他想进同一锁的虚拟线程。所以"短临界区"的原则没变,只是不再附带 pin 这个额外惩罚。Native 方法里的 synchronized 仍有边界情况,迁移后我们仍跑了 JFR 确认 VirtualThreadPinned 归零。

写在后面

现在回头看,《JDK 22/23 虚拟线程改进与 pin 问题根治》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考