去年用 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) | 约 4200 | 9,800 |
| JDK 23 EA(synchronized 不 pin) | 0 | 41,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 问题根治》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。