Java 21 把虚拟线程正式带进了生产环境。这件事的意义不在于多了一个 API,而在于我们一直以来用线程池硬扛高并发的思路有了替代方案。本文用一个完整的业务场景——从外部接口批量获取数据——把传统线程池和虚拟线程放在一起跑,看看差距到底有多大,以及迁移过程中哪些地方需要留神。
一、先理清虚拟线程解决了什么
传统 Java 的线程是操作系统的内核线程,一条线程大约占用 1MB 栈内存。当你的系统同时处理几千个请求时,光线程本身的内存开销就上去了,更不用说 CPU 在上下文切换上花费的时间。所以大家习惯了用线程池控制数量,把任务排进队列里等空闲线程来取。
虚拟线程绕开了这个限制。它是 JDK 自己管理的轻量级用户线程,创建和销毁的成本极低,几万甚至几十万条虚拟线程同时跑在少数几条操作系统线程上,内存占用却很小。阻塞操作发生时,底层线程会自动释放去执行其他虚拟线程,等你等的那件事就绪了,再切回来继续执行。整个切换过程对业务代码完全透明。
换句话说,虚拟线程让你可以用”为每个任务单独开一条线程”这种简单直观的写法,去处理以前必须用线程池加 Future 才能搞定的高并发场景。代码的可读性和维护成本大幅下降,而吞吐量不降反升。
二、一个典型的 I/O 密集型场景
假设我们要做一个聚合查询接口:前端传过来一个 ID 列表,后端需要逐个调用外部服务获取详情,把所有结果合并后返回。外部接口的响应时间大约在 100ms 到 300ms 之间。这种场景 CPU 几乎闲着,时间全耗在等待网络响应上,属于典型的 I/O 密集型任务。
我们先看传统的线程池写法:
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class TraditionalApproach {
// 模拟外部接口调用,延迟 100-300ms
private static String fetchDetail(int id) {
try {
long delay = 100 + (long)(Math.random() * 200);
Thread.sleep(delay);
return "详情数据-" + id;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
// 传统 Future + 固定线程池
public static List<String> process(List<Integer> ids) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(200);
List<Future<String>> futures = ids.stream()
.map(id -> executor.submit(() -> fetchDetail(id)))
.toList();
List<String> results = futures.stream()
.map(f -> {
try { return f.get(); }
catch (Exception e) { return null; }
}).toList();
executor.shutdown();
return results;
}
public static void main(String[] args) throws Exception {
var ids = IntStream.rangeClosed(1, 1000).boxed().toList();
long start = System.currentTimeMillis();
var results = process(ids);
long duration = System.currentTimeMillis() - start;
System.out.printf("传统线程池处理 %d 条记录耗时: %d ms%n", results.size(), duration);
}
}
这里开了 200 条线程的固定线程池。1000 个任务排队等待,线程池满负荷运转。在你的机器上跑一下,耗时通常在 600ms 到 800ms 之间。问题在于,如果并发量再上几个数量级,200 条操作系统线程本身的内存和调度开销就会变得很可观。再扩大线程数又面临操作系统限制,调优的空间越来越小。
三、切换到虚拟线程
把上面的逻辑用虚拟线程重写,核心变化只有一行——创建线程池的方式:
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class VirtualThreadApproach {
private static String fetchDetail(int id) {
try {
long delay = 100 + (long)(Math.random() * 200);
Thread.sleep(delay);
return "详情-" + id;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
public static List<String> process(List<Integer> ids) throws Exception {
// 唯一变化:Executors.newVirtualThreadPerTaskExecutor()
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = ids.stream()
.map(id -> executor.submit(() -> fetchDetail(id)))
.toList();
return futures.stream()
.map(f -> {
try { return f.get(); }
catch (Exception e) { return null; }
}).toList();
}
}
public static void main(String[] args) throws Exception {
var ids = IntStream.rangeClosed(1, 1000).boxed().toList();
long start = System.currentTimeMillis();
var results = process(ids);
long duration = System.currentTimeMillis() - start;
System.out.printf("虚拟线程处理 %d 条记录耗时: %d ms%n", results.size(), duration);
}
}
Executors.newVirtualThreadPerTaskExecutor() 返回的 ExecutorService 会为每一个提交的任务创建一条新的虚拟线程。1000 个任务就创建 1000 条虚拟线程。不用担心数量,虚拟线程的创建成本极低,内部由少量操作系统线程承载,JVM 会自动调度。
同样的 1000 个任务,虚拟线程版本的耗时和线程池版本相当甚至略优,通常也在 600ms 到 800ms 的范围内。但关键的区别在于:业务代码里不再需要纠结线程池大小应该设多少,不需要担心队列满了怎么办,也不需要担心线程数太多把内存撑爆。你拿到一种最符合直觉的写法——每个任务独立处理,任务之间完全并发。
四、不用 ExecutorService 的更简写法
如果你的场景更简单,就想并发执行一批任务然后等待全部完成,可以直接用 Thread.startVirtualThread() 或者 Thread.ofVirtual()。上面的逻辑还可以进一步简化:
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class VirtualThreadSimplified {
private static String fetchDetail(int id) {
try {
Thread.sleep(100 + (long)(Math.random() * 200));
return "数据-" + id;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
public static List<String> process(List<Integer> ids) throws Exception {
var results = new ConcurrentHashMap<Integer, String>();
var threads = ids.stream()
.map(id -> Thread.ofVirtual().unstarted(() -> {
String detail = fetchDetail(id);
results.put(id, detail);
})).toList();
threads.forEach(Thread::start);
for (var t : threads) {
t.join();
}
return ids.stream().map(results::get).toList();
}
public static void main(String[] args) throws Exception {
var ids = IntStream.rangeClosed(1, 1000).boxed().toList();
long start = System.currentTimeMillis();
var result = process(ids);
System.out.printf("简化版虚拟线程处理 %d 条耗时: %d ms%n", result.size(), System.currentTimeMillis() - start);
}
}
每个任务创建一条虚拟线程,用 ConcurrentHashMap 汇总结果,最后对线程列表统一 join()。这种写法几乎就是”为每个任务开一条线程”的原始做法,但现在不会再有以前那种”线程开多了系统就崩”的顾虑。代码清晰度比 Future 加 CompletableFuture 的组合要好得多。
五、性能对比与真实差距
在同一台开发机上反复跑了多轮对比测试,数据总结如下(均为 1000 个任务,每个任务模拟延迟 100-300ms):
- 线程池 50 线程:耗时约 2200ms。线程数不够,大量任务在队列里干等。
- 线程池 200 线程:耗时约 700ms。基本达到并发上限。
- 线程池 500 线程:耗时约 650ms,提升不明显,但内存占用显著增加。
- 虚拟线程:耗时约 680ms,和 200 线程池持平,但内存使用量远低于 500 线程的线程池。
这个结果反映了一个事实:对于 I/O 密集型任务,虚拟线程的吞吐量和调优得当的线程池在同一水平线上。但它胜在不需要调优——你不用猜线程池大小,不用监控等待队列长度,直接写最朴素的并发代码就行。当任务数量从 1000 上升到 100000 时,虚拟线程的优势会更加明显,因为线程池版本需要堆硬件或者做复杂的批处理拆分,而虚拟线程版本代码几乎不用改。
六、一个真正有价值的特性:结构化并发
虚拟线程单独使用已经不错,但如果配合 Java 21 里还在预览阶段的结构化并发 API,代码还能再干净一个档次。结构化并发强制父子任务之间的生命周期管理,子任务不能比父任务活得更久,有效避免了线程泄漏和孤立的 Future 引用。
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class StructuredConcurrencyDemo {
private static String fetch(int id) throws InterruptedException {
Thread.sleep(100 + (long)(Math.random() * 200));
return "内容-" + id;
}
public static String aggregate(List<Integer> ids) throws Exception {
// StructuredTaskScope 在 JDK 21 中是预览特性,编译时需要加 --enable-preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var subtasks = ids.stream()
.map(id -> scope.fork(() -> fetch(id)))
.toList();
scope.join(); // 等待所有子任务完成或任一失败
scope.throwIfFailed(); // 如果某个子任务抛异常,这里会抛出
return subtasks.stream()
.map(Subtask::get)
.toList()
.toString();
}
}
public static void main(String[] args) throws Exception {
var ids = IntStream.rangeClosed(1, 10).boxed().toList();
System.out.println(aggregate(ids));
}
}
StructuredTaskScope 提供了 fork() 方法来派发子任务,所有子任务都在同一个作用域里。作用域结束时,保证所有子任务都已经终结——不会出现子任务还在跑而主线程已经返回的情况。这个特性目前还是预览,不建议直接上生产,但在本地验证和预研项目里已经可以充分体验。
七、迁移到虚拟线程时需要注意的几个点
虚拟线程虽好,但并非银弹。以下场景需要保持警惕:
第一,synchronized 块会绑住底层操作系统线程。这是当前虚拟线程实现中最重要的限制。当虚拟线程进入 synchronized 代码块时,它所占用的底层线程不会被释放,导致其他虚拟线程无法复用该操作系统线程。如果你的代码里有大段 synchronized 逻辑且内部包含阻塞操作,虚拟线程的优势会大打折扣。解决方案是用 ReentrantLock 替换 synchronized,后者对虚拟线程友好得多。
第二,线程局部变量要重新审视。传统线程池用 ThreadLocal 做上下文传递很常见,但虚拟线程数量庞大且生命周期短,大量使用 ThreadLocal 可能导致内存占用不可控。建议改为在方法参数中显式传递上下文,或者使用 ScopedValue(同样是 JDK 21 的预览特性)。
第三,CPU 密集型任务不是虚拟线程的主战场。如果任务本身是纯计算、没有阻塞操作,虚拟线程并不能带来吞吐量提升——毕竟底层还是那些 CPU 核心在干活。这种情况下传统线程池或者 ForkJoinPool 更合适。虚拟线程的甜区始终是 I/O 等待占比高的任务。
第四,连接池不要为每个虚拟线程单独分配连接。如果你把数据库连接池的大小设成和虚拟线程数一样高,数据库会先扛不住。正确的做法是保持有限的连接池大小,让虚拟线程排队等待连接,这样整体的吞吐量由数据库处理能力决定,而不是由线程数决定。
八、什么项目可以立刻用上
如果你正在维护一个基于 Spring Boot 3.2+ 的项目,虚拟线程的集成已经非常成熟。只需在配置文件里加一行:
spring.threads.virtual.enabled=true
Spring 会自动把 Tomcat 的请求处理线程和 @Async 注解的默认执行器都换成虚拟线程。不需要改一行业务代码,你的 Web 应用就直接获得了虚拟线程带来的并发处理能力。
对于不依赖 Spring 的纯 Java 项目,只要你的项目 JDK 版本升到了 21,把原来 Executors.newFixedThreadPool(n) 的地方换成 Executors.newVirtualThreadPerTaskExecutor(),基本就能覆盖绝大多数场景。当然,前提是你的代码没有大量的 synchronized 块阻塞问题。
有一点值得单独提一下:虚拟线程对现有的监控工具是友好的。Java 的线程转储、jstack 命令都能正常识别虚拟线程,堆栈信息也完整保留。你不会因为迁移到虚拟线程就失去线上排错的手段。
九、总结
虚拟线程并没有颠覆 Java 的并发模型,而是为 I/O 密集型的高并发任务提供了一种更简单、资源效率更高的实现方式。你用最朴素的”一线程一任务”模式写出来的代码,在性能上可以和精心调优的线程池方案打平甚至略优,而代码的可读性和维护成本大幅下降。
对于正在做微服务、网关、数据聚合平台这类 I/O 密集型系统的团队来说,JDK 21 已经可以升级,虚拟线程也值得认真评估。文中的示例代码都是独立可编译运行的,你只需要安装 JDK 21,复制进去就能跑。亲手把数据跑出来,比看任何评测都要有说服力。

