Java 虚拟线程与并发新特性实战
Java 21 正式发布的虚拟线程(Virtual Thread,Project Loom)是 Java 并发模型近二十年来最大的变革。它让你用同步写法的代码获得异步的吞吐量,不再需要纠结线程池大小、不再被回调地狱折磨。本文从虚拟线程的核心原理讲起,对比传统平台线程,给出生产级迁移路径与避坑指南。
一、平台线程 vs 虚拟线程
传统 Java 的 Thread 是平台线程(Platform Thread),它直接映射到操作系统内核线程。创建一个平台线程大约消耗 1MB 栈内存,切换开销也不小。所以实际开发中我们用线程池复用线程,池大小通常设为 CPU 核数的 1 到几倍。
虚拟线程完全不同:它是 JVM 管理的轻量级线程,不映射到操作系统线程。成千上万个虚拟线程可以跑在少量平台线程(称为载体线程)上,由 JVM 负责调度。你可以把它理解为用户态协程,但写代码时感觉就像普通线程一样。
| 对比维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 内存开销 | 约 1MB 栈 | 几十到几百字节 |
| 数量上限 | 几百到几千 | 几百万 |
| 调度方式 | 操作系统内核调度 | JVM 用户态调度 |
| 编程模型 | 线程池复用 | 每个任务一个新线程 |
| 适合场景 | CPU 密集型 | I/O 密集型 |
二、第一个虚拟线程程序
启动一个虚拟线程就像启动普通线程一样简单:
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// 启动一个虚拟线程
Thread.startVirtualThread(() -> {
System.out.println("虚拟线程运行在:" + Thread.currentThread());
});
// 创建 10000 个虚拟线程
var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 10_000; i++) {
final int id = i;
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (id % 1000 == 0) {
System.out.println("任务 " + id + " 完成");
}
});
}
executor.close(); // 等待所有任务完成
}
}
注意 newVirtualThreadPerTaskExecutor():每个任务都创建一个新的虚拟线程,而不是复用线程池。这听起来浪费,但虚拟线程创建成本极低,完全可以这样做。
三、为什么虚拟线程能提升吞吐量
虚拟线程的核心优势在于 I/O 等待时不占载体线程。当一个虚拟线程执行到 Thread.sleep() 或 socket 读写时,JVM 会自动把它从载体线程上卸载(unmount),让载体线程去运行其他虚拟线程。等 I/O 就绪后,JVM 再把虚拟线程挂回某个载体线程继续执行。
这意味着:在等待数据库响应的那 100 毫秒里,你的服务器没有闲着,而是在处理其他请求。传统线程池在这种情况下,工作线程会被阻塞住,新请求只能排队。
// 传统线程池:阻塞时线程被占住
ExecutorService pool = Executors.newFixedThreadPool(200);
// 200 个请求同时查数据库,第 201 个开始排队
// 虚拟线程:每个请求一个虚拟线程,阻塞时不占载体
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> queryDatabase()); // 同时跑一万个,不排队
}
}
四、生产迁移策略
把现有 Spring Boot 应用迁移到虚拟线程非常简单,只需要改一个配置:
# application.yml
spring:
threads:
virtual:
enabled: true
这一行配置会让 Spring Boot 的 Tomcat 服务器、@Async 方法、定时任务全部改用虚拟线程。你的业务代码完全不用改——因为虚拟线程的 API 和普通线程一模一样。
但有几个坑需要注意:
坑一:synchronized 块会钉住载体线程。当虚拟线程在 synchronized 块里执行 I/O 操作时,JVM 暂时无法把它卸载,因为同步监视器是和载体线程绑定的。在高频 I/O 场景下,建议改用 ReentrantLock。
// 不太推荐:synchronized 里做 I/O
public synchronized String bad() throws Exception {
Thread.sleep(100); // 这 100ms 载体线程被钉住
return "result";
}
// 推荐:ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public String good() throws Exception {
lock.lock();
try {
Thread.sleep(100);
return "result";
} finally {
lock.unlock();
}
}
坑二:不要突然创建几十万个虚拟线程做 CPU 密集型任务。虚拟线程解决的是 I/O 等待问题。如果任务是纯计算,虚拟线程不会比平台线程更快,反而增加调度开销。
五、结构化并发
Java 21 同时引入了结构化并发(Structured Concurrency,预览特性)。它的核心思想是:把一组相关的并发任务当作一个工作单元来管理,要么全部成功,要么一起取消。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser());
Future<Order> order = scope.fork(() -> fetchOrder());
scope.join(); // 等待所有任务
scope.throwIfFailed(); // 任何一个失败就抛异常
System.out.println(user.resultNow() + order.resultNow());
}
相比传统的 CompletableFuture 链式调用,结构化并发的代码更直观,错误处理也更统一。
六、性能监控与诊断
虚拟线程数量动辄几万,传统的线程 dump 会输出海量信息。JDK 提供了专门的诊断工具:
# 查看虚拟线程统计
jcmd <pid> Thread.dump_to_file -virtual=true threads.txt
# JDK 21+ 的内置指标
# 可以通过 JConsole 或 Micrometer 查看 virtual.threads.count
建议在生产环境监控三个指标:活跃虚拟线程数、载体线程数、虚拟线程被卸载后重新挂载的频率。如果载体线程数接近 CPU 核数的几倍,说明你的任务偏 CPU 密集,可能不适合全量虚拟线程。
七、总结
虚拟线程是 Java 并发编程的范式转移:不再需要精心调优线程池大小,不再需要用响应式框架的复杂 API,用最朴素的同步写法就能获得高吞吐。对于 I/O 密集型的 Web 应用、微服务后端、数据抓取任务,迁移到虚拟线程是提升性能和简化代码的双赢选择。记住三条原则:虚拟线程不要池化、synchronized 里避免 I/O、CPU 密集型任务仍然用平台线程。