JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · CONCURRENCY · JEP 444

虚拟线程:把阻塞变成廉价

JDK 21 · 深钻主题 01 / 08 · 预计 55 分钟
核心四步深钻JEP 444JEP 453
一句话虚拟线程把「线程」从操作系统资源变成 JVM 管理的轻量任务:阻塞时自动卸载,让一个请求一个线程的写法重新可行。它提升的是阻塞场景下的吞吐上限,不是单任务速度。
本质:平台线程贵在「一线程一内核栈」,虚拟线程便宜在「用户态调度 + 堆上栈」。价值不在更快,而在同样的阻塞代码可以开更多。

学完你要能自己回答

  1. 虚拟线程、平台线程、线程池三者是什么关系?为什么不应该池化虚拟线程?
  2. 一次阻塞调用发生时,虚拟线程的栈去了哪里?载体线程做什么?
  3. JDK 21 里哪些操作会 pin 住载体线程?怎么发现和规避?
  4. 为什么 CPU 密集型任务不会因为虚拟线程变快?
01 把线程想成「窗口」与「取号单」02 三个必须建立的心智锚点

把线程想成「窗口」与「取号单」01

平台线程像银行柜台:一个客户占一个窗口,客户去复印材料(阻塞 I/O)时,窗口也跟着空转。想服务更多客户,只能加窗口,而窗口数量受操作系统限制。

虚拟线程像取号单:客户拿到号就去旁边等,窗口只在真正办理业务时被占用。客户去复印时,窗口叫下一个号。号可以发几十万张,窗口仍然只有 CPU 核数量那么多。

平台线程虚拟线程
映射1:1 绑定内核线程M:N 由 JVM 调度到载体线程
栈内核栈,默认约 1MB 预留堆上的 Continuation 对象,按需增长
创建成本微秒到毫秒级,数量受 OS 限制纳秒到微秒级,百万级可行
阻塞代价占住内核线程卸载栈,释放载体线程
适用任务CPU 密集、需要长期占用I/O 密集、大量并发、短任务
关键结论:虚拟线程不是「更快的线程」,而是「更便宜的等待」。它优化的是并发数量与阻塞成本,不是单条指令的执行速度。

三个必须建立的心智锚点02

锚点一:虚拟线程仍然是一次「线程」

  • Thread.currentThread()、ThreadLocal、InterruptedException、join 这些语义都还在。
  • 它不是协程库里的 Task,也不是 Reactor 的 Mono;它是 java.lang.Thread 的一个实现。
  • 因此现有阻塞式代码通常可以直接迁移,不需要把方法改成异步回调。

锚点二:调度权从操作系统交给 JVM

虚拟线程运行在载体线程(carrier thread)上,载体线程是普通的平台线程,默认来自一个 ForkJoinPool。虚拟线程遇到阻塞点时,把栈帧卸载(unmount)到堆上,载体线程立刻去跑别的虚拟线程;阻塞结束后再挂载(mount)回来。

锚点三:便宜不等于无限

  • 虚拟线程便宜,但它访问的数据库连接、HTTP 连接、文件句柄并不便宜。
  • 没有限流时,十万虚拟线程会同时压向只有 20 个连接的池,瓶颈只是从线程转移到了连接池。
  • 正确姿势:虚拟线程负责「等待」,信号量/连接池负责「稀缺资源的并发上限」。
先猜再看如果一个任务只做 1 + 1,用虚拟线程会比平台线程快吗?
不会,通常还略慢。虚拟线程的收益来自阻塞时的卸载;纯 CPU 任务没有阻塞点,反而多了挂载/调度开销。CPU 密集任务应该继续用固定大小的平台线程池。
01 挂载、卸载与 Continuation02 Pinning:JDK 21 的头号雷区03 调度器与并行度

挂载、卸载与 Continuation01

每个虚拟线程内部持有一个 Continuation,它保存了虚拟线程的栈帧。运行时的大致路径是:

  1. start() 把虚拟线程提交给调度器,调度器把它放进载体线程的任务队列。
  2. 载体线程执行 Continuation.run(),虚拟线程开始运行。
  3. 遇到可卸载的阻塞点(如 Socket 读、Thread.sleep、LockSupport.park),运行时把当前栈帧冻结进 Continuation,run() 返回。
  4. 载体线程从队列取下一个任务;阻塞条件满足后,调度器把虚拟线程重新排队,等某个载体线程挂载。
