虚拟线程(Project Loom)原理与对比
引言
JDK 21 正式转正了虚拟线程(Virtual Threads,JEP 444),这是 Java 并发编程历史上最大的变革之一。有人说它终结了"异步编程框架"的时代,有人说它只是把线程池换了个名字。真实情况是什么?本文从原理到实践,拆解虚拟线程的 M:N 调度模型、阻塞挂起机制、适用场景和坑点。
一、传统线程的痛点
先看一个最简单的 Web 服务场景:
// 传统平台线程模型
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(延续性)。当虚拟线程执行阻塞操作时:
- JVM 捕获当前虚拟线程的栈帧和局部变量
- 将 Carrier 线程释放,归还给 ForkJoinPool
- 虚拟线程进入等待队列,不占用任何 OS 线程资源
- 阻塞操作完成后,JVM 将虚拟线程重新调度到某个 Carrier 线程上执行
// 虚拟线程的阻塞挂起,不是真正的 OS 线程阻塞
Thread.sleep(1000); // → 虚拟线程挂起,Carrier 线程被释放
socket.read(); // → 同上
blockingQueue.take(); // → 同上挂起/恢复的成本在纳秒级别,比平台线程的上下文切换(微秒级)低 3-4 个数量级。
创建代价对比
// 平台线程 — 每个线程 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 密集型 |
与异步编程模型对比
// 异步框架写法(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 线程,退化为平台线程行为:
// 场景一:synchronized 块内阻塞
synchronized (lock) {
Thread.sleep(1000); // 虚拟线程被钉住,Carrier 线程无法复用
}
// 场景二:JNI 调用中阻塞
// 虚拟线程在 native 方法中阻塞时,也会钉住 Carrier 线程JDK 21 中,JVM 会在钉住时输出警告日志。规避方法:用 ReentrantLock 替代 synchronized,后者不会钉住。
2. 线程池不兼容
// 错误用法 — 线程池 + 虚拟线程,失去轻量级优势
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 会成为内存泄漏放大器:
// 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 重锁场景 — 钉住问题 ❌ 极低延迟场景 — 虚拟线程的调度延迟比平台线程略高
迁移策略
// 步骤 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 模型和钉住条件,才能在正确的场景发挥它的价值。
参考资料
- JEP 444: Virtual Threads — https://openjdk.org/jeps/444
- JEP 429: Scoped Values — https://openjdk.org/jeps/429
- Project Loom Wiki — https://wiki.openjdk.org/display/loom/Main
- 《Java 并发编程实战》Brian Goetz