Java 21 虚拟线程深度解析


一、产生背景

传统平台线程的局限性

Java 的传统线程(Platform Thread)是对操作系统线程的 1:1 封装。每创建一个 Thread 对象,JVM 就向 OS 申请一个内核线程。这带来了三个硬性瓶颈:

① 内存开销大
每个平台线程默认占用约 1MB 的栈空间(可通过 -Xss 调整,但下限约 256KB)。1 万个并发请求 = 约 10GB 内存仅用于线程栈,这在高并发系统中几乎不可接受。

② 上下文切换成本高
OS 调度内核线程时,需要保存/恢复 CPU 寄存器、程序计数器、栈指针等完整状态,一次切换耗时约 1~10 微秒,且会污染 CPU 缓存。线程数越多,调度开销越大。

③ 吞吐量天花板明显
在典型的 Web 服务中,线程大部分时间在等待 IO(数据库查询、HTTP 调用)——等待期间线程被阻塞,但 OS 线程资源并未释放。线程池大小(通常几百个)直接决定了服务的最大并发吞吐量上限。

传统模型:
请求 1 → 线程A [处理 10ms] [等待DB 50ms] [处理 5ms] → 响应
         ←————线程A 被占用整整 65ms ————————————————→
                      ↑ 等待期间完全浪费

Project Loom 的立项动机

Project Loom 于 2017 年 由 Ron Pressler 主导启动,核心目标只有一个:让简单的同步代码获得异步代码的性能

经历了漫长的孵化期后,虚拟线程在 Java 19(JEP 425,预览)→ Java 20(JEP 436,二次预览)→ Java 21(JEP 444,正式发布) 完成了从实验到生产就绪的完整旅程。

与 Go / Node.js 的路线对比

特性 Java 虚拟线程 Go Goroutine Node.js 事件循环
编程模型 同步阻塞(不改变代码风格) 同步阻塞 异步回调 / async-await
调度方式 JVM 用户态调度 Go runtime 调度 单线程事件循环
栈大小 动态增长,初始 KB 级 动态增长,初始 ~8KB 无独立栈概念
与已有代码兼容性 ✅ 极高,Thread API 不变 ❌ 需重写 ❌ 需改异步风格
CPU 密集型 无优势 自动多核并行 ❌ 单线程限制

Java 的选择是兼容性优先:不引入新的关键字(无 async/await),不强迫开发者改变心智模型,让几十亿行现有 Java 代码无缝受益。


二、技术原理

核心类比:虚拟线程 vs 平台线程

OS 内核线程 想象成城市里的出租车(数量有限,昂贵);把虚拟线程想象成共享单车(数量近乎无限,轻量)。出租车(Carrier Thread)负责真正在路上跑,共享单车(虚拟线程)需要出行时"挂载"到出租车上,不需要时"卸载"归还,让出租车去载下一个用户。

底层三要素

① Carrier Thread(承载线程)
即真实的平台线程,数量默认等于 CPU 核心数(通过 jdk.virtualThreadScheduler.parallelism 可调)。它是虚拟线程实际执行的"宿主"。

② Continuation(续体)
这是 Loom 最核心的底层抽象。Continuation 封装了虚拟线程的执行上下文(栈帧、局部变量、程序计数器),支持在任意阻塞点暂停(yield)并在稍后恢复。本质上是 JVM 对"可挂起计算"的原生支持。

③ 调度器(Scheduler)
虚拟线程默认使用一个专用的 ForkJoinPool(工作窃取模式)作为调度器。它负责将就绪的虚拟线程分配给空闲的 Carrier Thread 执行。

挂载与卸载机制

虚拟线程生命周期:

[就绪] --调度器分配--> [挂载到 Carrier Thread] --执行代码--> [遇到阻塞IO]
                                                                      |
                                                              [卸载,Continuation保存状态]
                                                              [Carrier Thread 去执行其他虚拟线程]
                                                                      |
                                                              [IO完成,重新入队]
                                                                      |
                                                              [再次挂载到(可能不同的)Carrier Thread]
                                                              [从断点处继续执行]

阻塞时,JVM 底层(java.iojava.netjava.nio 等)会触发 Continuation.yield(),将当前虚拟线程从 Carrier Thread 上"剥离",Carrier Thread 立即可用于运行其他任务。IO 完成后,虚拟线程重新进入调度队列等待恢复。全程无 OS 线程阻塞,无上下文切换损耗

代码示例:虚拟线程创建方式

import java.util.concurrent.Executors;

public class VirtualThreadDemo {