Thread t = Thread.ofVirtual()
    .name("worker-", 1)
    .start(() -> {
        System.out.println("virtual=" + Thread.currentThread().isVirtual());
        System.out.println("carrier=" + Thread.currentThread().toString());
        try { Thread.sleep(100); } catch (InterruptedException e) { }
    });
t.join();

载体线程的名字形如 ForkJoinPool-1-worker-1。注意:不要把载体线程当成虚拟线程的身份,也不要缓存 Thread.currentThread() 到 ThreadLocal 后跨阻塞点使用。

虚拟线程默认是守护线程(daemon),不会阻止 JVM 退出。主线程结束时,未完成的虚拟线程会被直接终止,和守护平台线程一样。

Pinning:JDK 21 的头号雷区02

当虚拟线程在阻塞时无法卸载,它就会钉住(pin)载体线程。被钉住的载体线程无法去执行其他虚拟线程,吞吐会退化回平台线程模型。

场景JDK 21 行为规避方式
在 synchronized 块/方法中阻塞钉住载体线程改用 ReentrantLock;JDK 24 起 JEP 491 已解决
执行 native 方法或 Foreign Function钉住载体线程缩短 native 调用;升级到支持 FFM 的版本
类初始化过程中的阻塞可能钉住避免在 static 初始化里做 I/O
Object.wait()钉住改用 Condition.await()
普通 Thread.sleep / Socket 读正常卸载无需处理
// JDK 21:这段代码会 pin 住载体线程
synchronized (lock) {
    Thread.sleep(Duration.ofSeconds(1)); // 卸载失败
}

// 改为 ReentrantLock,阻塞点可正常卸载
lock.lock();
try {
    Thread.sleep(Duration.ofSeconds(1));
} finally {
    lock.unlock();
}

检测方式:启动参数 -Djdk.tracePinnedThreads=full 会打印钉住时的栈;JFR 打开 jdk.VirtualThreadPinned 事件可以统计频率和持续时间。JDK 21 的 JFR 事件默认阈值是 20ms。

调度器与并行度03

虚拟线程调度器默认是一个 ForkJoinPool,并行度等于 Runtime.getRuntime().availableProcessors()。它只负责运行虚拟线程,不负责限制业务并发。

# 查看/调整调度器并行度(一般不需要改)
-Djdk.virtualThreadScheduler.parallelism=8
-Djdk.virtualThreadScheduler.maxPoolSize=256

# 诊断 pinning
-Djdk.tracePinnedThreads=full
  • parallelism 是同时运行的载体线程数,默认等于 CPU 核数;把它调大不会让 CPU 变多。
  • maxPoolSize 是载体线程池上限,主要应对阻塞导致的临时扩容。
  • 不要用 -XX:ActiveProcessorCount 之外的方式「给虚拟线程留核」,调度器与业务线程池共享 CPU。
虚拟线程的调度是非抢占式的:它只在阻塞点或显式 yield 时让出载体线程。一个死循环的虚拟线程会像平台线程一样占满一个核。
01 迁移路线:先换执行器,再查 pinning02 用信号量给下游限流03 可运行示例:十万次阻塞任务

迁移路线:先换执行器,再查 pinning01

  1. 第一步:替换线程池。把 Executors.newFixedThreadPool(n) 换成 Executors.newVirtualThreadPerTaskExecutor(),保持任务代码不变。
  2. 第二步:限制稀缺资源。用 Semaphore 或连接池限制数据库/HTTP 并发,不要用线程池大小充当限流。
  3. 第三步:扫描 pinning。打开 jdk.tracePinnedThreads=full 或 JFR,把 synchronized 中的阻塞调用改成 ReentrantLock。
  4. 第四步:压测与观测。观察吞吐、延迟分位、载体线程数、连接池等待时间,确认瓶颈已转移。
// Before:固定线程池,队列堆满后开始拒绝
ExecutorService pool = Executors.newFixedThreadPool(200);

// After:每个任务一个虚拟线程,任务结束即销毁
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofMillis(50));
            return "ok";
        });
    }
} // close() 等待所有任务结束

ExecutorService 在 Java 19 起实现 AutoCloseable,close() 会先停止接收新任务,再等待已提交任务完成。try-with-resources 是比 shutdown() + awaitTermination() 更简洁的写法。

