承接的问题
14.1 说虚拟线程遇到阻塞会 unmount,把载体让出去。但有一条旧规则在网上流传很广:虚拟线程里不要用 synchronized,它会 pin 住载体。这条规则在 JDK 24 之前成立,在 JDK 25 已经过期。
JEP 491(JDK 24)改掉了监视器的实现,虚拟线程在 synchronized 块里阻塞时可以正常卸载。继续按旧规则把 synchronized 全部换成 ReentrantLock,不仅没有必要,还会丢掉监视器的简洁性和异常安全。这一节用 JFR 把「还剩什么会 pin」逐项测出来。
JEP 491 改了什么
旧实现里,synchronized 的锁记录分配在载体线程的栈上,对象头的 Mark Word 指向这条锁记录。虚拟线程一旦在临界区阻塞,锁记录还留在载体栈里,换一条载体就没法恢复,所以 JVM 只能让它 pin 住当前载体。
JEP 491 之后,监视器的所有权跟着虚拟线程走,而不是跟着载体走。虚拟线程在临界区里 park 时,栈片段照常冻结入堆,监视器状态一并保存;恢复时换哪条载体都不影响正确性。Object.wait 也走同一条路径。
先用实验验证。下面这段程序开两个阶段,各自独立录一段 JFR,只启用 jdk.VirtualThreadPinned 事件,阈值设成 1 ms:
import jdk.jfr.*;
import jdk.jfr.consumer.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
public class PinningDemo {
static final class SlowInit {
static final long VALUE;
static { sleep(300); VALUE = System.nanoTime(); }
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static void runPhase(String name, Runnable phase) throws Exception {
Path file = Path.of("/tmp/java-conc/ch14/" + name + ".jfr");
Files.deleteIfExists(file);
long t0 = System.nanoTime();
try (Recording rec = new Recording()) {
rec.enable("jdk.VirtualThreadPinned")
.withThreshold(Duration.ofMillis(1)).withStackTrace();
rec.start();
phase.run();
rec.stop();
rec.dump(file);
}
long elapsed = (System.nanoTime() - t0) / 1_000_000;
List<RecordedEvent> events = new ArrayList<>();
try (RecordingFile rf = new RecordingFile(file)) {
while (rf.hasMoreEvents()) {
RecordedEvent e = rf.readEvent();
if (e.getEventType().getName().equals("jdk.VirtualThreadPinned")) {
events.add(e);
}
}
}
System.out.println("phase=" + name + " elapsed=" + elapsed
+ "ms pinnedEvents=" + events.size());
events.stream().limit(3).forEach(e -> {
System.out.printf(" duration=%.1fms thread=%s%n",
e.getDuration().toNanos() / 1_000_000.0,
e.getThread("eventThread").getJavaName());
e.getStackTrace().getFrames().stream().limit(4).forEach(f ->
System.out.println(" at " + f.getMethod().getType().getName()
+ "." + f.getMethod().getName()));
});
}
public static void main(String[] args) throws Exception {
runPhase("sync", () -> {
int n = 20;
Object[] locks = new Object[n];
for (int i = 0; i < n; i++) locks[i] = new Object();
CountDownLatch done = new CountDownLatch(n);
for (int i = 0; i < n; i++) {
Object lock = locks[i];
Thread.ofVirtual().start(() -> {
synchronized (lock) { sleep(50); }
done.countDown();
});
}
try { done.await(30, TimeUnit.SECONDS); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
runPhase("clinit", () -> {
int n = 10;
CountDownLatch go = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(n);
for (int i = 0; i < n; i++) {
Thread.ofVirtual().start(() -> {
try {
go.await();
if (SlowInit.VALUE == 0) throw new AssertionError();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
done.countDown();
});
}
go.countDown();
try { done.await(30, TimeUnit.SECONDS); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
}
本机输出(Apple M1 Pro,10 核,load averages 约 209;节选 sync 与 clinit 两个阶段):
java.version = 25.0.5
phase=sync elapsed=58ms pinnedEvents=0
phase=clinit elapsed=308ms pinnedEvents=19
duration=303.9ms thread=clinit-9
at java.lang.VirtualThread.postPinnedEvent
at java.lang.VirtualThread.parkOnCarrierThread
at java.lang.VirtualThread.parkNanos
at java.lang.VirtualThread.sleepNanos
duration=3.2ms thread=clinit-7
at jdk.internal.loader.BuiltinClassLoader.loadClassOrNull
at jdk.internal.loader.BuiltinClassLoader.loadClass
at java.lang.ClassLoader.loadClass
at PinningDemo.lambda$main$3
duration=304.1ms thread=clinit-7
at PinningDemo.lambda$main$3
at PinningDemo$$Lambda.0x00000fe00104d800.run
at java.lang.Thread.runWith
at java.lang.VirtualThread.run
20 条虚拟线程各自在自己的锁里睡 50 ms,pin 事件 0 个。同样的阻塞换到类初始化里,10 条线程产生 19 个事件,其中最长 303.9 ms,栈顶就是 parkOnCarrierThread——它确实卡在载体上。
还剩哪两类 pin
任意 JNI 与 FFM 调用
虚拟线程调用 native 方法时,原生帧在载体线程的栈上,JVM 没法把它挪到堆里。只要这次调用不返回,载体就一直被占着。FFM 的 downcall 同理。
而且这类 pin 不一定会出现在 JFR 事件里。jdk.VirtualThreadPinned 是在 VirtualThread.postPinnedEvent 里发出的,触发点是「已经 pin 住的虚拟线程调用了 park」。纯 native 调用期间没有 park,就没有事件。本机用 FFM 调 usleep(300ms),事件数为 0。
判断方法是看载体有没有被占住:开 10 条虚拟线程同时调 native,再起一条探针虚拟线程,量它从 start() 到真正运行的时间。下面的程序把 native 调用换成 500 ms,让占用窗口足够长。
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class NativeOccupancy {
static final int CARRIERS = 10;
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
MethodHandle usleep = linker.downcallHandle(
linker.defaultLookup().find("usleep").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
for (int round = 1; round <= 3; round++) {
System.out.printf("round %d%n", round);
System.out.printf(" native usleep: probeDelay=%dms%n", probe(usleep, true));
System.out.printf(" Thread.sleep : probeDelay=%dms%n", probe(usleep, false));
}
}
static long probe(MethodHandle usleep, boolean nativeCall) throws Exception {
CountDownLatch started = new CountDownLatch(CARRIERS);
CountDownLatch done = new CountDownLatch(CARRIERS);
for (int i = 0; i < CARRIERS; i++) {
Thread.ofVirtual().start(() -> {
started.countDown();
try {
if (nativeCall) {
int rc = (int) usleep.invokeExact(500_000);
if (rc != 0) throw new IllegalStateException("rc=" + rc);
} else {
Thread.sleep(500);
}
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
done.countDown();
}
});
}
started.await();
Thread.sleep(50); // 让 10 条全部进入阻塞点
AtomicLong delay = new AtomicLong(-1);
long t0 = System.nanoTime();
Thread probe = Thread.ofVirtual().start(() ->
delay.set((System.nanoTime() - t0) / 1_000_000));
probe.join();
done.await(5, TimeUnit.SECONDS);
return delay.get();
}
}
运行命令:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
java --enable-native-access=ALL-UNNAMED -Xmx256m NativeOccupancy
本机输出(同一台机器,load averages 约 155–171):
round 1
native usleep: probeDelay=450ms
Thread.sleep : probeDelay=0ms
round 2
native usleep: probeDelay=437ms
Thread.sleep : probeDelay=0ms
round 3
native usleep: probeDelay=441ms
Thread.sleep : probeDelay=0ms
10 条 native 调用正好占满 10 条载体,探针要等约 450 ms 才拿到载体;同样数量的 Thread.sleep 则立刻让出。这个对照不依赖 JFR,也不受事件实现影响。
类初始化边界
<clinit> 期间 JVM 持有类初始化锁,这个锁不是 synchronized 监视器,JEP 491 覆盖不到。一个虚拟线程在静态初始化块里阻塞,会 pin 住载体;其他等待同一个类初始化的线程也跟着被挡住。
上面的实验已经量化了:10 条线程竞争同一个 SlowInit,19 个 pin 事件,最长 303.9 ms。注意事件数比线程数多,因为等待初始化锁、类加载过程中的锁也会各记一次。
这类 pin 通常只在启动阶段出现,但也有例外:懒加载的模板引擎、序列化器、JDBC 驱动,第一次请求时才触发 <clinit>,而初始化里可能读文件、连服务。流量刚起来的那个瞬间,载体被几条初始化线程占满,表现就是「一开始很慢,跑一会儿自己好了」。
用 JFR 观察 pinning
生产上不要等出事才开 JFR。jdk.VirtualThreadPinned 默认阈值 20 ms,可以用 -XX:StartFlightRecording 直接带上:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
java -XX:StartFlightRecording=duration=60s,filename=/tmp/pin.jfr,settings=profile \
-jar libraryd-conc.jar
jfr print --events jdk.VirtualThreadPinned --stack-depth 8 /tmp/pin.jfr
jfr summary /tmp/pin.jfr | grep VirtualThreadPinned
读事件时看三个字段:duration 判断影响大小,eventThread 定位是哪条虚拟线程,stackTrace 找 pin 的根因。阈值调到 1 ms 会放大噪声,排查期用 1–5 ms,长期监控保持默认 20 ms。
但记住上一节的限制:事件为 0 不等于没有 pin。native 占用不 park,不会发事件;要配合「载体数 vs 可运行虚拟线程数」的观察。载体只有 N 条,而大量虚拟线程长时间处于可运行却没进展,就要怀疑 native 或 <clinit>。
在 libraryd-conc 里落地
VirtualLoanService 做两件事:启动时把模板类初始化预热掉;把可能长时间阻塞的原生调用挪到平台线程池。这里只列与 pinning 相关的成员,reserve / returnBook 的完整实现在 14.1。
public final class VirtualLoanService implements AutoCloseable {
private static final System.Logger LOG = System.getLogger("loan.vt");
// 静态初始化会读模板文件;在构造期、平台线程上触发,避免首个请求 pin 载体
static final class Templates {
static final Map<String, String> ALL = load();
private static Map<String, String> load() {
return Map.of("mail", "mail-template", "sms", "sms-template", "push", "push-template");
}
}
private final BookRepository books;
private final LoanRepository loans;
private final ExecutorService fanout = Executors.newVirtualThreadPerTaskExecutor();
private final ExecutorService nativePool = Executors.newFixedThreadPool(4);
public VirtualLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
Templates.ALL.size(); // 预热 <clinit>
}
private void send(String channel, Reservation r) {
if (channel.equals("push")) {
// 原生压缩库的调用可能阻塞几十毫秒,放平台线程池,别占载体
nativePool.submit(() -> pushCompressed(r));
} else {
LOG.log(System.Logger.Level.DEBUG, channel + " " + r.loanId());
}
}
private void pushCompressed(Reservation r) {
// 假设底层压缩是 native/FFM 实现;这里保留调用边界
byte[] payload = ("loan:" + r.loanId())
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
LOG.log(System.Logger.Level.DEBUG, "push " + payload.length + " bytes");
}
@Override
public void close() throws InterruptedException {
fanout.shutdown();
nativePool.shutdown();
fanout.awaitTermination(5, TimeUnit.SECONDS);
nativePool.awaitTermination(5, TimeUnit.SECONDS);
}
}
reserve / returnBook 仍然用 synchronized 保护库存读改写。JEP 491 之后它不再 pin,没有理由为了虚拟线程把它换掉;要换 ReentrantLock 的理由只有 tryLock、可中断、公平锁这些真实需求(9.2 讨论过)。
什么时候不要用虚拟线程
| 场景 | 问题 | 做法 |
|---|---|---|
| 高频、长耗时的 JNI/FFM | 每次调用都占住载体,10 条 native 就能让调度器停摆 | 专用平台线程池,或改成异步 native 接口 |
| 启动期大量类初始化 | <clinit> 读文件、连服务,首波流量被 pin 卡住 | 启动时预热;初始化逻辑移出静态块 |
| 以为「事件为 0 就安全」 | native pin 不发 JFR 事件 | 加载体饥饿探针 / 看线程 dump |
因为旧文章把 synchronized 全换掉 | 丢失监视器的异常安全和简洁性,收益为 0 | JDK 24+ 保留 synchronized |
| 纯 CPU 任务 | 没有阻塞可卸载,pin 与否都不影响「载体不够」 | 平台线程池或 ForkJoin |
还有一条:synchronized 不 pin 不等于「临界区可以随便放大」。临界区里做 100 ms 的网络调用,仍然会让其他竞争同一把锁的虚拟线程排队,只是不占载体而已。锁的粒度问题依旧存在,只是从「占载体」降级成「占锁」。
小结
JDK 25 里 pinning 只剩两块:任意 JNI/FFM 调用期间,以及类初始化边界。synchronized 和 Object.wait 在 JEP 491 之后都能正常卸载,本机 JFR 实测 20 条同步块阻塞产生 0 个 pin 事件,类初始化阻塞产生 19 个。
jdk.VirtualThreadPinned 能抓到「pinned 后 park」的场景,抓不到纯 native 占用;后者要用载体饥饿实验或线程 dump 判断。本机 10 条 FFM usleep 让探针延迟 437–450 ms,同样数量的 Thread.sleep 是 0 ms。落地层面的两条规则:启动时预热 <clinit>,长 native 调用挪到平台线程池。
阅读导航:上一节 14.1 虚拟线程的调度模型 | 下一节 14.3 虚拟线程下的池、ThreadLocal 与限流
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。