    public static void main(String[] args) throws InterruptedException {

        // ① 方式一:Thread.ofVirtual() 创建单个虚拟线程
        Thread vt = Thread.ofVirtual()
                .name("my-vthread-1")
                .start(() -> {
                    System.out.println("运行在: " + Thread.currentThread());
                    // 输出类似: VirtualThread[#21,my-vthread-1]/runnable@ForkJoinPool...
                });
        vt.join();

        // ② 方式二:虚拟线程工厂(适合与旧代码集成)
        var factory = Thread.ofVirtual().name("worker-", 0).factory();
        Thread t2 = factory.newThread(() -> System.out.println("工厂创建: " + Thread.currentThread()));
        t2.start();
        t2.join();

        // ③ 方式三:专用 ExecutorService(最推荐的生产用法)
        // 每个任务提交时自动创建一个新虚拟线程,无需手动管理
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                final int id = i;
                executor.submit(() -> {
                    // 模拟 IO 阻塞(如数据库查询)
                    Thread.sleep(100);
                    System.out.println("任务 " + id + " 完成");
                    return id;
                });
            }
        } // try-with-resources 自动 shutdown + awaitTermination

        // ④ 判断当前线程是否是虚拟线程
        Thread.ofVirtual().start(() -> {
            System.out.println("是虚拟线程: " + Thread.currentThread().isVirtual()); // true
        }).join();
    }
}

三、应用场景

最适合:IO 密集型场景

虚拟线程的收益与线程阻塞时间占比正相关。典型受益场景:

  • 数据库查询:JDBC 阻塞调用,等待时间占 80%+
  • 微服务间 HTTP 调用HttpClientRestTemplateOpenFeign
  • 文件读写:大量文件处理任务
  • 消息队列消费:Kafka/RabbitMQ 的长轮询消费者

不适合或收益有限的场景

  • CPU 密集型计算:图像处理、加密运算、机器学习推理。虚拟线程无法让 1 个 CPU 核心变成 2 个,该并行还是要用 ForkJoinPoolparallelStream
  • 已经是响应式架构的系统:使用 Spring WebFlux + Reactor 的系统已经达到了高吞吐,迁移收益有限且可能引入复杂性。

在 Spring Boot 3.x 中启用虚拟线程

Spring Boot 3.2+ 提供了开箱即用的支持,一行配置即可:

# application.yml
spring:
  threads:
    virtual:
      enabled: true  # Tomcat、Jetty、调度线程池全部切换为虚拟线程

或通过代码配置(Spring Boot 3.1 兼容写法):

@Configuration
public class VirtualThreadConfig {

    // 替换 Tomcat 的线程池为虚拟线程
    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }

    // 替换 Spring @Async 的线程池
    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    public AsyncTaskExecutor asyncTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }
}

实战示例:并发请求多个外部 API

// 场景:聚合 3 个下游服务的数据,总耗时取决于最慢的那个

// ——— 传统线程池写法 ———
ExecutorService pool = Executors.newFixedThreadPool(3); // 线程是稀缺资源,要限制
Future<String> f1 = pool.submit(() -> callServiceA()); // 各自阻塞线程池线程
Future<String> f2 = pool.submit(() -> callServiceB());
Future<String> f3 = pool.submit(() -> callServiceC());
String result = f1.get() + f2.get() + f3.get(); // 等待全部完成

// ——— 虚拟线程写法(Java 21)———
// 无需限制线程数,虚拟线程开销极小
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> v1 = executor.submit(() -> callServiceA());
    Future<String> v2 = executor.submit(() -> callServiceB());
    Future<String> v3 = executor.submit(() -> callServiceC());
    String result2 = v1.get() + v2.get() + v3.get();
    // 三个服务各耗时 200ms,总耗时约 200ms(并行),
    // 且不占用任何 OS 线程等待——Carrier Thread 全程在处理其他任务
}

四、最佳实践

① 不要池化虚拟线程

// ❌ 错误:对虚拟线程使用固定大小线程池
ExecutorService wrong = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// 限制了并发数,且失去了虚拟线程"按需创建"的优势

// ✅ 正确:每任务一个虚拟线程,让 JVM 调度器自己控制
ExecutorService correct = Executors.newVirtualThreadPerTaskExecutor();

虚拟线程的设计哲学是**“廉价且短暂”**,创建成本极低(微秒级),池化反而会引入不必要的等待和复杂性。

② Pinned Thread 问题——synchronized 的陷阱

这是虚拟线程最重要的注意事项。当虚拟线程在 synchronized 块内执行阻塞操作时,它无法被卸载,会将 Carrier Thread 一起钉住(Pin),退化为平台线程行为。

// ❌ 危险:synchronized 内部发生阻塞
synchronized (lock) {
    String result = httpClient.send(request, ...); // 阻塞!Carrier Thread 被 Pin 住
}

// ✅ 正确:改用 ReentrantLock(支持虚拟线程卸载)
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    String result = httpClient.send(request, ...); // 可以正常卸载
} finally {
    lock.unlock();
}

排查命令

