Skip to content

虚拟线程(Project Loom)原理与对比

引言

JDK 21 正式转正了虚拟线程(Virtual Threads,JEP 444),这是 Java 并发编程历史上最大的变革之一。有人说它终结了"异步编程框架"的时代,有人说它只是把线程池换了个名字。真实情况是什么?本文从原理到实践,拆解虚拟线程的 M:N 调度模型、阻塞挂起机制、适用场景和坑点。


一、传统线程的痛点

先看一个最简单的 Web 服务场景:

java
// 传统平台线程模型
ExecutorService executor = Executors.newFixedThreadPool(200);
while (true) {
    Socket socket = serverSocket.accept();
    executor.submit(() -> handleRequest(socket));
}

每个请求分配一个线程,200 个线程意味着操作系统要维护 200 个内核线程。每个线程默认栈大小 1MB(Linux),光栈就占用 200MB 内存。更致命的是:

  • 上下文切换开销:一次平台线程的上下文切换约 1-10μs,涉及内核态切换、TLB 刷新
  • 内存限制:1GB 物理内存最多支撑约 1000 个平台线程
  • C10K 问题:万级并发连接时,平台线程数不可能涨到 10000

所以 Java 社区引入了异步编程模型:CompletableFuture、Reactor、WebFlux。但异步代码的复杂度陡增——回调地狱、异常传播难、调试困难。


二、虚拟线程的核心原理

M:N 调度模型

虚拟线程的本质是 JVM 管理的用户态线程,不绑定 OS 线程。调度模型是 M:N:M 个虚拟线程复用在 N 个平台线程(Carrier Thread)上执行。

┌─────────────────────────────────────────┐
│          M 个虚拟线程                      │
│  VT#1  VT#2  VT#3  VT#4  VT#5 ... VT#M  │
└──────────────────┬──────────────────────┘
                   │ 调度器 (ForkJoinPool)

┌─────────────────────────────────────────┐
│          N 个 Carrier 线程                │
│  CT#1  CT#2  CT#3  ...  CT#N            │
│  (N = CPU 核数,默认)                      │
└─────────────────────────────────────────┘

Carrier 线程默认使用 ForkJoinPool.commonPool(),线程数 = Runtime.getRuntime().availableProcessors()。所以一个 4 核机器上,虚拟线程背后只有 4 个真正的 OS 线程在工作。

Continuation 挂起机制

虚拟线程能实现"轻量级"挂起的核心是 Continuation(延续性)。当虚拟线程执行阻塞操作时:

  1. JVM 捕获当前虚拟线程的栈帧和局部变量
  2. 将 Carrier 线程释放,归还给 ForkJoinPool
  3. 虚拟线程进入等待队列,不占用任何 OS 线程资源
  4. 阻塞操作完成后,JVM 将虚拟线程重新调度到某个 Carrier 线程上执行
java
// 虚拟线程的阻塞挂起,不是真正的 OS 线程阻塞
Thread.sleep(1000);   // → 虚拟线程挂起,Carrier 线程被释放
socket.read();        // → 同上
blockingQueue.take(); // → 同上

挂起/恢复的成本在纳秒级别,比平台线程的上下文切换(微秒级)低 3-4 个数量级。

创建代价对比

java
// 平台线程 — 每个线程 1MB 栈,创建耗时微秒级
Thread.ofPlatform().start(() -> { ... });

// 虚拟线程 — 初始栈仅几百字节,创建耗时纳秒级
Thread.ofVirtual().start(() -> { ... });

// 百万级虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 1_000_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(1000);
            return i;
        })
    );
}

上面这段代码在 4 核机器上,1 秒钟内创建并运行 100 万个虚拟线程,内存占用仅几百 MB,而平台线程要撑爆 1TB。


三、虚拟线程 vs 其他模型

与平台线程对比

维度平台线程虚拟线程
调度方OS 内核JVM
创建成本高(系统调用)极低(用户态)
栈大小默认 1MB动态增长,初始 ~几百字节
上下文切换1-10μs,内核态纳秒级,用户态
最大数量几千(受内存限制)千万级
适合场景CPU 密集型IO 密集型

