承接的问题
构造场景:libraryd-conc 的借阅查询整体迁到虚拟线程,一次压测起 8 万个虚拟线程,每个线程里有一次缓存查询(synchronized 保护的本地 LRU)、一次下游调用、一次审计写入。
现象:
- 8 万个虚拟线程,
VirtualThreadSchedulerMXBean.getMountedVirtualThreadCount()长期等于 10; - 整机 CPU 只用 1.2 个核,
iostat显示磁盘空闲、网络空闲; - 吞吐卡在 900/s,加虚拟线程数量没有反应,降到 2 万个也一样;
- 火焰图上
Object.wait与VirtualThread.parkOnCarrierThread占了绝大部分采样。
值班同学在 JDK 21 时代学过一条规则:「虚拟线程在 synchronized 块里阻塞会 pin 住载体线程」。于是他把 synchronized 全换成 ReentrantLock,重新压测——吞吐一点没变。
这就是本节要解决的问题:那条规则在 JDK 25 上还成立吗?如果不成立,是什么在 pin?
结论先行:旧清单已经过期
JDK 24 的 JEP 491
(Synchronize Virtual Threads without Pinning)重写了监视器实现,让虚拟线程在 synchronized 里阻塞时可以正常卸载。JDK 25 沿用这套实现。所以 JDK 21 时代流传的那份 pin 清单,在 JDK 25 上大部分条目已经失效:
| JDK 21 时代流传的 pin 场景 | JDK 25 本机实测 |
|---|---|
synchronized 块里 Thread.sleep | 不 pin |
synchronized 块里阻塞 IO | 不 pin |
synchronized 块里 LockSupport.park | 不 pin |
等待进入已被占用的监视器(BLOCKED) | 不 pin |
Object.wait() | 仍会 pin,但有条件 |
类初始化(<clinit>)期间阻塞 | 仍会 pin |
| native 帧 / JNI 在栈上时阻塞 | 仍会 pin |
左列是 JDK 21 时代流传的说法,不是本机复测——本机只装了 JDK 8 和 JDK 25,没有 JDK 21 可供对照。右列每一行都是本节在本机跑出来的,附命令与输出原文。先讲方法,因为方法错了,结论就会错——这是本节最容易踩的坑。
测量方法:阈值必须降到 0 ms
jdk.VirtualThreadPinned 在 profile.jfc 里的默认阈值是 20 ms。也就是说,任何短于 20 ms 的 pin 都不会被记录。用默认配置测「synchronized 里 sleep(2)」,得到的 0 条事件不能证明任何事——它只证明「没有超过 20 ms 的 pin」。
用一个 5 ms 的类初始化 pin 做对照,同一份代码、同一个 JVM:
--- 默认 profile.jfc(阈值 20 ms)---
wall=10ms
jdk.VirtualThreadPinned 0 0
--- 命令行 override threshold=0ms ---
wall=8ms
jdk.VirtualThreadPinned 5 355
两段计数都是 jfr summary 的原始输出。事件明细里每条 7.24–7.52 ms:
duration = 7.51 ms
pinnedReason = "VM call to Pin5ms$C1.<clinit> on stack"
eventThread = "short-pin-1" (javaThreadId = 32, virtual)
同一批 7.5 ms 的 pin,默认阈值下计数是 0,阈值归零后是 5。所以本节所有 pin 测试都显式写了 threshold=0ms,后面每个「0 条」才是有意义的证据。
三种开录制的写法
JDK 25 的 jfr 命令行没有 start 子命令,jfr help 只列出 print / view / configure / metadata / scrub / summary。启动录制有三条路:
# 写法一:命令行直接 override 事件设置
java -XX:StartFlightRecording=filename=/tmp/pin.jfr,dumponexit=true,\
jdk.VirtualThreadPinned#threshold=0ms -cp out YourMain
# 写法二:先生成一份阈值为 0 的配置,再引用它(团队内复用最方便)
jfr configure --input "$JAVA_HOME/lib/jfr/profile.jfc" \
--output /tmp/custom.jfc jdk.VirtualThreadPinned#threshold=0ms
java -XX:StartFlightRecording=filename=/tmp/pin.jfr,settings=/tmp/custom.jfc,dumponexit=true \
-cp out YourMain
# 写法三:对已经跑起来的进程在线抓 10 秒
jcmd <pid> JFR.start name=pin settings=profile \
jdk.VirtualThreadPinned#threshold=0ms duration=10s filename=/tmp/pin.jfr
写法二的输出是 Configuration written successfully to: /tmp/custom.jfc,生成的文件里能看到改过的设置:
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">0 ms</setting>
还有一个坑:-Djdk.tracePinnedThreads=full 在 JDK 25 已经不起作用。本机用这个参数跑一个确定会 pin 的类初始化场景,标准输出里什么都没有;在 java.base 源码里 grep tracePinnedThreads 是 0 处。网上大量 JDK 21 时代的文章还在教这个参数,照抄会得到「没有 pin」的假象。
一个可以同时验证两件事的录制
把「已知会 pin」和「声称不 pin」的两类阻塞放进同一个 10 秒录制,是最省事的自检方式:
import java.util.concurrent.atomic.AtomicLong;
/** 自检 + 对照:2 次类初始化 pin,96 次 synchronized+sleep,379 次 Object.wait。 */
public final class MixedEvidence {
static final Object MON = new Object();
static final Object WAIT_MON = new Object();
static final AtomicLong SYNC = new AtomicLong(), WAIT = new AtomicLong();
static volatile boolean stop;
static final class InitA { static final int V; static { sleep(5); V = 1; } static int v() { return V; } }
static final class InitB { static final int V; static { sleep(5); V = 2; } static int v() { return V; } }
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 4; i++) {
Thread.ofVirtual().name("sync-" + i).start(() -> {
while (!stop) { synchronized (MON) { sleep(100); SYNC.incrementAndGet(); } }
});
Thread.ofVirtual().name("wait-" + i).start(() -> {
while (!stop) {
synchronized (WAIT_MON) {
try { WAIT_MON.wait(100); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
WAIT.incrementAndGet();
}
}
});
}
Thread.ofVirtual().name("self-check").start(() -> {
sleep(1000);
InitA.v(); // 第 1 次自检 pin
sleep(4000);
InitB.v(); // 第 2 次自检 pin
});
Thread.sleep(10_000);
stop = true;
Thread.sleep(300);
System.out.printf("synchronized+sleep=%d, synchronized+wait=%d%n", SYNC.get(), WAIT.get());
}
}
用 custom.jfc(阈值 0 ms)跑出来:
synchronized+sleep 次数=96, synchronized+wait 次数=379
jdk.VirtualThreadPinned 87 4924
87 条里,2 条来自 self-check(6.82 ms 与 5.12 ms 的类初始化),85 条来自 wait-3(Object.wait)。96 次 synchronized 里的 100 ms 阻塞,一条都没有。 自检事件在,说明录制链路和 0 ms 阈值都生效;在这个前提下,「synchronized 组 0 条」才是证据。
实测矩阵
把所有写法逐项跑一遍,阻塞时长覆盖 5 ms 到 400 ms。短于 20 ms 的那几行只有在阈值归零之后才有意义,这也是前面那段对照实验的作用:
| 场景 | 阻塞次数 | pin 事件 | 结论 |
|---|---|---|---|
synchronized + Thread.sleep(100) | 96 | 0 | 不 pin |
synchronized + LockSupport.parkNanos(5ms) | 300 | 0 | 不 pin |
静态 synchronized 方法 / 嵌套 synchronized | 600 | 0 | 不 pin |
| 监视器竞争(5 个线程排队进入,持锁 200 ms) | 5 | 0 | 不 pin |
ReentrantLock + sleep(5) | 300 | 0 | 不 pin |
Object.wait,每个线程独占监视器 | 240 | 0 | 本机未观测到 |
Object.wait,8 线程共享监视器,wait(10ms) | ~2400 | 529 | pin |
类初始化 <clinit> 内阻塞 200 ms | 16 | 16 | pin |
JNI 回调内 Thread.sleep | 1 | 1 | pin |
| native 方法内直接阻塞 | 8 | 0 | 无事件,但载体被占死 |
监视器竞争那一行的 jdk.JavaMonitorEnter 也是 0 条:本机把阈值设成 0 ms 后,5 个虚拟线程在监视器上排队 200 ms,jdk.JavaMonitorEnter 只记到 2 条来自 JFR 自身平台线程的记录。虚拟线程的监视器竞争不会出现在这个事件里,别指望用 3.1 的 JFR Monitor 事件查虚拟线程的锁竞争,那套方法只对平台线程有效。
仍然 pin 之一:类初始化
16 个不同的类同时做 200 ms 的 <clinit>,默认 10 个载体。程序输出与 jfr summary 摘录:
wall=422ms (16 个 200ms 的 <clinit>;10 载体若全 pin 需≈400ms, 不 pin≈200ms)
jdk.VirtualThreadPinned 16 1228
duration = 202 ms
pinnedReason = "VM call to ClinitStorm$C2.<clinit> on stack"
eventThread = "clinit-2" (javaThreadId = 34, virtual)
三次运行分别是 422 ms、429 ms、462 ms,都落在「两轮」而不是「一轮」上,16 条事件一条不少。类初始化期间阻塞仍然 pin 住载体,这是 JDK 25 上最容易被忽略的一条。
它的危害不在慢,而在放大:一次 <clinit> 慢 200 ms,只会 pin 一个载体;但如果启动期有几十个类同时初始化(典型的是各种懒加载的配置类、正则 Pattern、DateTimeFormatter),就会瞬间把 10 个载体全钉住,其余几万个虚拟线程一个都跑不了。
处置办法很直接:把慢初始化挪到启动期。用 Class.forName(name, true, loader) 或一次空调用把关键的类提前初始化,别让它在流量高峰期发生。这不是虚拟线程特有的问题,但虚拟线程让它的后果从「一个线程卡住」变成「整个调度器卡住」。
仍然 pin 之二:Object.wait
Object.wait 的情况比教材写的复杂。同样是 8 个虚拟线程、3 秒、wait 循环,只改等待时长和监视器是否共享:
| 配置 | wait 次数 | pin 事件 |
|---|---|---|
每个线程独占监视器,wait(100ms) | ~240 | 0 |
8 线程共享监视器,wait(300ms) | ~80 | 1 |
8 线程共享监视器,wait(100ms) | ~240 | 15 |
8 线程共享监视器,wait(50ms) | ~480 | 194 |
8 线程共享监视器,wait(10ms) | ~2400 | 529 |
事件长这样,blockingOperation 明确写着 Object.wait:
duration = 107 ms
blockingOperation = "Object.wait"
pinnedReason = "Native or VM frame on stack"
carrierThread = "ForkJoinPool-1-worker-4" (javaThreadId = 42)
eventThread = "wait-3" (javaThreadId = 39, virtual)
stackTrace = [
java.lang.Object.wait0(long)
java.lang.Object.wait(long) line: 382
MixedEvidence.lambda$main$1() line: 38
java.lang.VirtualThread.run(Runnable) line: 460
栈顶是 Object.wait0,这是个 native 方法——这就解释了 pinnedReason:虚拟线程要卸载,栈上不能有 native 帧。独占监视器时本机一条都没测到,共享监视器且唤醒频繁时能稳定复现,具体是哪条路径决定「能不能卸载」,本节没有归因(它取决于 HotSpot 监视器的唤醒与重入时序),能确定的是:这个场景在 JDK 25 上并未消失,而且和等待时长强相关。
工程结论不受影响:虚拟线程代码里不要用 Object.wait。等条件用 ReentrantLock + Condition.await(),等事件用 ArrayBlockingQueue.take() 或 SynchronousQueue,都是不含 native 帧的纯 Java park 路径。
仍然 pin 之三:native 帧
两种 JNI 形态要分开看。
形态 A:native 方法里直接阻塞。 本机写了一个 native void sleepMillis(int),内部 usleep,8 个虚拟线程各阻塞 200 ms,载体并行度压到 1:
jni starvation N=8 block=200ms wall=1690ms
1690 ms ≈ 8 × 211 ms,完全串行。但这次录制里 jdk.VirtualThreadPinned 是 0 条——因为虚拟线程根本没有「尝试 park」,它只是被 native 代码占着,事件自然不会触发。这是 JFR 的一个盲区:JNI 里傻等不会产生 pin 事件,但载体确实被占死了。
形态 B:native 帧在栈上,回调 Java 代码阻塞。 native 方法通过 CallVoidMethod 回调 Runnable.run(),里面 Thread.sleep(300):
duration = 310 ms
blockingOperation = "LockSupport.park"
pinnedReason = "Native or VM frame on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 31)
eventThread = "jni-callback-park" (javaThreadId = 30, virtual)
形态 B 才有事件,因为这时虚拟线程真的想 park,而栈上的 native 帧阻止了卸载。所以判断「JNI 会不会拖住载体」,不能只看 jdk.VirtualThreadPinned 的计数,还得看 mounted 是否长期顶满 parallelism。
为什么不能用「并行度调成 1」来判 pin
一个常见的土办法是把 -Djdk.virtualThreadScheduler.parallelism=1,再看 N 个阻塞任务是不是串行。本机测下来这个办法不可靠:
8 个确定会 pin 的任务(JNI 回调 sleep),并行度 1,墙钟 841 ms,而同期统计到的载体线程是 2 个——ForkJoinPool 在 worker 被钉住时会补偿出新载体,串行程度因此被稀释。真正的串行只发生在「载体被占住且 FJ 看不见」的形态 A(1690 ms)。
结论:判 pin 以 JFR 事件为准,饥饿实验只作辅助,而且要记录载体数量,否则会把补偿误读成「没有 pin」。
消除与替代写法
按三类剩余场景分别处置:
| 场景 | 处置 | 不作为的后果 |
|---|---|---|
| 类初始化 | 启动期 Class.forName(..., true, ...) 预热 | 高峰期数十个载体被同时钉住 |
Object.wait | 换 Condition.await() / 阻塞队列 | 共享监视器高频唤醒时随机 pin |
| JNI / native 阻塞 | 隔离到独立平台线程池;或换纯 Java 实现 | 载体被占死,且 JFR 无事件 |
前两条是代码改造,第三条是架构隔离:任何「会进 native 且可能长时间不返回」的调用(本地加密库、老式驱动、JNI 封装的图像处理),都不该直接跑在虚拟线程上。给它一个固定大小的平台线程池,虚拟线程把任务投进去再等 Future,载体就不会被占住。
Semaphore 解决不了 pin:它限制的是「同时有多少个在跑」,而 pin 的问题是「这 10 个载体被谁占着」。限流能缓解,但根因还在。
在 libraryd-conc 里落地
把「pin 事件」接进日常巡检,而不是等出故障才去抓。conc-ops 里加一个录制生命周期管理:
package com.plume.conc.ops;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* 开一段只收 VirtualThreadPinned 的录制,按 pinnedReason 聚合。
* 注意:必须显式把阈值设成 0,否则短 pin 全被过滤。
*/
public final class PinWatch {
public static Map<String, Long> sample(Path out, Duration d) throws Exception {
try (Recording r = new Recording()) {
r.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ZERO).withStackTrace();
r.start();
Thread.sleep(d.toMillis());
r.stop();
r.dump(out);
Map<String, Long> byReason = new HashMap<>();
try (RecordingFile file = new RecordingFile(out)) {
while (file.hasMoreEvents()) {
RecordedEvent e = file.readEvent();
if (!e.getEventType().getName().equals("jdk.VirtualThreadPinned")) {
continue;
}
String reason = e.getString("pinnedReason");
byReason.merge(reason == null ? "<null>" : reason, 1L, Long::sum);
}
}
return byReason;
}
}
}
withThreshold(Duration.ZERO) 是这段代码唯一的重点。默认构造出来的事件是 20 ms,忘了这一行,整个类返回空表,看起来「很健康」。
巡检规则建议这样定:
byReason里出现VM call to ... <clinit>→ 排查启动期类初始化,列为 P2;- 出现
Native or VM frame on stack且比例超过总 pin 的 5% → 排查 JNI 调用点,列为 P1; - 出现
Object.wait→ 直接找到那个wait调用点,改成Condition,列为 P1; mounted == parallelism持续 5 分钟:载体全被挂载的虚拟线程占住。CPU 高是算得满,CPU 低于 40% 就是有人在 pin,先抓 10 秒录制再判断。注意正常的「等下游」不会让mounted顶满——等 I/O 的虚拟线程是卸载的。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
用默认 profile.jfc 测 pin | 短于 20 ms 的 pin 全被过滤,得到假阴性 | threshold=0ms |
用 -Djdk.tracePinnedThreads=full | JDK 25 已移除,无任何输出 | 用 JFR |
「synchronized 会 pin」照搬到 JDK 25 | 白改一轮锁,性能不变 | JEP 491 之后不再 pin |
用 jdk.JavaMonitorEnter 查虚拟线程竞争 | 该事件不记录虚拟线程 | 抓 Thread.dump_to_file 看 BLOCKED |
| 靠并行度=1 的串行度判 pin | FJ 会补偿载体 | 以 JFR 事件为准 |
| 只看 pin 事件就断言「没有 native 阻塞」 | 形态 A 的 JNI 不产生事件 | 同时看 mounted/parallelism |
什么时候不用管 pin:如果你的虚拟线程总量只有几百个、载体有 10 个,偶尔 pin 一两个载体不会影响吞吐——被钉住的那个线程本来就该等。pin 是规模化之后的问题:线程数远大于载体数时,被钉住的载体才是稀缺资源。判断阈值可以粗略地取「虚拟线程数 / 载体数 > 100」,低于这个比例,先优化别处。
小结
JDK 24 的 JEP 491 之后,synchronized 在 JDK 25 上不再 pin 载体线程。本机用 0 ms 阈值实测:synchronized 里 sleep、park、阻塞 IO、监视器竞争、静态与嵌套同步,合计上千次阻塞,jdk.VirtualThreadPinned 一条都没有。
仍然会 pin 的是三类:类初始化(16 个并发慢 <clinit> 在 10 个载体上跑出 422 ms,两轮串行)、Object.wait(共享监视器、wait(10ms) 时 3 秒 529 条,独占监视器时 0 条)、native 帧(JNI 回调中 park 稳定复现,pinnedReason = "Native or VM frame on stack")。
方法上有两个必须坚持的点:阈值降到 0 ms,并且放一个「已知会 pin」的自检事件在同一个录制里。JDK 25 的 jfr 命令行没有 start,在线抓取用 jcmd JFR.start;-Djdk.tracePinnedThreads 已经不存在。
下一节讨论规模化之后另外三个必须处理的东西:连接池上限、ThreadLocal 的拷贝成本、以及 MDC 在虚拟线程里的传递。
阅读导航:上一节 6.1 十万/百万虚拟线程实测 | 下一节 6.3 连接池、ThreadLocal 与 MDC
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。