本节目标:看清虚拟线程如何挂在载体上、什么时候卸载、什么时候 pin,以及 1000 个虚拟线程为什么只对应几个载体。
适用版本:JDK 25(Temurin 25.0.5+8);JEP 491 已生效;Thread.dump_to_file -format=json。
承接的问题
8.1 的 JSON 转储里,1000 个虚拟线程和 11 个 ForkJoinPool-1 线程同时出现。一个自然的疑问是:1000 个线程在睡 10 分钟,为什么只需要 10 个平台线程陪着?如果它们同时在跑,会发生什么?
libraryd 的 DashboardService 也依赖这个模型:一个面板请求扇出两个查询,每个查询一个虚拟线程,下游连接池只有 10 个连接。虚拟线程让「等待」变便宜,但不会让连接变多,也不会让 CPU 变多。要判断线上「虚拟线程 5000 个、CPU 只有 30%」是下游慢、载体被占住,还是锁竞争,得先看清运行时。
这一节回答四个问题:虚拟线程的栈在哪里、挂起时发生了什么;载体是谁、调度器怎么选下一个;JDK 25 里还有哪些操作会 pin 住载体;为什么 JFR 的 jdk.VirtualThreadPinned 经常是 0,以及什么时候不能相信这个 0。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1。
Continuation 与载体:栈在堆上,挂起即卸载
平台线程的栈是操作系统分配的本地内存,默认 1 MB 左右;创建几千个就要几 GB。虚拟线程的栈帧存在堆上的 Continuation 对象里(实现上是 jdk.internal.vm.Continuation 和 StackChunk),只在运行时才被「挂载」到某个载体线程的栈上。
一次挂起大致是:
- 虚拟线程执行到会阻塞的操作(
sleep、park、socket read 等)。 - JVM 把当前栈帧从载体线程的栈复制回堆上的
StackChunk。 - 虚拟线程进入等待队列,卸载(unmount),载体线程空出来去跑别的虚拟线程。
- 条件满足后,调度器把虚拟线程重新挂载(mount)到某个空闲载体上,从上次的帧继续执行。
关键点是第 4 步:恢复时不一定回到原来的载体。所以「当前线程名」里的载体名不能用来标识业务请求,ThreadLocal 里也不应该存和载体绑定的东西。虚拟线程自己的 ThreadLocal 跟着虚拟线程走,不跟着载体走。
创建一个 1000 个虚拟线程、全部 sleep(600_000) 的进程,用 jcmd 看调度器:
java -cp out HoldVThreads 1000
jcmd <pid> Thread.vthread_scheduler
默认并行度下,本机输出:
java.util.concurrent.ForkJoinPool@44ad5bbd[Running, parallelism = 10, size = 0,
active = 0, running = 0, steals = 1000, tasks = 0, submissions = 0, delayed = 1000]
parallelism = 10 等于 CPU 核数;size = 0 表示当前没有常驻的载体线程在跑(1000 个虚拟线程都在睡,已经卸载);delayed = 1000 是 1000 个定时唤醒任务;steals = 1000 是调度过程中的工作窃取计数。size = 0 不代表虚拟线程不存在,它们只是没有占用载体。
把并行度改成 2,再跑一次:
java -Djdk.virtualThreadScheduler.parallelism=2 \
-Djdk.virtualThreadScheduler.maxPoolSize=2 \
-cp out HoldVThreads 1000
jcmd <pid> Thread.vthread_scheduler
本机输出:
java.util.concurrent.ForkJoinPool@166bfd78[Running, parallelism = 2, size = 2,
active = 0, running = 0, steals = 1000, tasks = 0, submissions = 0, delayed = 1000]
parallelism 是常驻载体数,maxPoolSize 是载体池上限。后者只在有载体被 pin 住、调度器需要额外线程来维持并行度时才用得上。生产环境一般不要动这两个参数:默认按核数设置,改小会让阻塞任务排队,改大只会增加上下文切换。
调度与观测:scheduler、poller 与 JSON 容器
虚拟线程的调度器是一个专用的 ForkJoinPool,载体是池里的平台线程,名字形如 ForkJoinPool-1-worker-N。调度策略是工作窃取:空闲载体从其他载体的队列里偷任务,尽量避免有人闲着。
在并行度为 2 的进程上抓 JSON 转储:
jcmd <pid> Thread.dump_to_file -format=json /tmp/vol4-d/vthreads/hold-threads.json
用 Python 按容器统计:
import json
d = json.load(open("hold-threads.json"))
for c in d["threadDump"]["threadContainers"]:
th = c.get("threads", [])
print(c["container"][:60], len(th), sum(1 for t in th if t.get("virtual")))
本机输出:
<root> 8 0
ForkJoinPool.commonPool/... 0 0
ForkJoinPool-1/... 3 0
java.util.concurrent.ThreadPerTaskExecutor@83f99ff 1000 1000
java.util.concurrent.ThreadPoolExecutor@610455d6 0 0
ForkJoinPool-1 容器里只有 3 个平台线程:ForkJoinPool-1-worker-1、worker-2 和 ForkJoinPool-1-delayScheduler。前两个是载体,第三个负责定时任务(sleep 的唤醒时间)。1000 个虚拟线程全部在 ThreadPerTaskExecutor 容器里,virtual=true。
这里有一个容易误读的地方:Thread.vthread_scheduler 说 size = 2,JSON 容器里有 3 个线程。多出来的 delayScheduler 不是载体,它只处理定时唤醒。统计载体数量时,按 worker- 前缀过滤,不要数容器里的所有线程。
I/O 还有一组 poller 线程。同一个进程执行:
jcmd <pid> Thread.vthread_pollers
本机输出(节选):
Read I/O pollers:
[0] sun.nio.ch.KQueuePoller@3cbd9ae1 [registered = 0, owner = null]
macOS 上用的是 kqueue。虚拟线程做 socket read 时,如果数据没到,会注册到 poller 并卸载;数据到达后 poller 唤醒调度器,虚拟线程重新挂载。registered = 0 表示当前没有注册的 fd,owner = null 是因为进程里没有读 I/O;实验里只有 sleep。
虚拟线程停在 socket read 时,Thread.print 看不到它,JSON dump 里是 VirtualThread.park 或 SocketDispatcher.read 的栈;不要把「平台线程都在 park」误判成「没有请求在处理」。
pinning:JDK 25 还剩哪些
「pin」指虚拟线程在阻塞时无法卸载,载体被占住。所有载体都被 pin 时,调度器只能创建额外的平台线程(到 maxPoolSize 为止),吞吐退回平台线程模型。
JDK 21 时代最著名的结论是「synchronized 会 pin,改用 ReentrantLock」。这个结论在 JDK 25 已经不成立:JDK 24 的 JEP 491 允许虚拟线程在 synchronized 块或方法中阻塞时卸载载体。现在还剩两类常见 pin:
- 本地方法/外部函数调用期间。JVM 看到的是「正在执行 native」,不知道它会在里面阻塞多久,无法安全地卸载栈。
- 类初始化。虚拟线程在等待另一个线程完成
<clinit>时,栈上有 VM 调用,不能卸载。
用一个心跳实验区分。载体池限制成 1 个,起一个虚拟线程每 1 ms 醒一次并计数;另一个虚拟线程阻塞 400 ms,模式分别是 sleep、sync-sleep、native、classinit:
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
public class PinDemo {
static final AtomicLong ticks = new AtomicLong();
static final AtomicBoolean ticking = new AtomicBoolean(true);
static class SlowInit {
static { try { Thread.sleep(400); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
static int value = 42;
}
public static void main(String[] args) throws Exception {
String mode = args[0];
Thread ticker = Thread.ofVirtual().name("vt-ticker").start(() -> {
while (ticking.get()) {
try { Thread.sleep(1); } catch (InterruptedException e) { return; }
ticks.incrementAndGet();
}
});
long begin = System.nanoTime();
Thread blocker = Thread.ofVirtual().name("vt-blocker").start(() -> block(mode));
blocker.join();
ticking.set(false);
ticker.join();
System.out.println("mode=" + mode + " blockedMs=" + (System.nanoTime() - begin) / 1_000_000
+ " tickerTicks=" + ticks.get());
}
static void block(String mode) {
try {
switch (mode) {
case "sleep" -> Thread.sleep(400);
case "sync-sleep" -> { synchronized (PinDemo.class) { Thread.sleep(400); } }
case "native" -> {
var l = java.lang.foreign.Linker.nativeLinker();
var h = l.downcallHandle(l.defaultLookup().find("usleep").orElseThrow(),
java.lang.foreign.FunctionDescriptor.of(java.lang.foreign.ValueLayout.JAVA_INT,
java.lang.foreign.ValueLayout.JAVA_LONG));
System.out.println("usleep rc=" + (int) h.invokeExact(400_000L));
}
case "classinit" -> System.out.println("value=" + SlowInit.value);
default -> throw new IllegalArgumentException(mode);
}
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
}
javac --release 25 -d out PinDemo.java
for m in sleep sync-sleep native classinit; do
java -Djdk.virtualThreadScheduler.parallelism=1 \
-Djdk.virtualThreadScheduler.maxPoolSize=1 \
--enable-native-access=ALL-UNNAMED -cp out PinDemo "$m"
done
本机输出:
mode=sleep blockedMs=401 tickerTicks=300
mode=sync-sleep blockedMs=402 tickerTicks=294
usleep rc=0
mode=native blockedMs=535 tickerTicks=1
value=42
mode=classinit blockedMs=412 tickerTicks=1
逐行解释:
sleep:300 次心跳。虚拟线程睡着,载体空出来跑心跳线程。sync-sleep:294 次心跳,和普通sleep同量级。JEP 491 生效,synchronized里的阻塞不再 pin。native:1 次心跳。FFM 调用usleep的 400 ms 里,载体被占住,心跳只跑了一次。classinit:1 次心跳。SlowInit的静态初始化睡 400 ms,另一个虚拟线程在<clinit>上等待,载体被占住。
所以 JDK 25 里,业务代码的 synchronized、ReentrantLock、Object.wait 不再是 pin 来源;真正要留意的是 JNI/FFM 调用和类初始化。JDBC 驱动如果是纯 Java socket,走虚拟线程友好路径;如果内部用 JNI 做加密、压缩,那段可能 pin。
JFR:默认 0,不等于没有发生
用默认的 profile 配置录 1000 个虚拟线程的启动和结束:
java -XX:StartFlightRecording=filename=/tmp/vol4-d/vthreads/start.jfr,settings=profile,dumponexit=true \
-cp out VThreadDemo 1000
jfr summary /tmp/vol4-d/vthreads/start.jfr | grep -i VirtualThread
本机输出:
jdk.VirtualThreadEnd 0 0
jdk.VirtualThreadPinned 0 0
jdk.VirtualThreadStart 0 0
jdk.VirtualThreadSubmitFailed 0 0
1000 个虚拟线程确实启动了,但 VirtualThreadStart 是 0。这些事件在默认配置里没有开启(或阈值不适用),需要显式打开:
java -XX:StartFlightRecording=filename=/tmp/vol4-d/vthreads/start2.jfr,settings=profile,jdk.VirtualThreadStart#enabled=true,jdk.VirtualThreadEnd#enabled=true,jdk.VirtualThreadPinned#enabled=true,dumponexit=true \
-cp out VThreadDemo 1000
jfr summary /tmp/vol4-d/vthreads/start2.jfr | grep -i VirtualThread
本机输出:
jdk.VirtualThreadEnd 1000 11824
jdk.VirtualThreadPinned 0 0
jdk.VirtualThreadStart 1000 11824
Start/End 各 1000 条,Pinned 仍然是 0。这个 0 要分两种情况看:
- 没有 pin:
sleep和synchronized都正常卸载,确实是 0。 - 有 pin,但 JVM 不知道:native 调用期间,JVM 看到的是「运行中」,没有「尝试阻塞」的动作可记。心跳实验里
native模式载体被占住 400 ms,jdk.VirtualThreadPinned依然是 0。
类初始化则会被记录。用 classinit 模式单独录一轮,本机 Pinned 计数为 1,事件内容是:
jdk.VirtualThreadPinned {
duration = 405 ms
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to PinDemo$SlowInit.<clinit> on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 31)
eventThread = "vt-blocker" (javaThreadId = 32, virtual)
}
pinnedReason 直接给出原因,carrierThread 指出被占住的载体。不能把「没有 jdk.VirtualThreadPinned 事件」当成「没有 pin」的证据;要验证 native 是否占住载体,用心跳法或直接看载体线程的 CPU/栈。
在 libraryd 里落地
DashboardService 的模型是「一个请求两个虚拟线程 + 10 个数据库连接」。连接数必须显式限制,否则虚拟线程会把并发原样放大到连接池:
package com.plume.library.web;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public final class DashboardService {
public record Dashboard(String memberId, List<String> activeLoans, int overdueCount) {}
private final LoanQueryPort loans;
private final FineQueryPort fines;
private final Semaphore dbPermits;
public DashboardService(LoanQueryPort loans, FineQueryPort fines, int poolSize) {
this.loans = loans;
this.fines = fines;
this.dbPermits = new Semaphore(poolSize);
}
public Dashboard load(String memberId) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var loanTask = executor.submit(() -> callWithPermit(() -> loans.active(memberId)));
var fineTask = executor.submit(() -> callWithPermit(() -> fines.overdue(memberId)));
return new Dashboard(memberId, loanTask.get(), fineTask.get());
}
}
private <T> T callWithPermit(ThrowingSupplier<T> work) throws Exception {
if (!dbPermits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("数据库繁忙");
}
try {
return work.get();
} finally {
dbPermits.release();
}
}
@FunctionalInterface
public interface ThrowingSupplier<T> { T get() throws Exception; }
public interface LoanQueryPort { List<String> active(String memberId) throws Exception; }
public interface FineQueryPort { int overdue(String memberId) throws Exception; }
}
用假的查询端口跑 40 个并发请求,每个查询 20 ms,连接许可 10 个:
requests=40 poolSize=10 maxInFlight=10 elapsedMs=216
maxInFlight=10 说明信号量把并发卡在连接池大小上。40 个请求、80 个查询任务,理论耗时约 80/10 × 20 = 160 ms,实测 216 ms,差额来自虚拟线程调度和 join 开销(单次运行,未做统计)。
这个 200 ms 的 tryAcquire 超时是有意的:连接池满时快速失败,而不是让请求在队列里挂 30 秒。把请求数加到 100,会有相当一部分抛出「数据库繁忙」——这不是 bug,是容量不足的信号。超时值要比 Hikari 的 connectionTimeout 短得多,许可数要等于 maximumPoolSize,不能拍脑袋。
两个运行时的细节:
newVirtualThreadPerTaskExecutor()每个任务新建一个虚拟线程,close()会等待所有任务结束。它适合「一次请求内扇出几个查询」,不适合当常驻池。- 需要在线程转储里区分请求时,用自己的
ThreadFactory命名:Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("dash-", 0).factory())。默认执行器不命名,dump 里全是空字符串。
启动参数只在不必要时才动:parallelism 默认等于核数,改了不会让下游变快;maxPoolSize 默认较大,用于补偿 pin 造成的载体不足。除非有心跳实验证明载体被占满,否则保持默认。
常见错误与边界
错误一:还按 JDK 21 的经验躲 synchronized。 本机 sync-sleep 心跳 294 次,与普通 sleep 的 300 次同量级,JEP 491 已生效。真正要躲的是 native 调用和类初始化期间的阻塞。
错误二:用 Thread.getAllStackTraces() 或 ThreadMXBean 统计虚拟线程。 它们只返回平台线程。本机 1000 个虚拟线程时,Thread.print 匹配数为 0;要用 JSON dump 或 JFR。
错误三:把虚拟线程放进固定大小的池。 Executors.newFixedThreadPool(32, Thread.ofVirtual().factory()) 只创建 32 个虚拟线程并复用,退化成平台线程模型,还多了一层调度开销。虚拟线程不需要池化,需要限制的是下游并发。
错误四:把 jdk.VirtualThreadPinned=0 当成没有 pin。 本机 native 模式载体被占住 400 ms,事件计数仍是 0。JVM 只在虚拟线程尝试阻塞时判定 pin,native 调用期间它看到的是「运行中」。
边界:Thread.vthread_scheduler 的 size 不是虚拟线程数。 size=0 可能只是全部卸载了;虚拟线程数量看 delayed、JFR 事件或 JSON 容器。
边界:pinning 不是唯一的性能问题。 synchronized 不 pin 了,但它仍然是互斥锁;所有请求在同一把锁上排队时,虚拟线程只是让排队不占载体,不会让锁消失。
小结
- 虚拟线程的栈在堆上的 Continuation/StackChunk 里,挂起时卸载、恢复时可能换载体;不要依赖载体身份。
- 调度器是专用
ForkJoinPool,默认并行度等于核数;本机 1000 个睡眠虚拟线程在parallelism=2时只占 2 个载体,另有 1 个delayScheduler。 Thread.vthread_scheduler的size=0表示没有常驻载体在跑,不是没有虚拟线程;JDK 25 的synchronized不再 pin,本机sync-sleep心跳 294 次,native 和类初始化仍会占住载体。jdk.VirtualThreadPinned默认可能为 0;显式开启后 1000 个虚拟线程记录到 1000 个 Start,但 native pin 仍为 0,类初始化 pin 为 1。- libraryd 的
DashboardService用信号量把并发卡在连接池大小上,本机 40 个请求的maxInFlight=10,超时要远小于连接池超时。
线程、锁、虚拟线程的运行时都过了一遍。下一章换到启动:类加载、解释执行和首请求耗时,看看一个服务从 java -jar 到能接流量到底花在哪。
阅读导航:上一节:8.2 锁的运行时实现:对象头、Monitor 与膨胀 · 下一节:9.1 启动耗时分解:类加载、解释执行与首请求 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。