与异步编程模型对比

java
// 异步框架写法(Reactor)
Flux.from(someSource)
    .flatMap(data -> asyncService.call(data))
    .subscribe(result -> { ... });

// 虚拟线程写法(同步阻塞风格)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Result>> futures = dataList.stream()
        .map(data -> executor.submit(() -> syncService.call(data)))
        .toList();
    for (var f : futures) {
        Result r = f.get();  // 阻塞的是虚拟线程,不是 Carrier 线程
    }
}

虚拟线程让同步阻塞代码重新可行——你不需要学习 Reactor 的 operator 链,不需要理解背压,不需要处理回调里的异常传播。写普通的 try-catch 就行。


四、虚拟线程的坑点

1. 钉住(Pinning)问题

虚拟线程在以下场景会 "钉住" Carrier 线程,退化为平台线程行为:

java
// 场景一:synchronized 块内阻塞
synchronized (lock) {
    Thread.sleep(1000);  // 虚拟线程被钉住,Carrier 线程无法复用
}

// 场景二:JNI 调用中阻塞
// 虚拟线程在 native 方法中阻塞时,也会钉住 Carrier 线程

JDK 21 中,JVM 会在钉住时输出警告日志。规避方法:用 ReentrantLock 替代 synchronized,后者不会钉住。

2. 线程池不兼容

java
// 错误用法 — 线程池 + 虚拟线程,失去轻量级优势
var pool = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10000; i++) {
    pool.submit(() -> { ... });  // 还是 200 个平台线程
}

// 正确用法
var executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 10000; i++) {
    executor.submit(() -> { ... });  // 每个任务一个虚拟线程
}

3. ThreadLocal 内存泄漏

虚拟线程创建成本极低,大量使用时 ThreadLocal 会成为内存泄漏放大器

java
// 100 万个虚拟线程,每个都创建了 ThreadLocal
for (int i = 0; i < 1_000_000; i++) {
    Thread.startVirtualThread(() -> {
        threadLocal.set(new byte[1024]);  // 1GB 泄漏
        // 忘掉 remove()
    });
}

JDK 21 引入的 ScopedValue(JEP 429)是 ThreadLocal 的替代方案,不可变、不可继承、不需要手动 remove。


五、生产环境建议

适用场景

Web 服务器 — 每个请求一个虚拟线程,简单高效 ✅ RPC 调用 — 大量短连接、IO 等待 ✅ 数据库访问 — JDBC 连接等待 ✅ 消息消费 — Kafka/RocketMQ 消费者

不适用场景

CPU 密集型计算 — 虚拟线程的调度开销没有意义 ❌ synchronized 重锁场景 — 钉住问题 ❌ 极低延迟场景 — 虚拟线程的调度延迟比平台线程略高

迁移策略

java
// 步骤 1:替换 Executor
// 旧
Executors.newCachedThreadPool()
// 新
Executors.newVirtualThreadPerTaskExecutor()

// 步骤 2:替换 Spring Boot 内置 Tomcat
// application.properties
server.tomcat.threads.max=10  // 不相关了,虚拟线程无边
spring.threads.virtual.enabled=true

// 步骤 3:检查 synchronized,替换为 ReentrantLock
// 旧
synchronized (this) { ... }
// 新
private final Lock lock = new ReentrantLock();
lock.lock();
try { ... } finally { lock.unlock(); }

总结

虚拟线程不是"更快"的线程,而是"更多"的线程。它解决的核心问题是平台线程资源稀缺导致的架构复杂度——过去我们不得不用异步框架来复用有限的平台线程,现在虚拟线程让每个 IO 任务都可以有自己的线程,代码回归同步风格。

但虚拟线程不是万能药:CPU 密集型任务、synchronized 重锁、毫秒级低延迟场景,它并不占优。理解它的 M:N 模型和钉住条件,才能在正确的场景发挥它的价值。


参考资料

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。