用信号量给下游限流02

public class DownstreamGate {
    private static final Semaphore DB_PERMITS = new Semaphore(20);

    static String query(String id) throws Exception {
        DB_PERMITS.acquire();
        try {
            return callDatabase(id); // 只有 20 个虚拟线程能同时进入
        } finally {
            DB_PERMITS.release();
        }
    }
}
  • 虚拟线程数量可以是十万,但数据库连接只有几十个;信号量把「等待」留在虚拟线程层,避免连接池超时。
  • 不要让信号量许可数大于连接池上限,否则等待会转移到连接池,观测上更难定位。
  • HTTP 客户端自身通常有连接池,优先使用客户端内置限制,信号量用于没有内置限制的资源。
常见反模式:new ThreadPoolExecutor(200, 200, ..., new VirtualThreadFactory())。用线程池包虚拟线程等于给取号单设置固定窗口,既失去弹性,又容易误解并发上限。

可运行示例:十万次阻塞任务03

import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class VirtualThreadsDemo {
    public static void main(String[] args) throws Exception {
        AtomicInteger done = new AtomicInteger();
        long start = System.nanoTime();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofMillis(10));
                    done.incrementAndGet();
                    return null;
                });
            }
        }

        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println("done=" + done.get());
        System.out.println("elapsedMs=" + ms);
        System.out.println("isVirtual=" + Thread.ofVirtual().unstarted(() -> {}).isVirtual());
    }
}
done=100000
elapsedMs≈1200(取决于机器,远小于 100000×10ms 的串行时间)
isVirtual=true

十万个任务各睡 10ms,若串行需要 1000 秒;虚拟线程让它们在等待时互相让出载体线程,总耗时接近「任务提交 + 调度」的开销。实际数字受 CPU、内存和调度器影响,但量级差异就是这个特性的意义。

01 先猜再看:三个高频误解02 费曼自检:能否讲清因果

先猜再看:三个高频误解01

先猜再看虚拟线程阻塞时,它的栈会一直占用内存吗?
栈帧被冻结进堆上的 Continuation,按实际使用量占用内存;不运行时不再占用载体线程的栈。它仍然占内存,但比平台线程预留的内核栈小得多,而且可被 GC 管理。
先猜再看把固定线程池的核心线程数设成 10000,是不是就等价于虚拟线程?
不是。平台线程仍与内核线程 1:1 绑定,10000 个阻塞线程就是 10000 个被占住的内核线程,操作系统调度和内存成本都很高。虚拟线程是 M:N 调度,阻塞时释放载体线程,这是模型差异,不是参数差异。
先猜再看JDK 21 里用了 synchronized 就一定会 pin 吗?
只有在 synchronized 块/方法内部发生阻塞时才会 pin。快速进出、不阻塞的同步块没有问题。JDK 24 的 JEP 491 已移除这一限制,但 JDK 21 项目仍需排查。

费曼自检:能否讲清因果02

01. 用一句话解释虚拟线程为什么能开几十万个。
因为它不在阻塞时占住内核线程:栈可以卸载到堆上,少量载体线程轮转执行大量虚拟线程。
02. 为什么虚拟线程不适合 CPU 密集型任务?
CPU 密集任务没有阻塞点,无法让出载体线程;虚拟线程的调度开销不会带来收益,固定大小的平台线程池更合适。
03. 虚拟线程池化为什么是反模式?
虚拟线程的创建成本极低,池化会引入复用、排队和状态残留问题,还会掩盖真正的稀缺资源。应该每个任务新建,用信号量限制下游并发。
04. 如何证明一次阻塞没有 pin 住载体线程?
开启 -Djdk.tracePinnedThreads=full 或 JFR 的 jdk.VirtualThreadPinned 事件;没有输出/事件即未检测到钉住。
05. ThreadLocal 在虚拟线程里还能用吗?
能用,但每个虚拟线程一份,数量大时内存开销可观;跨线程传递优先考虑显式参数或 JDK 21 预览的 ScopedValue,并避免依赖 InheritableThreadLocal 的隐式继承。
能把「便宜的是等待,不是计算」这句话讲给一个没听过虚拟线程的人,并说清载体线程、卸载、pinning 三个词的关系,这一节就过关了。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。