# 运行时检测 Pin 事件(添加 JVM 参数)
-Djdk.tracePinnedThreads=full   # 打印详细堆栈
-Djdk.tracePinnedThreads=short  # 仅打印 Pin 发生的帧

# 或使用 JFR 事件:jdk.VirtualThreadPinned

③ ThreadLocal 慎用 → 推荐 Scoped Values

// ⚠️ 问题:虚拟线程数量可达百万级,ThreadLocal 数据随线程生命周期持有,
// 若线程"复用"或者数据量大,内存压力剧增

// 传统写法
static ThreadLocal<User> currentUser = new ThreadLocal<>();
currentUser.set(user);  // 设置
currentUser.get();      // 获取
currentUser.remove();   // 必须手动清理!否则内存泄漏

// ✅ Java 21 推荐:ScopedValue(JEP 446,预览)
// 不可变、自动作用域管理、对虚拟线程友好
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, user).run(() -> {
    // 在此 lambda 范围内,CURRENT_USER.get() 返回 user
    processRequest();
}); // 作用域结束自动清理,无泄漏风险

④ 结构化并发(Structured Concurrency)

结构化并发将多个并发任务的生命周期限定在一个词法作用域内,避免任务泄漏:

import java.util.concurrent.StructuredTaskScope;

// 场景:并发查询用户信息和订单,任一失败则全部取消
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

    // 分叉:提交子任务(运行在虚拟线程上)
    StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
    StructuredTaskScope.Subtask<List<Order>> orderTask = scope.fork(() -> fetchOrders(userId));

    // 汇合:等待全部完成,若有异常则传播
    scope.join().throwIfFailed();

    // 安全使用结果(能到这里说明两个任务都成功了)
    return new UserProfile(userTask.get(), orderTask.get());

} // 作用域结束,所有子任务必然已完成或被取消——无泄漏

五、未来演进方向

Scoped Values 正式化(JEP 446 → 487)

ScopedValue 作为 ThreadLocal 的现代替代方案,在 Java 21/22 处于预览状态,预计在 Java 23/24 正式转正。其核心优势:不可变(天然线程安全)、层次化继承(父线程值对子虚拟线程可见)、自动作用域管理。

结构化并发成熟(JEP 453)

结构化并发同样处于预览阶段,未来将提供更丰富的 ShutdownOnSuccess(竞速模式,取第一个成功结果)等策略,并与 ScopedValue 深度集成,形成完整的"现代 Java 并发编程三件套":虚拟线程 + 结构化并发 + Scoped Values。

对响应式编程的冲击与共存

维度 虚拟线程 Reactive (WebFlux)
代码可读性 ✅ 直观,同步风格 ❌ 链式操作,学习曲线陡
调试友好性 ✅ 普通线程堆栈 ❌ 堆栈碎片化
超高并发(百万级) ✅ 适合 IO 密集 ✅ 适合极致吞吐
背压控制 ❌ 无原生支持 ✅ Reactor 内置
成熟度 新,生态适配中 成熟,但复杂

Spring 官方的态度是:新项目优先考虑虚拟线程;已有 WebFlux 项目继续使用响应式;两者可以共存于同一应用中。

Java 并发模型的整体走向

Java 并发的未来清晰可见:从"管理线程资源"转向"描述任务结构"。开发者将越来越少关心线程池大小、线程生命周期,而是专注于业务逻辑的并发结构——哪些任务并行、哪些串行、失败如何传播——这些由 JVM 和框架统一处理。


思考题(自测)

  1. 虚拟线程在 synchronized 块内执行 Thread.sleep() 会发生 Pin 吗?为什么?(提示:sleep 不涉及 IO,思考 JVM 对 sleep 的实现)
  2. 将一个使用大量 ThreadLocal 存储上下文的老系统迁移到虚拟线程,可能会引发什么问题?如何评估迁移风险?
  3. 为什么虚拟线程的调度器选择 ForkJoinPool 而不是普通的 ThreadPoolExecutor?工作窃取(work-stealing)在这里有何优势?

学习路线图(前置知识补充)

虚拟线程
├── 必须掌握
│   ├── Java 并发基础(synchronized、volatile、happens-before)
│   ├── JUC 核心(ReentrantLock、CompletableFuture、ExecutorService)
│   └── IO 模型(BIO/NIO/AIO 区别,Linux epoll 原理)
│
├── 深入理解推荐
│   ├── JVM 栈帧结构与方法调用机制
│   ├── ForkJoinPool 工作窃取算法
│   └── Continuation 理论(可研究 Kotlin Coroutines 对比理解)
│
└── 生产落地必读
    ├── Spring Boot 3.x 虚拟线程集成文档
    ├── JEP 444 / 453 / 446 原文
    └── 你所用 JDBC 驱动 / 连接池是否已适配虚拟线程
        (HikariCP 已适配;某些驱动内部仍用 synchronized)
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