Chapter 07

虚拟线程与 Project Loom

Java 21 里程碑特性深度解析:理解虚拟线程的工作原理,掌握其适用场景与注意事项

Project Loom 的背景与目标

Project Loom 是 OpenJDK 中一个长期运行的项目,由 Ron Pressler 领导, 目标是为 Java 引入轻量级并发原语——首先是虚拟线程,其次是结构化并发。

传统并发模型的困境

在 Java 21 之前,开发者面对高并发 I/O 有两种选择,各有代价:

「一请求一线程」模型

  • 代码简单直观(同步风格)
  • 每个请求一个平台线程
  • 线程数受 OS 限制(通常几千个)
  • 大量 I/O 等待时,线程被阻塞浪费
  • 高并发时内存和 OS 线程耗尽

响应式/异步模型

  • 代码复杂(回调、CompletableFuture 链)
  • 少量线程处理大量并发
  • 调试困难(堆栈不完整)
  • 需要专门的响应式框架支持
  • 学习成本高,代码可读性差

Project Loom 的答案是:让「一请求一线程」模型在百万级并发下也高效运行, 同时保持同步代码的简洁性。这就是虚拟线程的核心价值。

虚拟线程的内部机制

核心概念

虚拟线程(Virtual Thread)
由 JVM 管理的轻量级线程,不与特定 OS 线程 1:1 绑定。栈帧存储在 Java 堆中(可动态增长/收缩),创建成本极低(约几百字节到几 KB)。
载体线程(Carrier Thread)
真正运行虚拟线程的平台线程。虚拟线程在载体线程上「执行」,类似 OS 上的用户态线程调度。默认载体线程数 = CPU 核数(一个 ForkJoinPool)。
挂载(Mount)
将虚拟线程附加到载体线程上执行。调度器从等待队列取出虚拟线程,将其堆栈帧复制(移动)到载体线程栈中执行。
卸载(Unmount)
虚拟线程遇到阻塞操作(I/O、sleep、锁等待)时,将堆栈保存回堆内存,释放载体线程去执行其他虚拟线程。阻塞操作完成后,虚拟线程重新进入等待队列等待挂载。
续体(Continuation)
虚拟线程底层依赖 JVM 的 Continuation 机制实现挂起/恢复,类似操作系统的上下文切换但在 JVM 用户态完成。
虚拟线程调度详细流程: JVM 调度器(ForkJoinPool,默认 CPU 核数个 Carrier Thread) │ ├── Carrier Thread-1 │ ├── [时刻1] 挂载 VThread-A → 执行代码 │ ├── [时刻2] VThread-A 遇到 I/O 阻塞 → 卸载 VThread-A,挂载 VThread-B │ ├── [时刻3] 执行 VThread-B 代码 │ └── [时刻4] VThread-B 完成,挂载 VThread-C... │ ├── Carrier Thread-2 │ ├── 独立运行其他虚拟线程 │ └── ... │ └── 等待队列(堆中保存的挂起虚拟线程) ├── VThread-A(等待网络 I/O,已卸载) ├── VThread-D(等待数据库响应,已卸载) └── VThread-E(刚创建,等待挂载)

创建与使用虚拟线程

// 方式1:Thread.ofVirtual()(最基础的创建方式)
Thread vt = Thread.ofVirtual()
    .name("vt-1")
    .start(() -> System.out.println("虚拟线程:" + Thread.currentThread()));

// 方式2:Thread.Builder(工厂方法,可批量创建命名线程)
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
Thread t1 = builder.start(() -> task1()); // worker-0
Thread t2 = builder.start(() -> task2()); // worker-1

// 方式3:ExecutorService(推荐用于大量任务)
try (ExecutorService vExec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        final int id = i;
        vExec.submit(() -> {
            Thread.sleep(Duration.ofMillis(100)); // I/O 模拟
            return "result-" + id;
        });
    }
} // 自动等待所有任务完成

// 方式4:Spring Boot 3.2+ 一行配置启用虚拟线程(推荐)
// application.properties:
// spring.threads.virtual.enabled=true
// Tomcat/Jetty 自动将每个请求用虚拟线程处理

与平台线程的深度对比

