虚拟线程避坑全解:Thread Pinning 问题定位与 3 种主流解决方案对比
Java 21 引入虚拟线程(Virtual Threads)后,"线程池 + 平台线程"的传统模型被打破,百万级并发的 thread-per-request 变得廉价。但很多团队上线后发现:虚拟线程吞吐没涨,载体线程(Carrier Thread)反而被吃满,P99 飙升 。根因往往不是虚拟线程本身,而是 Thread Pinning(钉线程) 。
本文从原理 → 定位 → 三种主流解法对比,把这块讲透。
一、Thread Pinning 到底是什么
虚拟线程的核心机制是:遇到阻塞(socket 读、Thread.sleep、BlockingQueue.take 等)时,把栈帧冻结到堆里,从载体线程上 unmount,载体线程去跑别的虚拟线程。
Pinning 指:虚拟线程在阻塞那一刻无法 unmount,被"钉"在载体线程上 ,导致载体线程跟着一起阻塞。载体线程池默认只有 CPU 核数 个(ForkJoinPool),一旦被钉满,后续所有虚拟线程都调度不动,系统退化为平台线程模型甚至更差。
触发 Pinning 的两个官方条件(JDK 21~23)
- 虚拟线程正在执行
synchronized块/方法内部发生阻塞 ------ JVM 的 ObjectMonitor 绑定的是底层 OS 线程身份,unmount 会破坏 monitor 语义,所以禁止卸载。 - 虚拟线程正在执行 native 方法 / JNI / FFM 外部函数 且内部阻塞 ------ JVM 管不到 C 栈,无法冻结。
注:JDK 24 通过 JEP 491 重写了 monitor 实现,让大部分
synchronized+ 阻塞 IO 场景不再 pin;但 native/JNI pinning 仍然存在,且大量团队还停在 JDK 21/22/23,所以本文结论对当前生产依然成立。
Pinning 本身不会让程序算错,但高频 + 长耗时 pinning(如锁内查数据库 500ms)会直接拖垮扩展性。
二、怎么定位 Pinning(生产可用手段)
1. JFR 事件(最推荐,线上常驻)
JDK 21 起 JFR 自带 jdk.VirtualThreadPinned,默认阈值 20ms,开销极低。
ini
# 启动录制
java -XX:StartFlightRecording=filename=app.jfr,settings=profile -jar app.jar
# 或运行时开
jcmd <pid> JFR.start name=vt settings=profile
# 查看 pinning 事件与堆栈
jfr print --events jdk.VirtualThreadPinned app.jfr
事件里会带 reason=MONITOR/NATIVE、持续时长、阻塞栈,能直接圈出第三方 jar 里的 synchronized 行号。
2. 启动参数强行打印(排查期临时用)
ini
-Djdk.tracePinnedThreads=full # 打印完整栈,标出 monitor/native 帧
-Djdk.tracePinnedThreads=short # 只打问题帧
缺点:日志量巨大,JDK 24 已移除该参数。
3. jcmd 线程快照
ini
jcmd <pid> Thread.dump_to_file -format=json vt.json
JSON 里虚拟线程会标 pinned 状态、所属 carrier、持有 monitor;传统 jstack 在百万虚拟线程下基本不可读。
4. 辅助信号
- 载体线程池
ForkJoinPool-1-worker-*全部RUNNABLE但实际在做网络等待 jdk.VirtualThreadSubmitFailed事件出现 → 载体耗尽前兆- 吞吐上不去但 CPU 不高,carrier 数 ≈ 核数,排队 VT 数暴涨
三、3 种主流解决方案对比
方案 A:synchronized → ReentrantLock(治 Monitor Pinning)
csharp
// ❌ pinning:锁内做 IO
synchronized(cache){
return db.query(id); // 载体被钉 500ms
}
// ✅ 可卸载
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try { return db.query(id); }
finally { lock.unlock(); }
原理 :ReentrantLock 基于 AQS + LockSupport.park(),JVM 调度器认识它,park 时虚拟线程可以 unmount,载体释放。
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 虚拟线程 pinning | 会(JDK<24) | 不会 |
| 可中断/超时 | 否 | 是(tryLock) |
| 公平锁 | 否 | 可选 |
| 改写成本 | 0 | 中(要 try/finally) |
| 读多写少优化 | 无 | 可配 StampedLock/ReadWriteLock |
适用:自己写的业务代码、Spring Bean 里热点锁、缓存回填、限流计数器。
不适用:只在启动期跑一次、纯内存非阻塞、调用频次极低的 synchronized------没必要动。
方案 B:Native / 老 IO 卸载到独立平台线程池(治 Native Pinning)
JNI 压缩、老版 SQLite/RocksDB 绑定、某些旧 JDBC 驱动、遗留 java.io 文件操作,在虚拟线程里直接跑就会 native-pin。
scss
static final ExecutorService OFFLOAD =
Executors.newCachedThreadPool(); // 平台线程池
CompletableFuture.supplyAsync(() -> nativeBlockingCall(x), OFFLOAD);
把"必 pin 的操作"隔离到普通平台线程池,HTTP 处理主链路仍是虚拟线程,载体不被占。
| 维度 | 说明 |
|---|---|
| 优点 | 不动三方库源码,立刻止血 |
| 缺点 | 多一层线程池、平台线程数要调;只是转移而非消除 pin |
| 调参 | newCachedThreadPool 易炸,建议用有界队列 + 拒绝策略,或 Executors.newThreadPerTaskExecutor 限量 |
| 适用 | 第三方 native 库、不可改的 legacy IO、FFM 调用 |
方案 C:升级 JDK 24+(JEP 491 让 synchronized 不 pin)
JDK 24 起 ObjectMonitor 改为与虚拟线程身份解耦,常见"synchronized 内阻塞 IO"不再 pin,-Djdk.tracePinnedThreads 也删了。
| 维度 | 说明 |
|---|---|
| 优点 | 代码零改动,monitor pinning 基本消失 |
| 缺点 | Native/JNI pinning 仍在;企业升级 LTS 成本高(21→24 非连续 LTS,25 才是下一 LTS) |
| 风险 | 部分依赖 monitor 语义的诡异代码、AOP 字节码增强需回归测试 |
| 适用 | 新项目直接选 JDK 24/25;老 JDK 21 集群不能只靠"以后升级"回避当下问题 |
四、三种方案怎么选(决策树)
- JDK 21/22/23 + 自己代码里 synchronized 包 IO → 方案 A(ReentrantLock),收益最大
- 卡在第三方 native/JNI/老驱动 → 方案 B(卸载到平台线程池),短期必做
- 新建服务 / 能控基线版本 → 直接 JDK 24+(方案 C)+ 对 native 调用仍保留 B
- synchronized 只在启动加载、低频纯内存 → 不管它,别过度重构
实测数据(1000 并发、Redis 3s 延迟):synchronized 版 8 个载体全钉死、P99 30s;换 ReentrantLock 后 P99 3.2s;同代码跑 JDK 24 无 pin 日志。
五、几个容易踩的反模式
- 为了"统一"把所有 synchronized 无脑换 ReentrantLock------低频纯内存锁换了只增加复杂度
- 虚拟线程里套
Executors.newFixedThreadPool当虚拟线程池------虚拟线程本就不该进池 - 以为开虚拟线程就能跑 CPU 密集循环------不阻塞就不 unmount,载体被占满
- 靠调大
jdk.virtualThreadScheduler.maxPoolSize缓解 pinning------只是用更多 OS 线程掩盖病灶 - ThreadLocal 重对象缓存 + 虚拟线程------每个 VT 一份,内存炸;改用 ScopedValue(JDK 25+)或局部变量
小结
Thread Pinning 的本质是 "虚拟线程想让位,但 JVM 不敢卸" 。定位靠 JFR 的 jdk.VirtualThreadPinned + jcmd JSON dump;解法上 ReentrantLock 治 synchronized pin、平台线程卸载治 native pin、JDK 24+ 治历史包袱,三者不互斥。迁移虚拟线程时,先跑一遍 JFR 看 pinning 热点,再决定动哪一块------比盲目"全量换锁"或"等升级 JDK"都稳。