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(); // 同步代码,在虚拟线程中执行
}
虚拟线程的适用场景
适合使用虚拟线程
- 高并发 I/O:Web 服务、REST API、数据库查询、文件读写。
- 微服务间调用:HTTP 客户端、gRPC 调用——大量等待时间。
- 批处理 I/O 任务:并行处理大量文件或数据库记录。
- 「一请求一线程」架构迁移:将原有阻塞同步代码直接迁移,无需改写为响应式。
不适合使用虚拟线程
- CPU 密集型计算:矩阵运算、图像处理、加解密。虚拟线程不减少 CPU 需求,应使用 ForkJoinPool 或平台线程池。
- 管理虚拟线程的「线程池」:不要用 newFixedThreadPool(N) 来「管理」虚拟线程,直接 newVirtualThreadPerTaskExecutor() 即可。
本章小结
- Project Loom 是 OpenJDK 的长期项目,虚拟线程(JEP 444)在 Java 21 正式发布。
- 虚拟线程由 JVM 调度,运行在少量载体线程上,I/O 阻塞时自动卸载,释放载体线程,I/O 就绪后重新挂载执行。
- 创建方式:
Thread.ofVirtual().start()、Executors.newVirtualThreadPerTaskExecutor()。 - Pinning(钉住)是主要陷阱:synchronized 块内阻塞会 pin 住载体线程,Java 24(JEP 491)已修复。
- 虚拟线程专为 I/O 密集型任务设计,CPU 密集型任务仍需平台线程。
- Spring Boot 3.2+ 一行配置即可让所有 Web 请求运行在虚拟线程上。