特性 平台线程(Platform Thread) 虚拟线程(Virtual Thread)
创建成本 高(~1MB 栈,OS 系统调用) 极低(几百字节,JVM 堆分配)
切换成本 高(内核态切换,~1μs) 极低(JVM 用户态,~ns 级别)
最大数量 数千(受 OS 限制) 数百万(受堆内存限制)
I/O 阻塞 OS 线程阻塞,浪费资源 自动卸载,载体线程继续执行
CPU 密集型 合适 不改善(仍需 OS 线程)
调试体验 完整堆栈 完整堆栈(比响应式好)
Thread.currentThread() 平台线程 虚拟线程(isVirtual()=true)
ThreadLocal 支持 支持(但注意内存:百万×TL)
线程池 需要(复用) 不需要(每任务一线程)

Pinning 问题:虚拟线程的最大陷阱

Pinning(钉住)是指虚拟线程被「钉」在载体线程上,无法在阻塞时卸载, 从而阻塞载体线程,退化为平台线程的行为。

导致 Pinning 的两种情况

1. synchronized 代码块/方法内阻塞
虚拟线程在 synchronized 代码块内遇到 I/O 阻塞时,由于 synchronized 的实现依赖 OS 监视器,JVM 无法安全地卸载虚拟线程(锁语义要求线程与监视器绑定)。此时虚拟线程被 pin 在载体线程上,该载体线程阻塞等待 I/O。
2. native 方法调用期间
虚拟线程执行 native 方法(JNI 调用)时,JVM 无法管理 native 层的执行状态,同样会 pin 住载体线程。
// Pinning 示例:synchronized 内部 I/O 阻塞
public class PinningDemo {
    private final Object lock = new Object();

    void problematic() {
        synchronized (lock) {
            // 虚拟线程在此阻塞 I/O 时被 pin 住!
            byte[] data = readFromDatabase(); // 网络 I/O
        }
    }

    // 修复:用 ReentrantLock 替换 synchronized
    private final ReentrantLock reentrantLock = new ReentrantLock();

    void fixed() {
        reentrantLock.lock();
        try {
            // ReentrantLock 内部用 LockSupport.park(),虚拟线程可以安全卸载
            byte[] data = readFromDatabase(); // 不再 pin 住
        } finally {
            reentrantLock.unlock();
        }
    }
}
Java 24 修复 synchronized Pinning

好消息!JEP 491(Java 24,2025年3月发布)修复了 synchronized 的 pinning 问题。 从 Java 24 起,虚拟线程在 synchronized 块内阻塞时也可以安全卸载,不再 pin 住载体线程。 这意味着大量使用 synchronized 的遗留代码(如旧版 JDBC 驱动)也可以直接享受虚拟线程的优势。

检测 Pinning

// JVM 参数:检测并打印 pinning 事件
// -Djdk.tracePinnedThreads=full    详细打印堆栈
// -Djdk.tracePinnedThreads=short   只打印关键帧

// JFR 检测:使用 Java Flight Recorder 追踪 pinning
// 运行时:jcmd {pid} JFR.start settings=profile filename=recording.jfr
// 分析:查找 jdk.VirtualThreadPinned 事件

虚拟线程与 ThreadLocal 的内存考量

ThreadLocal 在虚拟线程上仍然有效,但需要注意:如果每个虚拟线程都持有大型 ThreadLocal 值, 百万个虚拟线程可能消耗大量堆内存。Java 21 的 ScopedValue(结构化并发章节详述) 提供了更轻量的替代方案。

// 虚拟线程数量统计示例
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> results = new ArrayList<>();
    for (int i = 0; i < 100_000; i++) {
        results.add(exec.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1)); // 模拟 I/O
            return Thread.currentThread().toString();
        }));
    }
    // 10万个虚拟线程,每个只睡眠1秒
    // 平台线程方式:需要10万个OS线程,约100GB内存!
    // 虚拟线程方式:仅需少量内存,1秒多后全部完成
    for (Future<String> f : results) f.get(); // 收集结果
}

Spring Boot 与虚拟线程

// Spring Boot 3.2+:一行开启虚拟线程
// application.yml:
// spring:
//   threads:
//     virtual:
//       enabled: true

// 或编程方式(Spring Boot 3.2+)
@Configuration
public class VirtualThreadConfig {
    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
        return protocolHandler ->
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    }
}

// Quarkus(需 Java 21+)
@RunOnVirtualThread  // 该 REST 端点在虚拟线程中执行
@GET
@Path("/products")
public List<Product> getProducts() {
    return productService.findAll(); // 同步代码,在虚拟线程中执行
}

虚拟线程的适用场景

适合使用虚拟线程
不适合使用虚拟线程

本章小结