虚拟线程避坑全解:Thread Pinning 问题定位与 3 种主流解决方案对比
【摘要】 虚拟线程避坑全解:Thread Pinning 问题定位与 3 种主流解决方案对比Java 21 引入虚拟线程(Virtual Threads)后,"线程池 + 平台线程"的传统模型被打破,百万级并发的 thread-per-request 变得廉价。但很多团队上线后发现:虚拟线程吞吐没涨,载体线程(Carrier Thread)反而被吃满,P99 飙升。根因往往不是虚拟线程本身,而是 Th...
虚拟线程避坑全解: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,开销极低。# 启动录制
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. 启动参数强行打印(排查期临时用)
-Djdk.tracePinnedThreads=full # 打印完整栈,标出 monitor/native 帧
-Djdk.tracePinnedThreads=short # 只打问题帧
缺点:日志量巨大,JDK 24 已移除该参数。
3. jcmd 线程快照
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)
// ❌ 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。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"都稳。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)