JDK JDK 学习手册8 → 27 · JDK 专用学习站
QUICKREF · VIRTUAL THREADS · JEP 444

虚拟线程速查

JDK 21 · 速查主题 08 / 12 · 预计 25 分钟
核心25 分钟JEP 444JEP 453
一句话虚拟线程把「线程」从操作系统资源变成 JVM 管理的轻量任务:阻塞时自动卸载,让一个请求一个线程的写法重新可行。它提升的是阻塞场景下的吞吐上限,不是单任务速度。

学完你要能自己回答

  1. 虚拟线程与平台线程、线程池分别是什么关系?为什么不应该池化虚拟线程?
  2. JDK 21 中哪些操作会导致 pinning?如何检测和规避?
  3. 什么任务不适合虚拟线程?为什么 CPU 密集任务不会变快?

创建与运行01

JDK 21 提供三种入口:Thread.ofVirtual()、Thread.startVirtualThread()、Executors.newVirtualThreadPerTaskExecutor()。生产代码优先用执行器,便于统一关闭和异常处理。

// 1. 直接启动
Thread t = Thread.startVirtualThread(() -> {
    System.out.println("virtual=" + Thread.currentThread().isVirtual());
});
t.join();

// 2. Builder:命名、继承性等
Thread named = Thread.ofVirtual()
        .name("order-vt-", 0)
        .start(() -> handleOrder());

// 3. 执行器:每个任务一个虚拟线程
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f = executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        return "done";
    });
    System.out.println(f.get());
}
  • 虚拟线程默认是守护线程,不会阻止 JVM 退出。
  • 虚拟线程没有「线程池」概念:每个任务创建一条,用完即弃,创建成本极低。
  • 不要用 ThreadPoolExecutor 池化虚拟线程;池化会限制并发,也失去轻量优势。
  • ExecutorService.close() 会等待已提交任务完成;适合请求级或批处理级作用域。

调度模型:载体线程与挂载02

虚拟线程由 JVM 调度到少量载体线程(carrier threads)上运行。载体线程是平台线程,默认数量等于 CPU 核数。虚拟线程阻塞时,JVM 把它从载体上卸下(unmount),载体去运行别的虚拟线程;阻塞结束后再重新挂载。

虚拟线程任务本身,数量可达百万级;保存栈帧在堆上。
载体线程真正的 OS 线程,默认 = CPU 核数;负责执行。
调度器ForkJoinPool,负责把虚拟线程分配到载体。
挂载 / 卸载阻塞时让出载体,是虚拟线程的核心魔法。

关键推论:虚拟线程不加速 CPU 计算。如果任务一直在算,没有阻塞点,它和平台线程一样占用一个核;创建一百万个也只会排队。虚拟线程的收益来自「大量任务在等待 I/O」。

// 10,000 个任务各睡 1 秒:虚拟线程接近 1 秒完成
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    long start = System.nanoTime();
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return null;
        });
    }
    // close() 等待全部完成
}
System.out.println("done");
不要用「创建了多少线程」衡量虚拟线程收益。真正的指标是:在固定硬件下,系统能同时承载多少阻塞任务,以及尾延迟是否稳定。

Pinning:JDK 21 的头号雷区03

当虚拟线程在无法卸载的情况下阻塞,它会钉住(pin)载体线程。JDK 21 中常见原因有两个:在 synchronized 块 / 方法内阻塞,以及阻塞在 native 方法或外部函数调用中。被钉住的载体无法执行其他虚拟线程,严重时退化为平台线程模型。

private final Object lock = new Object();

// JDK 21:sleep 在 synchronized 内会 pin 载体
synchronized (lock) {
    Thread.sleep(Duration.ofSeconds(1)); // 不要这样写
}

// 改法一:缩小 synchronized 范围,把阻塞移出去
synchronized (lock) {
    updateState();
}
Thread.sleep(Duration.ofSeconds(1));

// 改法二:用 ReentrantLock,虚拟线程可正常卸载
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    Thread.sleep(Duration.ofSeconds(1));
} finally {
    lock.unlock();
}
  • JDK 21 检测:启动参数 -Djdk.tracePinnedThreads=full 会打印 pinning 堆栈。
  • JFR 事件 jdk.VirtualThreadPinned 记录超过阈值(默认 20ms)的钉住。
  • JDK 24 的 JEP 491 已消除 synchronized 导致的 pinning;JDK 21 仍需注意。
  • 并非所有 synchronized 都 pin:只有在其内部发生阻塞时才会。短临界区通常没问题。
  • Object.wait() 等监视器等待也可能导致 pinning;高并发代码优先用 ReentrantLock / Condition。
把「synchronized 一律换成 ReentrantLock」是过度反应。先测量:只有出现 jdk.VirtualThreadPinned 事件或吞吐异常时,再针对性修改。

并发控制:虚拟线程不是无限资源04

虚拟线程很便宜,但下游资源不是:数据库连接、HTTP 连接池、文件句柄、下游服务容量都有限。创建一百万虚拟线程去调同一个数据库,只会把压力转移给连接池或下游。

