《Java 虚拟机原理与性能调优》8.3 虚拟线程的运行时:Continuation、载体与调度

1000 个虚拟线程只对应 2 个载体,这是怎么做到的?本节用 Thread.vthread_scheduler、JSON 线程容器和 pin 心跳实验拆开 Continuation、mount/unmount 与调度,验证 JDK 25 的 synchronized 不再 pin,而本地调用和类初始化仍会占住载体。

本节目标:看清虚拟线程如何挂在载体上、什么时候卸载、什么时候 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),只在运行时才被「挂载」到某个载体线程的栈上。

一次挂起大致是:

  1. 虚拟线程执行到会阻塞的操作(sleep、park、socket read 等)。
  2. JVM 把当前栈帧从载体线程的栈复制回堆上的 StackChunk。
  3. 虚拟线程进入等待队列,卸载(unmount),载体线程空出来去跑别的虚拟线程。
  4. 条件满足后,调度器把虚拟线程重新挂载(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 启动耗时分解:类加载、解释执行与首请求 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发