JDK 25的评估我拖了很久,主要原因就是虚拟线程的Pinning问题。倒不是不相信官方文档,而是这玩意儿在不同JDK版本上的表现差异太大了,光看release note根本没法放心。这次专门腾出时间,在JDK 25早期构建上把经典复现场景、线程转储、JFR事件全跑了一遍,结论比想象中乐观,但也确实还有一些边界场景会继续踩坑。这篇文章就把整个验证过程、底层原理和线上排查方法完整记录下来,给正在做JDK版本升级评估,或者已经在生产环境用虚拟线程的同学一个参考。
1. 虚拟线程的Pinning问题到底是什么
1.1 先搞懂虚拟线程的调度模型
虚拟线程的核心卖点不是“线程更快”,而是“线程更便宜”。JVM把虚拟线程当成一种用户态线程,它的栈、状态都存在堆内存里,真正执行任务时则由调度器分发到少数几个平台线程上。这些平台线程在JDK内部是一个ForkJoinPool,虚拟线程会被挂到池里的某个平台线程上运行。
这里的关键在于,一个虚拟线程在执行过程中是可以被“卸载”下来的。比如它执行到Thread.sleep()、LockSupport.park()这些操作时,JVM会把当前Java栈帧保存下来,然后把载体线程还给池子,让这个平台线程去执行其他虚拟线程。这个机制保证了大量虚拟线程不需要独享平台线程,虚拟线程数量远超平台线程也不会出问题。
Pinning(钉住)破坏的正是这个“可卸载”假设。当一个虚拟线程被钉在载体线程上时,虚拟线程已经处于无法安全卸载的状态,载体线程只能陪它一起等着,哪怕这个虚拟线程接下来要做的事只是傻等,平台线程也腾不出来。
我打个比方,这就好比你在高铁上要临时下车去站台买个东西,列车员说不行,因为你的脚被焊在座位上了,于是整节车厢的人都得等你。你一个人占了座位,后面的乘客只能排队。
在JDK 21刚引入虚拟线程时,Pinning问题被吐槽得最多,因为synchronized太常见了,几乎每个Java项目里都有,很多团队跑个Demo就发现虚拟线程被synchronized直接“废掉”了。
1.2 Pinning的根源在哪里
从JVM实现层面说,虚拟线程需要“卸载”时,它的执行栈必须处于一个可以被安全挂起的位置。如果执行栈里是普通的Java代码,卸载很容易,把栈帧存下来,平台线程去接别的任务就行。但如果执行栈钻进了某些特殊区域,情况就变了。
第一个高发场景是synchronized锁竞争。在JDK 21到JDK 23这些版本上,虚拟线程一旦进入synchronized块并发生锁竞争,JVM内部会让载体线程进入monitor的native等待。这个等待发生在JVM底层,Java层的栈已经挂起了,但载体线程还在原地等锁,调度器没法把它拿去做别的事。结果是,只要有一个虚拟线程在锁里执行阻塞操作,钉住一个平台线程,再多的平台线程也会被排队抢锁的虚拟线程陆续占满。
第二个高发场景是调用native方法。如果native方法内部发生了阻塞系统调用,比如传统FileInputStream.read()、SocketInputStream.read()这类阻塞式IO,载体线程会直接阻塞在系统调用上,JVM在那一刻完全失去了对平台线程的控制权。这个场景其实和synchronized无关,就算把IO写在没有任何锁的虚拟线程代码里,只要IO本身是阻塞式的,照样会占住平台线程。
所以,Pinning不是虚拟线程天生有bug,而是某些平台级阻塞操作在底层感知不到虚拟线程这个抽象层级。要解决它,JVM必须把这些操作的实现改造成对虚拟线程友好,这正是后面要说的JEP 491在做的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JDK 25的Pinning问题还有吗
2.1 JEP 491修掉的是哪一块
JEP 491在JDK 24里合入,官方名字叫“Synchronize Virtual Threads without Pinning”。它的目标非常明确:把Java monitor(也就是synchronized)的实现改成对虚拟线程友好的方式。
旧实现里,虚拟线程进入synchronized块遇到锁竞争时,monitor会调用底层操作系统的线程同步原语,让当前载体线程进入平台级等待。JEP 491改成了这样:虚拟线程遇到monitor等待时,直接把自己挂起,载体线程立刻被释放,可以回ForkJoinPool继续执行其他虚拟线程。等到锁可用时,被挂起的虚拟线程再由调度器唤醒。
这个改动对日常Java开发的影响怎么强调都不过分。大型项目里完全避开synchronized几乎不可能,JDK自己的类库里到处有用synchronized的地方,比如System.out.println()内部就有同步块,StringBuffer是同步的,更别说各种老牌框架和中间件。之前一听虚拟线程就头大的人,十有八九是被synchronized Pinning坑过。
我在JDK 21和JDK 25上跑了同一个复现程序,前者在synchronized块里做简单睡眠,耗时随任务数线性增长;后者只在初始阶段有明显调度开销,整体耗时接近单次任务耗时。这个对比后面用代码展示,结论是JDK 25确实把这个经典坑填平了。
2.2 两个仍然会Pin的边界
JEP 491不是魔法,它在JDK 24的发布说明里明确留下了一些没有覆盖的场景。
第一个边界,是synchronized块内调用native方法且该native方法真的阻塞了。比如你在synchronized块里用老式API做文件读取、Socket读取,底层走的是native系统调用,平台线程一旦进入这个调用,JVM就失去了控制权,无法把这个平台线程重新调度给其他虚拟线程。这时候pinned标记依然会出现。
第二个边界,是synchronized块内发生JNI调用,JNI代码又回调Java并等待锁。这种嵌套情况比较少见,但原理和第一种一样,只要载体线程卡在native层,虚拟线程就无法卸载。
所以准确的说法是:JDK 25上,synchronized本身导致的Pinning已经基本消失,但“在锁块里做阻塞IO”这个习惯仍然会踩Pinning。你不能因为升级了JDK,就放任自己在锁里写各种阻塞操作,那是把JVM修复和白费资源混为一谈了。
3. 用代码复现一次Pinning问题
3.1 环境准备:在Linux上快速部署JDK 25
我这次测试在一台CentOS 7兼容的机器上跑,JDK 25用的还是官方早期访问构建。JDK 25还没正式GA,最稳妥的方式是去官方下载页找对应平台的tar.gz包。
安装流程很标准:
bash复制# 解压到 /opt
cd /opt
tar -zxvf jdk-25_linux-x64_bin.tar.gz
mv jdk-25 /opt/jdk-25
# 配置环境变量
cat >> /etc/profile <<'EOF'
export JAVA_HOME=/opt/jdk-25
export PATH=$JAVA_HOME/bin:$PATH
EOF
source /etc/profile
java -version
看到版本号正常输出就说明环境没问题。后面所有实验都在这个环境上执行,避免不同版本JDK混用导致的干扰。
3.2 复现代码:用synchronized制造Pinning
这是很经典的复现程序,逻辑特别简单:创建一批虚拟线程,每个虚拟线程抢同一把锁,在锁里面sleep 100毫秒。
java复制import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class VirtualThreadPinDemo {
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
int tasks = 400;
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
long start = System.currentTimeMillis();
for (int i = 0; i < tasks; i++) {
executor.submit(() -> {
synchronized (LOCK) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
long cost = System.currentTimeMillis() - start;
System.out.println("total tasks = " + tasks + ", cost = " + cost + " ms");
}
}
在JDK 21上,这个程序往往要跑几十秒。400个虚拟线程,同一时刻进入synchronized块的只有一个,其余的或排队或等待,而等待的虚拟线程把载体线程全部钉住了,ForkJoinPool无法再接新任务,整个调度器就这么卡死。任务数越多,线性放大越明显。
在JDK 24/25上,耗时一般只有一两百毫秒。400个虚拟线程几乎并发执行,抢占锁的过程不会让载体线程原地等待,整体吞吐直接拉满。这个对比非常直观:同样的代码,同样的锁,JDK版本差两个大版本,体感一个天上一个地下。
3.3 观察线程转储与结果
为了确认问题,可以在启动时加上JVM参数:
bash复制java -Djdk.tracePinnedThreads=full VirtualThreadPinDemo
tracePinnedThreads的意思是当虚拟线程发生pinning时,把对应的调用栈打出来。JDK 21上这个参数会输出一大串栈信息,能看到栈底停在Object.wait(Native Method)或类似位置。JDK 25上同样跑一遍,几乎没有任何pinned提示,说明synchronized等待不再造成钉住。
更进一步,用jcmd抓全量线程转储:
bash复制jcmd <pid> Thread.dump_to_file -format=json thread_dump.json
JSON格式的dump里,我重点关注两类信息:一类是栈内容里有很多java.lang.Object.wait的虚拟线程,它们都在等锁;另一类是带<pinned>标记的线程,它们表示当前确实被钉住了。JDK 25的dump里前者还会出现,但后者基本消失。如果在JDK 25的dump里依然看到大量<pinned>,且栈底是native阻塞IO,那就能确定是IO层面的钉住,而不是synchronized的问题。
4. 线上排查Pinning的工具与思路
4.1 先用JVM参数快速定位
测试环境最快的方式就是-Djdk.tracePinnedThreads。full级别会打出非常完整的虚拟线程栈,对分析问题很有帮助,但输出量很大,线上环境慎用。如果只是想快速确认有没有pin,可以用short:
bash复制java -Djdk.tracePinnedThreads=short YourApp
它会输出精简版的栈信息,对判断代码路径已经足够。线上如果坚持用,建议只短时间开启,配合日志采集,跑完立刻关闭。
4.2 用jcmd抓线程转储
生产环境的常见做法是在故障时刻执行jcmd抓取转储,不需要重启应用:
bash复制jcmd <pid> Thread.dump_to_file -format=json dump.json
这个命令开销很低,生成的文件也不大。抓到后把里面所有虚拟线程过一遍,优先级最高的是带pinned标志、或正在执行native阻塞方法的虚拟线程。你可以用简单的文本工具统计“pinned”关键词的数量,快速量化整个进程的Pinning严重程度。
还有一个小技巧:先后抓三份相隔几秒的dump。如果发现pinned线程数量稳定不变,说明是常态性阻塞;如果数量忽高忽低,说明和瞬时流量相关。这两种情况对应的排查方向会完全不同。
4.3 用JFR和Async Profiler长期观测
短时抓dump只能看瞬间,想长期观测,JFR是更好的选择:
bash复制java -XX:StartFlightRecording:filename=demo.jfr,settings=profile YourApp
跑一段时间后,用jfr命令直接解析:
bash复制jfr print --events jdk.VirtualThreadPinned demo.jfr
JFR里有专门的jdk.VirtualThreadPinned事件,如果数量很多,说明你的应用存在真实的Pinning风险。这个事件在JDK 21之后就能用,JDK 25里依然有效,非常适合接进监控平台做长期趋势分析。
Async Profiler可以做火焰图分析,定位CPU热点栈:
bash复制asprof -e cpu -d 60 -f flamegraph.html <pid>
火焰图里如果出现大面积的ObjectMonitor、__pthread_cond_wait、SocketInputStream.read之类的栈,基本就是Pinning发生的现场。不过火焰图只能看热力分布,建议配套线程dump做交叉确认。
4.4 排查思路总结
我的个人习惯是:先开tracePinnedThreads短跑一轮,看有没有pinned标记;有的话再抓三份相隔几秒的线程dump,观察pinned线程数量和位置是否稳定;最后用JFR统计一整天的pinned事件,找出哪个接口、哪条代码路径最容易被钉住。
这套流程下来,问题范围基本能锁定。我踩过的另一个坑是:刚开始一看到pinned标记,就急着把synchronized改成ReentrantLock,结果发现改完没啥变化。后来才反应过来,真正的问题是锁块里的阻塞IO,不是synchronized本身。所以排查时一定要看栈底,别被表面的锁竞争干扰了判断。
5. 避免Pinning的代码实践与边界场景
5.1 优先把锁换成JUC锁
JDK 25解决了synchronized的Pinning问题,但我的建议仍然是:新代码里,如果临界区可能发生阻塞,优先用ReentrantLock。原因有两个:一是语义更清晰,你可以在锁内做更多可控操作,比如tryLock、超时锁;二是从代码审查角度,JUC锁更容易看清临界区的范围。
java复制private final ReentrantLock lock = new ReentrantLock();
void doSomething() {
lock.lock();
try {
// 这里可以放心做短阻塞
} finally {
lock.unlock();
}
}
但要提醒一下,这不意味着要把老代码里的synchronized全部改掉。如果一个锁的临界区很窄,里面只有内存操作,改不改差别不大。无脑替换synchronized不仅增加review成本,还可能因为顺手改了锁的语义引发新问题。
5.2 锁块内不要放Native阻塞IO
这是我在实践里踩过最深的坑。曾经有一段代码,逻辑看起来合理:在synchronized块里用文件输出流写日志。文件IO走的是native阻塞调用,载体线程直接被钉住。表面现象是服务CPU不高,但吞吐上不去,虚拟线程全卡在写入上。
如果确实需要在锁内做IO,最稳的办法是把它拆出去:先在临界区里做好业务快照,出锁后再写文件或发网络请求。锁的临界区里只保留内存操作,把一切可能阻塞的调用放到锁外。这个原则在JDK 21成立,在JDK 25依然成立,以后应该也成立。
5.3 迁移到JDK 25时要检查依赖库
升级JDK时,最担心的往往不是自己的代码,而是依赖的三方库。如果库内部大量使用synchronized,且临界区里有阻塞IO,升级到JDK 25后这个问题会明显缓解。但如果是库本身直接调用阻塞式native方法,那JDK版本升级解决不了,只能等库更新。
建议升级后做一轮针对性压测:把原有的并发压测脚本跑一遍,同时开JFR,统计jdk.VirtualThreadPinned事件数量。如果这个数量没有明显下降,说明瓶颈主要在IO侧而不是synchronized侧。如果数量确实归零,那说明之前的问题被JEP 491真正修复了。
6. 常见问题速查与踩坑记录
6.1 常见问题速查表
| 问题 | 答案/处理建议 |
|---|---|
| JDK 25还有synchronized导致的Pinning吗 | 基本没有了,JEP 491在JDK 24已修复 |
| 所有Pinning都消失了吗 | 没有,锁块内的native阻塞IO仍会pin |
| 用什么参数可以快速看到pinned线程 | -Djdk.tracePinnedThreads=full/short |
| 生产环境怎么定位Pinning | jcmd抓线程dump,JFR统计事件 |
| Pinning主要影响什么 | 载体线程被占用,虚拟线程并发能力下降 |
| 是否应把所有synchronized改成ReentrantLock | 不建议一刀切,热点临界区或锁内有IO时再改 |
| JEP 491只影响虚拟线程吗 | 主要面向虚拟线程,对普通平台线程无负面影响 |
| JDK 25适合直接上生产吗 | 推荐先做兼容性和压测评估,重点看依赖库锁实现 |
6.2 踩过的坑和实测心得
第一次跑复现实验时,我忘了在JDK 21上开-Djdk.tracePinnedThreads,只看到总耗时从60秒降到120毫秒,一时间还没想明白为什么。后来补看了线程转储,才意识到之前那些任务都在Object.wait上排队,把载体线程全钉死了。
还有一次在测试JDK 25时,我把synchronized方法里的Thread.sleep()换成了FileInputStream.read(),结果pinned标记又出现了。这个细节提醒我,JDK修的是monitor,不是native IO。写代码时务必把锁和IO分离,这个原则不管JDK版本怎么变都成立。
最后分享一个小技巧:做版本升级评估时,不要只看GC日志和压测TPS。把虚拟线程的pinned事件纳入监控指标,可能比调优G1参数更有价值。JDK 25把最大的那个坑填了,但剩下的坑需要你自己绕开。