Semaphore permits = new Semaphore(100); // 限制同时访问下游的请求数

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request req : requests) {
        executor.submit(() -> {
            permits.acquire();
            try {
                return callDownstream(req); // 真正受限于 100 个许可
            } finally {
                permits.release();
            }
        });
    }
}
  • 连接池大小仍然是硬上限;虚拟线程只改变「等待连接」的成本,不改变连接数量。
  • 给外部调用设置超时,避免虚拟线程永久阻塞。
  • 批处理场景可以用 StructuredTaskScope(预览)统一取消子任务。
  • 不要把虚拟线程与固定线程池混用:池化虚拟线程没有意义。

JDK 21 的 StructuredTaskScope 仍是预览 API,需要 --enable-preview。它把一组子任务绑定到一个作用域:任一失败可取消其余任务,离开作用域前统一 join。

ThreadLocal、上下文与迁移05

虚拟线程支持 ThreadLocal,但数量可能达到百万级;每个线程一份副本会放大内存。传统「线程池 + ThreadLocal 缓存」的优化在虚拟线程下可能适得其反。

  • 请求上下文优先考虑 Scoped Values(JDK 21 预览,25 正式):不可变、作用域结束自动失效。
  • 必须用 ThreadLocal 时,在请求结束时 remove(),避免长生命周期虚拟线程持有大对象。
  • 不要用 ThreadLocal 做「线程级缓存」;虚拟线程不复用,缓存命中率低。
  • 日志 MDC 等框架上下文需确认是否兼容虚拟线程与 Scoped Values。
// JDK 21 预览,需要 --enable-preview
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
    handle(); // 在此作用域内可读
});

static void handle() {
    System.out.println(REQUEST_ID.get()); // req-42
}

观测与排障06

虚拟线程的排障工具与平台线程不同:传统 jstack 只能看到载体线程,虚拟线程需要 JSON 线程转储或 JFR。

# 打印 pinning 堆栈(仅 JDK 21 需要)
java -Djdk.tracePinnedThreads=full -jar app.jar

# JSON 线程转储(包含虚拟线程)
jcmd <pid> Thread.dump_to_file -format=json threads.json

# JFR 记录虚拟线程事件
jcmd <pid> JFR.start duration=60s filename=vt.jfr
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned vt.jfr
  • Thread.currentThread().isVirtual() 可在日志中区分线程类型。
  • 虚拟线程默认命名类似空字符串;用 Thread.ofVirtual().name(...) 命名便于定位。
  • 线程本地随机数:JDK 21 的 ThreadLocalRandom 对虚拟线程友好;Random 共享实例仍有竞争。
  • CPU 火焰图仍然按载体线程采样;虚拟线程等待时间要看 JFR 事件,不要只看 CPU。
如果虚拟线程吞吐不如预期,先排查三件事:是否有 pinning、下游连接池是否成为瓶颈、任务是否其实是 CPU 密集。

版本边界BOUNDARY

这张表比 API 本身更值得记。写代码时先确认你的运行时版本。

特性 / API状态说明
虚拟线程21 正式JDK 19–20 预览;JEP 444。
synchronized pinning21 存在 / 24 消除JDK 21 需规避;JDK 24 JEP 491 移除该限制。
结构化并发21 预览需要 --enable-preview,后续版本持续预览。
Scoped Values21 预览 / 25 正式JDK 21 不能直接用于生产。
Thread.ofVirtual / startVirtualThread21 正式JDK 19–20 为预览 API。

费曼自检SELF CHECK

先合上页面,用自己的话回答,再展开对照。能讲出因果和反例,才算真正掌握。

01. 虚拟线程为什么能让「一个请求一个线程」重新可行?
平台线程与 OS 线程一一对应,创建成本和栈内存都高,阻塞时还占用 OS 线程。虚拟线程由 JVM 调度,栈保存在堆上,阻塞时从载体线程卸载,因此可以用很低的成本创建大量线程并让它们在 I/O 上等待,从而恢复 thread-per-request 的简单模型。
02. JDK 21 中什么情况会导致 pinning?怎么发现?
虚拟线程在 synchronized 块/方法内阻塞,或阻塞在 native/外部函数调用中时,无法卸载,会钉住载体线程。可用 -Djdk.tracePinnedThreads=full 打印堆栈,或通过 JFR 的 jdk.VirtualThreadPinned 事件发现。规避方式包括缩小 synchronized 范围、改用 ReentrantLock,或升级到 JDK 24+。
03. 为什么虚拟线程不能加速 CPU 密集任务?
CPU 密集任务没有阻塞点,虚拟线程会一直占用载体线程直到时间片结束,和平台线程一样受限于 CPU 核数。虚拟线程减少的是阻塞等待的成本,不是计算本身的成本。
04. 虚拟线程需要池化吗?为什么?
不需要。虚拟线程创建成本极低,设计上就是每个任务一条、用完即弃。池化会限制并发数量,并让线程长期持有上下文,反而失去轻量和隔离的优势。需要限制的是下游资源,应该用 Semaphore 或连接池,而不是池化虚拟线程。
学习进度 记录保存在本机浏览器 · 不上传

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

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