《Java 并发编程·性能诊断与事故》6.2 pinning 的诊断与消除

JDK 24 的 JEP 491 让 synchronized 不再 pin 载体线程,旧教材的清单已经过期。本节在本机把十种阻塞写法逐个实测,给出类初始化、Object.wait、native 帧三类仍然会 pin 的场景与 JFR 事件原文,并说明为什么阈值必须降到 0ms。

承接的问题

构造场景: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)960不 pin
synchronized + LockSupport.parkNanos(5ms)3000不 pin
静态 synchronized 方法 / 嵌套 synchronized6000不 pin
监视器竞争(5 个线程排队进入,持锁 200 ms)50不 pin
ReentrantLock + sleep(5)3000不 pin
Object.wait,每个线程独占监视器2400本机未观测到
Object.wait,8 线程共享监视器,wait(10ms)~2400529pin
类初始化 <clinit> 内阻塞 200 ms1616pin
JNI 回调内 Thread.sleep11pin
native 方法内直接阻塞80无事件,但载体被占死

监视器竞争那一行的 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)~2400
8 线程共享监视器,wait(300ms)~801
8 线程共享监视器,wait(100ms)~24015
8 线程共享监视器,wait(50ms)~480194
8 线程共享监视器,wait(10ms)~2400529

事件长这样,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=fullJDK 25 已移除,无任何输出用 JFR
「synchronized 会 pin」照搬到 JDK 25白改一轮锁,性能不变JEP 491 之后不再 pin
用 jdk.JavaMonitorEnter 查虚拟线程竞争该事件不记录虚拟线程抓 Thread.dump_to_file 看 BLOCKED
靠并行度=1 的串行度判 pinFJ 会补偿载体以 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收