Java 21 虚拟线程(Virtual Threads,源自 Project Loom)是近年来 JVM 并发模型最大的一次范式变革。过去写高并发服务,线程数受限于操作系统平台线程,动辄要为几百并发维护一个重量级线程池;虚拟线程把「线程」变成由 JVM 调度的轻量对象,用 M:N 调度让单机承载百万级并发成为可能。本文从原理到落地,帮你把线程池平滑迁移到虚拟线程。
一、为什么需要虚拟线程
传统 Java 并发建立在平台线程(Platform Thread)之上:一个 Java 线程直接映射到一个操作系统内核线程,创建成本约 1MB 栈内存,且上下文切换由内核完成。当并发请求数上升到几千、上万时,线程池要么被占满导致请求排队,要么无限膨胀拖垮内存。结果是开发者被迫用「异步回调 / Reactive」把代码拆得支离破碎,只为省线程。
虚拟线程的出现,让「一个请求一个线程」的直观写法重新变得可行——而且成本极低。它不改变你熟悉的 Thread、Runnable、Executor API,却把底层调度交给了 JVM,从而彻底解耦「并发度」与「内核线程数」。
二、虚拟线程 vs 平台线程:核心差异
2.1 调度模型:M:N 而非 1:1
平台线程是 1:1 映射内核线程;虚拟线程是 M:N 调度:大量虚拟线程挂载在少量「载体线程(Carrier Thread,本质是平台线程)」上。当虚拟线程遇到 I/O 阻塞(如网络读写、数据库连接),JVM 会自动把它从载体线程上「卸载」,让载体去跑别的虚拟线程;I/O 就绪后再挂载回来。正因为阻塞不再占用内核线程,吞吐量随 I/O 等待时间近似线性提升。
2.2 内存与创建成本
平台线程默认栈约 1MB,虚拟线程初始栈仅几百字节,并按需增长。这意味着你可以为每个任务新建一个虚拟线程,而不必复用线程池——「池化」这一在平台线程时代必须的性能技巧,在虚拟线程下反而成了反模式。
| 维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 与内核线程映射 | 1:1 | M:N(多对少载体线程) |
| 初始内存 | 约 1 MB/线程 | 几百字节/线程 |
| 默认并发上限 | 数千级 | 百万级 |
| 阻塞 I/O 时 | 占用内核线程 | 自动卸载,释放载体 |
| 典型用法 | 线程池复用 | 随用随建,无需池化 |
三、快速上手:从创建到 Executor
虚拟线程的 API 刻意保持和老线程一致。最简单的创建方式是 Thread.startVirtualThread:
// Java 21:随用随建一个虚拟线程
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("running in " + Thread.currentThread());
});
vt.join(); // 等待结束
如果你有一批任务要并发执行,用 Executors.newVirtualThreadPerTaskExecutor() 替代原来的固定大小线程池即可。注意它没有线程数上限——这正是虚拟线程的设计意图,不要再给它套一个容量上限。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> fetch(url)))
.toList();
for (Future<String> f : futures) {
System.out.println(f.get()); // 阻塞的是虚拟线程,不影响载体线程
}
}
四、实战:高并发聚合多个下游接口
一个典型场景:一个接口要并行调用商品、库存、价格三个下游服务再聚合。用虚拟线程写,逻辑是直白的「顺序 + 并发 submit」,可读性远好于 CompletableFuture 的回调链:
public AggregatedResult aggregate(Long skuId) throws Exception {
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var product = exec.submit(() -> productClient.get(skuId));
var stock = exec.submit(() -> stockClient.get(skuId));
var price = exec.submit(() -> priceClient.get(skuId));
return new AggregatedResult(product.get(), stock.get(), price.get());
}
}
这里每个 get() 阻塞的都是虚拟线程本身,而底层载体线程会被 JVM 拿去执行别的就绪虚拟线程。即使下游偶发慢调用,也不会像传统线程池那样把整个池堵死。若你的服务跑在 Spring Boot 3 上,可结合我们整理的Spring Boot 3 升级踩坑实录把 JDK 一并升到 21,再切换并发模型。
五、避坑清单:别让虚拟线程「被钉住」
虚拟线程最大的隐性陷阱是 Pinning(钉住):当虚拟线程在 synchronized 块内发生阻塞 I/O 时,它无法被卸载,会一直占用载体线程,退化为「平台线程式」阻塞,失去虚拟线程的优势。
// 反例:synchronized 内阻塞 I/O 会导致 Pinning
synchronized (lock) { // 钉住区间
String r = httpClient.get(url); // 阻塞 I/O 无法卸载
}
// 正例:改用 ReentrantLock,阻塞点在锁外
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区只放纯内存操作
} finally {
lock.unlock();
}
String r = httpClient.get(url); // I/O 在锁外,可正常卸载
可用 -Djdk.tracePinnedThreads=full 启动参数在钉住发生时打印栈,定位需要改造的 synchronized 段。多数情况下换成 ReentrantLock 即可解决。
| 适合虚拟线程 | 不适合 / 需谨慎 |
|---|---|
| 大量 I/O 密集型任务(HTTP、DB、RPC) | CPU 密集型长计算(无 I/O 让出) |
| 请求级「一个任务一线程」模型 | 需要固定平台线程的本地变量(ThreadLocal 滥用) |
| 替代回调式 / Reactive 编排 | synchronized 临界区内含阻塞 I/O(会 Pinning) |
六、迁移策略:从线程池到虚拟线程
对存量服务,最安全的迁移是「先替换 99% 的 Executors.newFixedThreadPool 为 newVirtualThreadPerTaskExecutor」,而保留少量确实需要平台线程的专用池(如监听信号、处理 CPU 密集批任务)。在 CI 中配合类型与测试校验,可参考我们的GitHub Actions 实战把 JDK 版本门禁一并纳管。
// 旧:固定线程池(并发受内核线程约束)
ExecutorService pool = Executors.newFixedThreadPool(200);
// 新:虚拟线程执行器(百万级并发,几乎无感)
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
若是 Spring Boot 应用,还可通过 Tomcat 的虚拟线程连接器(需 Spring Boot 3.2+、JDK 21)让每个 HTTP 请求都跑在虚拟线程上,无需改动业务代码即可获得吞吐收益。这与在 K8s 上做弹性扩缩(见K8s 入门:Pod/Deployment/Service)是互补的两层优化。
七、性能基准与适用边界
在「10k 并发 HTTP 聚合」的实测中,虚拟线程版的尾延迟(p99)与吞吐显著优于 200 固定线程池——因为后者在并发超过池容量时开始排队。但也要清醒:虚拟线程不提升单核算力,对纯 CPU 密集任务没有帮助,甚至可能因为调度开销略降。它解决的是「因等待 I/O 而浪费线程」的问题。
| 场景 | 固定线程池(200) | 虚拟线程 |
|---|---|---|
| 并发 < 池容量 | 持平 | 持平 |
| 并发远超池容量 | 排队、尾延迟飙升 | 吞吐稳定、尾延迟低 |
| 纯 CPU 密集 | 持平 | 略降(调度开销) |
八、总结
Java 21 虚拟线程不是银弹,却是并发编程的范式回归:它让「一个请求一个线程」的直观模型重新高效,把开发者从线程池调参与 Reactive 回调地狱中解放出来。落地三步走——升 JDK 21、把 I/O 阻塞点从 synchronized 迁到 ReentrantLock、用 newVirtualThreadPerTaskExecutor 替换固定池,再用 Pinning 追踪参数兜底。对绝大多数 I/O 密集型 Java 服务,这是一次值得立即做的低成本升级。关于工程里「该不该为新技术买单」的权衡,也可以读读我们写的技术债与工程决策。




