去年年底我把一个内部服务从平台线程切到虚拟线程,改动量不大,压测一开始特别爽——几千并发直接挂住,吞吐量数字很漂亮。大概跑了三分钟,整个服务突然像被冻住一样,请求超时率飙升,进程的线程数量却一直往上走。我第一反应是“虚拟线程不是应该很轻吗,怎么还会这样”,最后靠一份线程转储定位到了罪魁祸首:Pinning。
虚拟线程从 JDK 19 开始预览,到 JDK 21 正式发布,这两年一直是 Java 后端最热门的话题。但真正把虚拟线程用到大流量线上服务的人,几乎都会撞上 Pinning 这堵墙。这篇文章把我排查过程中理解的底层触发点、线程转储的特征,以及 JDK 24 中 JEP 491 带来的那记“终极救赎”,一次性讲清楚。不管你是正在做虚拟线程迁移,还是纯粹在面试八股文里看到这个词想弄明白,这篇都值得看完。
1. 先搞懂虚拟线程的挂载与卸载,才能真正理解 Pinning
1.1 虚拟线程不是“更多线程”,而是“可以在载体线程间跑来跑去的任务”
很多人对虚拟线程的第一印象是“JDK 终于支持更多线程了”,这个理解其实会把方向带偏。虚拟线程的威力不在于线程数量多,而在于它改变了线程和 CPU 执行单元之间的关系。
平台上真正的执行单元是载体线程(Carrier Thread),默认实现是一个 ForkJoinPool,线程数量基本等于可用 CPU 核数。虚拟线程只是“任务”,它被挂到某个载体线程上执行,这个动作叫 Mount;当虚拟线程因为 IO、sleep、park 等操作需要等待时,JVM 把它从载体线程上卸下来,这个动作叫 Unmount。卸下来之后,载体线程立刻可以去执行其他虚拟线程。
我一般喜欢用出租车来类比:虚拟线程是乘客,载体线程是出租车,ForkJoinPool 就是打车平台。乘客上车之后,出租车拉着他跑;乘客想下车等一会儿,就下车,出租车继续接下一单。平台线程体系下,每个乘客必须自己买一辆车,出租车数量就成了瓶颈。虚拟线程体系下,乘客数量可以和出租车数量完全解耦,这才是高并发的核心来源。
这个机制的关键在于:乘客能不能随时下车?如果能,系统就有弹性;如果不能,哪怕乘客再多,车也只有那么几辆,全都堵在路上。Pinning,就是“乘客下了不车”的情况。
1.2 什么阻塞能卸载,什么阻塞不能卸载
搞清楚 Pinning 之前,要先明白 JVM 凭什么能把一个运行中的虚拟线程从载体线程上卸下来。
HotSpot 里的虚拟线程建立在 continuation(延续)机制之上。JVM 能把 Java 方法栈帧完整地保存下来,把 CPU 寄存器状态、程序计数器这些上下文都记录下来,然后让载体线程去做别的事情。等虚拟线程被重新调度时,再把栈帧恢复出来。这个前提是:所有栈帧都是 Java 层能理解和操控的。
一旦代码执行到了 native 方法(JNI 调用),或者执行到了某些 JVM 内部锁机制里,JVM 就没办法安全地保存和恢复执行点了。native 栈帧不受 JVM 管理,JVM 不知道该怎么快照它。这种情况下,虚拟线程只能一直占用着当前载体线程,直到它自己退出这个不可卸载的区域。这个现象就是 Pinning,也常被翻译为“钉住”或“钉死”。
具体到代码层面,最常见的触发点有三个:synchronized 块、native 方法/JNI 调用、类初始化临界区。下面逐个拆解。
2. 到底哪些代码会触发 Pinning:底层触发点逐一拆解
2.1 synchronized:最常见的 Pinning 触发点
synchronized 是 Java 里最老的同步关键字,也是虚拟线程迁移中最容易踩的坑。JDK 21 以及更早的版本里,虚拟线程只要一进入 synchronized 块,在整个块结束之前,都无法从载体线程上卸载。
这不是说“等在 synchronized 锁外面的虚拟线程会 pin”,而是更严重:只要虚拟线程在 synchronized 块范围内,不管是持有锁、等待锁,还是在块内做 sleep、IO、park,它都会一直占着当前载体线程。
举个实际例子。我们有段代码:
synchronized (cacheLock) { // 模拟一次耗时较长的远端缓存加载 Thread.sleep(500); cache.put(key, value); }在 JDK 21 上,这段代码会导致两种无差别的 Pinning:
- 当前线程如果进入了 synchronized 块并在里面 sleep,它本身就被 pin 住了,载体线程没法被调度出去执行其他虚拟线程。
- 其他虚拟线程如果也想进入这个 synchronized 块,它们会在锁入口处阻塞等待。这个等待过程同样会各自 pin 住一个载体线程。
假设运行机器是 8 核,ForkJoinPool 默认大概有 8 个载体线程。如果 8 个虚拟线程分别被 pin 在不同载体线程上,整个虚拟线程调度器就基本瘫痪了。ForkJoinPool 为了继续处理任务,会创建更多的 worker 线程,于是平台上出现越来越多平台线程。很多线上事故最后看到的“线程数飙升”,根源就是这里。
为什么 synchronized 会有这个问题,而 ReentrantLock 没有?因为 ReentrantLock 基于 AQS 实现,线程竞争锁时是通过 LockSupport.park 进入等待状态,这个阻塞点是 Java 层可保存的,虚拟线程可以正常卸载。而 synchronized 的底层是 JVM 的 monitor 机制,旧版本 HotSpot 的实现中,monitor 需要和 JavaThread 对象强关联,记录可重入次数、持有者信息等。虚拟线程一旦卸载,这些关联关系就不好维护。所以 JDK 团队选择了最简单的方案:在 synchronized 块内不让虚拟线程卸载,先保证正确性,代价就是 Pinning。
这也解释了一个常见的误解:有人以为只要把 synchronized 换成 ReentrantLock 就万事大吉。对虚拟线程来说,确实是这样。但要改的代码往往是历史项目里最核心的锁,动起来心里没底,所以 Pinning 在项目实战里特别棘手。
2.2 native 方法 / JNI 调用:一旦进去就真的下不了车
第二个触发点是 native 方法。虚拟线程执行到 JNI 调用时,JVM 的控制权会交给底层 C/C++ 代码,后续执行栈变成了 native 栈帧。continuation 机制只能保存 Java 栈帧,对 native 栈帧无能为力,所以执行 native 方法的虚拟线程同样无法卸载。
典型场景包括:通过 JNA/JNI 调用 C 语言库,某些加解密库的底层实现,以及部分文件系统监控、硬件交互组件。Java 标准库内部也有不少 native 方法,不过大部分高频的标准库方法都已经经过优化,不太会成为卡点。真正危险的是业务代码里直接依赖 native 库,并且这个 native 调用可能长时间阻塞,比如等待设备响应、等待外部进程返回。
这类 Pinning 和 synchronized 有个本质区别:JDK 24 之后的 synchronized 已经从根上修复,不需要你改代码;但 native 方法导致的 Pinning,目前仍然没有一个“完全消除”的方案,因为底层问题在于 JVM 无法快照 native 栈帧。JDK 24 引入的限时 Pinning 机制只能起到一定的兜底作用,这个后面详细讲。
2.3 类初始化与少数 JVM 内部临界区
第三种比较隐蔽,就是类初始化阶段。Java 类在首次加载时会执行静态初始化块,这个过程中 JVM 会对类对象加一把内部锁,防止多个线程同时初始化同一个类。如果一个类静态初始化块中触发了耗时操作,比如建立连接、读取大文件,而多个虚拟线程同时触发这个类的初始化,就可能出现 Pinning。
类初始化锁本质上也是一个 monitor 锁,所以在 JDK 21 下表现和 synchronized 一样:等待类初始化锁的虚拟线程会被 pin。随着 JDK 24 对 synchronized 底层实现的重写,类初始化锁这块也一并受益了,不再需要单独处理。
除了类初始化,JVM 内部还有少数临界区会导致虚拟线程无法卸载,比如 JVM TI 事件处理、某些反射校验路径。但这些场景比较少见,排查时只需要借助工具确认即可,不需要过度担心。
3. Pinning 的表现与排查:线上怎么快速定位
3.1 现象:压测变卡、线程数飙升
Pinning 最典型的现象是:服务压测刚开始没事,跑了十几分钟后吞吐量突然掉下去,请求耗时开始出现长尾。这时候去看线程数,会发现平台线程数量远远超过 CPU 核数,而且还在持续增长。原因是 ForkJoinPool 发现现有 worker 线程都被 pin 住了,为了让新的虚拟线程继续执行,它不得不创建新的 worker 线程。每个被 pin 住的虚拟线程背后挂着一个平台线程,平台线程越来越越多,上下文切换开销越来越大,最后整个服务被拖垮。
我见过一个真实场景:8 核机器,正常平台线程只有几十个,压测高峰时平台线程数涨到了两千多。拿线程转储一看,ForkJoinPool-worker 线程全堵在 Object.wait 和 Unsafe.park 上,下面挂着各自的虚拟线程。那画面真的让人印象深刻。
值得注意的是,线程数飙升并不是必现症状。如果 pin 住的载体线程数量没超过核数,服务可能只是偶尔抖动,看起来像“垃圾回收有点频繁”、“网络偶发抖动”,很难直接联想到 Pinning。这也是这个问题危险的地方。
3.2 线程转储怎么看:找到“没法卸货”的虚拟线程
遇到疑似 Pinning,第一件事就是抓线程转储。JDK 21 之后,虚拟线程也能在线程转储中看到,推荐使用 jcmd 抓取:
jcmd <pid> Thread.dump_to_file -format=json threaddump.jsonJDK 21 的线程转储中,如果看到大量平台线程(ForkJoinPool-worker-*)的栈顶停在java.lang.Object.wait或者jdk.internal.misc.Unsafe.park,而且每个平台线程的 carrier thread 字段里挂着一个虚拟线程,基本可以确认发生了 Pinning。
我自己的习惯是同时抓两份转储,间隔 3 到 5 秒。这样能看出哪些平台线程一直没动过,一直在原地 park。如果同一个虚拟线程在两个转储里都 pin 在同一个载体线程上,说明它不是瞬时抖动,而是稳定卡死,需要立刻处理。
3.3 用 -Djdk.tracePinnedThreads 直接给 JVM 装个记录仪
手动看转储效率太低,JDK 从很早的版本就提供了一个调试利器:-Djdk.tracePinnedThreads。这个参数支持两个值:
full:打印发生 Pinning 时所有相关线程的完整栈short:只打印栈中包含非 Java 帧或关键锁信息的部分
用法很简单,启动时加上参数:
java -Djdk.tracePinnedThreads=full -jar your-service.jar运行之后,一旦虚拟线程发生 Pinning,JVM 会直接在日志里输出信息,自动帮你标出问题代码。输出大概是这样的格式:
Thread[#25,ForkJoinPool-1-worker-3] Pinned virtual thread at java.lang.Object.wait(Native Method) at pinning.demo.SomeService.doWork(SomeService.java:42) ...这个参数太实用了。我一般建议压测环境一定要开,线上如果日志量能接受也可以短暂开一下定位问题。它比手动抓转储高效得多,能直接告诉你代码里哪一行是罪魁祸首。
4. JDK 24 的“终极救赎”:JEP 491 到底做了什么
4.1 不是小修小补,而是把 synchronized 的底给换了
JDK 24 在 2025 年 3 月正式发布,其中一个重量级特性就是 JEP 491:Synchronize Virtual Threads without Pinning。简单说,这个 JEP 从底层重写了 synchronized 的实现,让虚拟线程在 synchronized 块内也可以正常卸载。
旧方案里,synchronized 通过 JVM 的 ObjectMonitor 实现,monitor 与 JavaThread 强绑定,虚拟线程一旦进入 monitor 就下不了车。JEP 491 换掉了这套机制,引入了新的线性锁(linear lock)实现,把锁的持有关系和 JavaThread 对象解耦。这样一来,虚拟线程在 synchronized 块里遇到 sleep、IO、等待锁等操作时,就可以像 ReentrantLock 一样轻松卸载,载体线程不会被白占。
这对整个 Java 生态来说是一个巨大的利好。之前很多虚拟线程迁移项目,卡在“历史代码里的 synchronized 太多,根本改不动”这个尴尬局面。JDK 24 之后,大部分业务代码不需要改动,synchronized 不再是 Pinning 的元凶,迁移成本大幅下降。
类初始化锁导致的 Pinning 也在这个 JEP 中一并解决,因为类初始化锁基于同样的 monitor 实现。我在本地测试过 JDK 24,原先那个在 JDK 21 里稳定复现 Pinning 的压测用例,切换之后几乎看不到平台线程数飙升了。
4.2 限时 Pinning:为 native 等剩余场景兜底
synchronized 是修好了,但 native 方法导致的 Pinning 依然存在,因为 JVM 无法跨 native 栈帧做快照。JDK 24 对这个场景给出的方案是“限时 Pinning”(timed pinning)。
机制也不复杂:JVM 内部记录虚拟线程被 pin 住的时长。如果 pin 的时间超过一个阈值,JVM 会在下一个安全点尝试把虚拟线程从载体线程上强制卸下来,让载体线程恢复可用。这个阈值可以通过系统属性调整:
-Djdk.pinnedThreads.maxParkTime=1000默认值大约是 1000 毫秒,也就是虚拟线程被 pin 住超过 1 秒,JVM 就会尝试干预。如果你把值设为 0,相当于关闭限时 Pinning 机制,恢复成“一直 pin 到结束”的旧行为。
需要提醒的是,限时 Pinning 不是万能的。如果虚拟线程正阻塞在一个永远不返回的 native 方法里,JVM 无法在 native 栈帧中插入安全点,可能还是救不了。但对于常见的“native 调用偶尔耗时较长,最终会返回 Java 层”的场景,限时机制能把影响控制在一个可接受的时间窗口内。这种“兜底”设计很务实:不追求理论上的完美,而是保证最坏情况不至于把整个服务拖死。
4.3 迁移注意事项:JDK 24 不等于所有 Pinning 都消失
很多人看到 JDK 24 发布,第一反应是“终于可以把 synchronized 全扔了”。这个说法要分两层看。
对绝大多数基于 synchronized 的业务代码,确实不用再担心 Pinning 了。但有两个情况仍然要注意:
- 如果你的代码依赖 native 库,并且在 native 调用中可能长时间阻塞,建议升级后仍然在压测环境开
-Djdk.tracePinnedThreads=full验证一遍。native 引起的 Pinning 虽然有了超时兜底,但属于“退而求其次”的方案,能避免尽量避免。 - JDK 24 本身不是 LTS 版本,很多团队的生产环境还是 JDK 17 或 JDK 21。JDK 24 的修复会在后续的 LTS 版本(比如 JDK 25)中延续,但如果你只能在 LTS 版本之间选择,可能还是要等一等。不过这不影响你现在就拿 JDK 24 在测试环境做验证。
另外,synchronized 不再导致 Pinning,不等于建议你在锁里面随便写耗时操作。锁竞争本身带来的等待时间、线程唤醒开销依然存在。锁粒度该小还是小,该换 ReentrantLock 的地方也不是必须换回 synchronized,保持理智即可。
5. 实操复现:从 JDK 21 换到 JDK 24,我用同一个用例看到差异
5.1 一个能稳定复现 Pinning 的用例
为了把 Pinning 讲得具体,我写了一个简单的复现用例,可以在 JDK 21 和 JDK 24 上分别运行对比。
import java.util.concurrent.ThreadLocalRandom; public class PinDemo { static final Object LOCK = new Object(); public static void main(String[] args) throws Exception { // 先占住锁,并在持锁期间阻塞 30 秒 Thread.ofPlatform().name("lock-holder").start(() -> { synchronized (LOCK) { System.out.println("holder: locked, sleep 30s"); try { Thread.sleep(30_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); Thread.sleep(500); // 8 个虚拟线程竞争同一把锁 for (int i = 0; i < 8; i++) { int id = i; Thread.ofVirtual().name("v-lock-" + id).start(() -> { synchronized (LOCK) { System.out.println("v-lock-" + id + ": got lock"); } }); } // 16 个虚拟线程做与锁无关的业务 for (int i = 0; i < 16; i++) { int id = i; Thread.ofVirtual().name("v-biz-" + id).start(() -> { try { Thread.sleep(200); System.out.println("v-biz-" + id + ": done"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } Thread.sleep(2000); System.out.println("platform thread count = " + Thread.getAllStackTraces().keySet().size()); System.exit(0); } }单文件直接跑,不需要编译:
java PinDemo.java这个用例的设计思路是:一个平台线程持有 synchronized 锁并阻塞 30 秒,8 个虚拟线程在锁入口等待,16 个虚拟线程尝试执行普通业务。在 JDK 21 上,等待锁的虚拟线程会各自 pin 住一个载体线程,ForkJoinPool 被迫扩容,平台线程数会明显高于 CPU 核数。在 JDK 24 上,等待锁的虚拟线程可以卸载,平台线程数基本保持稳定。
5.2 在 JDK 21 上的现场
用 JDK 21 运行这个用例,输出大概是这样:
holder: locked, sleep 30s v-biz-0: done v-biz-3: done ... platform thread count = 31如果机器是 8 核,正常情况下 ForkJoinPool 只会创建 8 个左右 worker,加上其他 JVM 内部线程,平台线程总数不会超过 15。这里看到 31,就说明 ForkJoinPool 已经被迫创建了大量额外 worker,每一个基本都在伺候一个被 pin 住的虚拟线程。
如果加上-Djdk.tracePinnedThreads=full,能直接在日志里看到:
Thread[#30,ForkJoinPool-1-worker-3] Pinned virtual thread at java.lang.Object.wait(Native Method) at pinning.demo.PinDemo.lambda$main$1(PinDemo.java:22)这个输出就是最直接的定位证据,代码第 22 行的 synchronized 就是问题所在。
5.3 在 JDK 24 上的验证
同样的代码,切到 JDK 24 运行:
holder: locked, sleep 30s v-biz-0: done v-biz-3: done ... platform thread count = 13平台线程数明显下降,基本回到 8 核机器正常的状态。-Djdk.tracePinnedThreads=full也不会再输出 synchronized 相关的 Pinning 信息。锁释放之后,8 个等待锁的虚拟线程会依次执行v-lock-*: got lock,但整个过程不再拖垮调度器。
这个对比很直观:同一个字节码、同一个 synchronized,JDK 版本不同,表现天差地别。
5.4 核心区别:锁持有者也要注意
这个用例里有一个容易被忽略的细节:锁持有者是平台线程,不是虚拟线程。但线程转储中会出现 8 个等待锁的虚拟线程被 pin 住,这说明问题甚至不需要虚拟线程本身持有锁,只要等锁的线程是虚拟线程,Pinning 就会发生。JDK 24 的修复除了让锁持有者可以卸载,也使等待锁的虚拟线程可以安全释放载体线程,两边都治了。
实际业务场景往往比这个复杂:锁持有者是虚拟线程,持锁期间调用远程服务、访问数据库,等待锁的还是一堆虚拟线程。这种情况下,JDK 21 上的灾难会成倍放大。如果你手头有类似代码,强烈建议用这个思路写个小用例,在你的目标 JDK 版本上跑一下看看平台线程数变化,比读十篇文章都管用。
6. 常见问题速查与避坑清单
6.1 一张表看懂 Pinning 的触发点与 JDK 2424 后的状态
| 场景 | JDK 21 及以前 | JDK 24 及以后 | 实际操作建议 |
|---|---|---|---|
| synchronized 块内 sleep/IO/等待锁 | 触发 Pinning | 修复,不再触发 | 锁内仍别做长阻塞,锁竞争开销依然存在 |
| ReentrantLock 块内阻塞 | 不触发 | 不触发 | 按需使用,无需刻意替换 |
| native 方法/JNI 调用 | 触发 Pinning | 仍可能触发,但有限时兜底 | 避免长时间 native 阻塞,升级后验证 |
| 类初始化锁 | 触发 Pinning | 修复,不再触发 | 静态代码块别放重活 |
| CPU 密集计算 | 不触发 Pinning,但持续占用载体线程 | 同样 | 控制虚拟线程数量,别无脑开十万个 |
6.2 几个高频问题
问:JDK 24 之后,synchronized 是不是完全不用管了?不是。Pinning 问题解决了,但锁的语义没变。长时间持锁会阻塞所有等待锁的虚拟线程,哪怕它们可以卸载,业务耗时也上去了。锁粒度控制、锁内不调远程、不加长 sleep,这些好习惯不会因为 JDK 版本升级而过时。
问:-Djdk.tracePinnedThreads=full 能不能一直开着?看场景。full 模式会输出大量线程栈信息,线上流量大的时候日志量很可观。我建议压测环境常开,线上排查问题的时候短暂开几分钟,定位完立刻关掉。
问:jdk.pinnedThreads.maxParkTime 设多少合适?默认 1000 毫秒对大部分业务够用。如果你确认自己的 native 调用最坏耗时会超过 1 秒,可以根据实际压测数据适当调大。设得越小,JVM 越早尝试干预,但如果干预频繁,可能带来额外的调度开销。这个参数没有通用最优值,只能结合自己业务实测。
问:升级 JDK 24 需要改代码吗?大多数纯 Java 业务不需要改。但如果项目里用了 JNI/JNA 或者其他基于 native 实现的框架,建议在测试环境用 tracePinnedThreads 完整跑一遍压测,确认没有隐藏的 native Pinning 点。
最后说一点我自己的体会。Pinning 这个坑之所以让人头疼,不是因为技术多深,而是它藏得太深。表面上看是“服务变慢了”,实际是底层调度器已经被卡死。JDK 24 的 JEP 491 把最大的那根刺拔掉了,剩下的 native 场景也给出了限时兜底方案。但工具永远是辅助,真正的核心能力还是你愿不愿意在一堆线程栈里排查“为什么载体线程被占住”的耐心。我现在的习惯是:任何虚拟线程改造,上线前必须带着 tracePinnedThreads 跑一轮压测,确认没有 Pinning 点才敢放量。这套方法论,不管 JDK 升到多少版本都不过时。