做个实验。你拿一台4核8G的机器,开200个平台线程,每个线程去睡一觉(模拟IO阻塞),看它同时能扛住多少并发。说实话,200个平台线程处理200个并发请求,已经有点捉襟见肘了。但如果把平台线程换成虚拟线程,你会发现——十万个虚拟线程同时阻塞也没有压力。这不是玄学,是JDK 21正式发布的Virtual Threads。
今天这篇文章不聊那种PPT级别的概念,直接实战。内容包括虚拟线程的核心API、在Spring Boot 3里的集成方式、一个真实场景下的性能对比,以及我踩过的坑。我会把代码和配置原原本本贴出来,你照着做一遍就明白了。
虚拟线程到底解决了什么问题
聊虚拟线程之前,得先说说Java并发编程的老大难问题。
从Java 1.0开始,Thread就相当于一个操作系统线程的封装。每个平台线程(Platform Thread)都要占用独立的栈空间,默认1MB左右。这意味着你创建1万个线程,光栈内存就得10GB,这谁顶得住?所以传统做法就是用线程池,把线程数量控制在200~500个,然后用异步编程来做高并发。
异步编程的代价是可读性和可维护性。你一个简单的同步逻辑,为了异步,得拆成CompletableFuture链式调用,或者用响应式编程,写出来的代码自己看着都头疼。调试的时候,更是一不小心就陷入回调地狱的泥潭。
虚拟线程策略不同:它一个进程可以有几十万个虚拟线程,每个占用内存只有几百字节。你写普通同步代码就行,JVM在遇到阻塞的瞬间自动把虚拟线程挂起,然后把载体线程(Carrier Thread)让给别的虚拟线程执行。
说白了,虚拟线程让你用同步的写法,拿到异步的性能。这个价值在IO密集型项目中是巨大的。
核心API:就这几个,别怕
JDK 21是LTS版本,虚拟线程API已经稳定。创建虚拟线程有三种方式,我逐个说。
1. Thread.ofVirtual()
Thread vt = Thread.ofVirtual()
.name("order-vt")
.start(() -> {
System.out.println("当前执行线程: " + Thread.currentThread());
});
2. Thread.startVirtualThread()
Thread.startVirtualThread(() -> System.out.println("快速启动一个虚拟线程"));
3. Executors.newVirtualThreadPerTaskExecutor()
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> System.out.println("虚拟线程执行任务"));
关键点:Executors.newVirtualThreadPerTaskExecutor()它不干线程复用的事。每个任务提交进去,都新创建一个虚拟线程,任务跑完就销毁。这个执行器只是个任务提交的入口而已。
所以,别想着用这个执行器来做线程池限流。虚拟线程天然就该是”一个任务一个线程”的模型。
在Spring Boot 3中启用虚拟线程
Spring Boot 3.2以上版本,已经内置了虚拟线程的自动配置。你要做的,就是改一行配置文件。
spring:
threads:
virtual:
enabled: true
这一行配置加进去,Spring Boot底层的Tomcat容器就会使用虚拟线程来处理HTTP请求。对业务代码而言,你是无感知的,原来怎么写还是怎么写。
我随便写一个Controller做演示:
@RestController
@RequestMapping("/order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/detail")
public Result getOrderDetail(Long orderId) throws Exception {
// 下游调了用户服务、优惠券服务、物流服务
// 没启用虚拟线程之前,这个请求会占用Tomcat线程200ms
// 启用虚拟线程之后,阻塞期间线程被释放出去处理别的请求
OrderDetail detail = orderService.getFullDetail(orderId);
return Result.ok(detail);
}
}
有一个细节需要注意。如果你用的是Spring Boot 3.1或更早版本,加上这个配置是不生效的。还有,如果你在代码里用了自定义的Servlet Filter,并且依赖了Tomcat线程的数量(比如限流),那么启用虚拟线程后,HttpServletRequest的处理逻辑已经跑在虚拟线程上了,这个变化要心里有数。
性能测试:传统线程池 vs 虚拟线程
空谈没有意义。我写了一个非常直观的对比测试,模拟一个处理了200ms IO阻塞的任务,分别用传统固定线程池和虚拟线程执行800个任务,看总耗时。
先看模拟任务代码:
static void handleTask(int id) throws Exception {
// 模拟调用下游HTTP接口
Thread.sleep(200);
}
传统线程池:200个平台线程
public static void fixedThreadPoolTest() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(200);
for (int i = 0; i < 800; i++) {
int id = i;
pool.submit(() -> {
try {
handleTask(id);
} catch (Exception e) {
e.printStackTrace();
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
}
200个线程并发执行,800个任务分4批执行,每批200ms,总耗时要800ms左右。
虚拟线程:800个虚拟线程全部并发
public static void virtualThreadTest() throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 800; i++) {
int id = i;
executor.submit(() -> {
try {
handleTask(id);
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
}
800个任务,每个任务一个虚拟线程,全部同时执行,总耗时200ms左右,比传统线程池快了整整4倍。
这个性能优势是怎么来的?因为传统线程池里,线程在Thread.sleep(也就是IO阻塞)的时候,是真正被操作系统挂起的。而虚拟线程在阻塞时,虚拟线程会被JVM从载体线程上卸下来,载体线程被释放去执行其他虚拟线程。所以虚拟线程的阻塞成本接近于零。
实战:一个聚合接口的并发改造
光造轮子没意思。来看一个实际场景。
假设我有一个报表接口,需要同时从三个下游服务拉数据:用户基本信息、订单列表、库存快照。串行调用的话,总耗时是三个接口耗时的总和。并发调用的话,总耗时接近最慢的一个接口。
传统写法用CompletableFuture配合线程池:
@Service
public class DashboardService {
private final UserServiceClient userClient;
private final OrderServiceClient orderClient;
private final InventoryServiceClient inventoryClient;
private final Executor executor = Executors.newFixedThreadPool(10);
public DashboardData getDashboard(Long userId) {
CompletableFuture<UserInfo> userFuture =
CompletableFuture.supplyAsync(() -> userClient.getUser(userId), executor);
CompletableFuture<List<Order>> orderFuture =
CompletableFuture.supplyAsync(() -> orderClient.listOrders(userId), executor);
CompletableFuture<Inventory> inventoryFuture =
CompletableFuture.supplyAsync(() -> inventoryClient.getInventory(userId), executor);
DashboardData data = new DashboardData();
data.setUser(userFuture.join());
data.setOrderList(orderFuture.join());
data.setInventory(inventoryFuture.join());
return data;
}
}
换成虚拟线程之后,改一行Executor声明就行:
private final Executor executor = Executors.newVirtualThreadPerTaskExecutor();
这段代码的好处是:配合Spring Boot 3.2的虚拟线程自动配置,你的Tomcat线程池本身就是虚拟线程,而这段业务代码里的CompletableFuture里也用了虚拟线程,整个请求链路没有任何阻塞耗费在线程复用上的资源。很舒服。
当然你可能会问,既然Spring框架里已经是虚拟线程了,为什么CompletableFuture还要单独配?因为在这段代码里,CompletableFuture默认使用ForkJoinPool.commonPool(),如果不指定Executor,它不会自动使用虚拟线程。所以需要显式传入一个虚拟线程执行器。
踩坑记录:虚拟线程不是万能的
我在实际项目中用虚拟线程也踩了不少坑。这里把最值得说的4个坑列出来。
坑1:CPU密集型任务别凑热闹
虚拟线程強在前,在IO阻塞的时候自动挂起,来提升并发度。但如果你的任务是纯CPU计算,比如图像处理、复杂加密、大列表排序,那就算了。因为虚拟线程调度本身也有成本,CPU核数就那么多,虚拟线程也得排队执行,而且频繁切换没有意义。
这类任务老老实实用一个固定大小等于CPU核数的线程池就完了。
坑2:synchronized在JDK 21里会固定载体线程
这是我最无语的一个点。在JDK 21的虚拟线程中,如果遇到synchronized块竞争锁,虚拟线程不会正常卸载,而是会把载体线程整个阻塞住。这意味着其他虚拟线程也跑不了。
解决方式是改用ReentrantLock,或者在JDK 22及以上版本中运行。JDK 22对synchronized和虚拟线程的适配已经做了优化。所以生产环境如果必须用虚拟线程,我建议直接用JDK 21还是JDK 22,或者升级到JDK 23。
坑3:ThreadLocal别乱用
虚拟线程支持ThreadLocal,但虚拟线程创建成本极低,意味着可以创建海量虚拟线程。如果每个虚拟线程都往ThreadLocal里塞大对象(比如用户会话、数据库连接),那内存压力会非常大。
建议用ScopedValue。当然ScopedValue还是Preview Preview,用在生产环境要谨慎。
坑4:Spring Boot版本必须对齐
Spring Boot 3.0、3.1根本不支持spring.threads.virtual.enabled这个配置。只有3.2及以上版本才支持。而且你最好用的是Spring Boot 3.2.5以上版本,早期3.2版本中虚拟线程和Tomcat的配合有Bug,比如连接超时失效的问题。
如果你还在用Spring Boot 2.x,那基本上不用考虑虚拟线程了。先升级到Spring Boot 3.x再说。
总结
虚拟线程是Java并发编程的一次关键升级。它最大的价值是,让普通Java开发者用同步代码,就能写出现在异步编程才能达到的并发性能。说白了就是,把并发编程的门槛降下来了。
我非常推荐你在新的后端项目里尝试一下虚拟线程。如果项目已经用了Spring Boot 3.2以上版本,那么加一行配置就能启用。先跑一个不那么重要的业务接口做下压测,感受一下效果,再慢慢推广。
反正自从我把手头项目的核心服务切到虚拟线程之后,我就再也不想回去写CompletableFuture链式调用了。
这篇文章是我实际使用虚拟线程的一个汇总。哪里写得不对,或者你有踩到别的坑,欢迎留言一起讨论。

