去年Java 21发布的时候,虚拟线程终于作为正式特性落地。这个在Project Loom孵化多年的东西,几乎重塑了Java处理并发的方式。简单说,它让“一个请求一个线程”这种传统模型在极高并发下重新变得可行,不需要再去折腾回调、响应式那一套。
我最近把公司一个内部API网关的线程池替换成了虚拟线程,实测下来,在10万并发连接下内存占用从原来的3.8GB降到了不到600MB,而且代码逻辑完全没有改动。这篇文章会把整个替换过程的经验整理出来,从最底层的创建方式聊到结构化并发,最后给一个可以直接跑的HTTP服务器例子,让你对虚拟线程有一个落到实处的认识。
虚拟线程到底解决了什么老问题
Java的线程从第一天起就是和操作系统线程一对一绑定的。一个Thread对象背后就是一个OS线程,OS线程很重——创建成本高、上下文切换开销大,更关键的是每个线程默认会预留1MB左右的栈空间,几万个线程就能把内存吃干。所以很多年以前,大家处理高并发就只能走线程池+异步这条路,用少量线程去服务大量请求,但代价是代码被割裂成一个个回调,调试和可读性都很难受。
虚拟线程的思路很直接:把Java线程和OS线程解耦。虚拟线程由JVM管理,底层共享一小组真正的OS线程(也叫载体线程)。当虚拟线程执行到阻塞操作(比如网络IO、数据库查询)时,JVM会自动把它从载体线程上卸下来挂起,载体线程瞬间被释放去执行别的虚拟线程。等阻塞操作完成,虚拟线程再被调度回任意一个可用的载体线程上继续跑。这个过程对开发者完全透明,写代码时你看到的仍然是那条从头到尾顺序执行的线程。
用一个不太精确但很好理解的比喻:平台线程像那种只能容纳一个人的办公室,人在里面干活,离开办公室就占着位置不动;虚拟线程则像弹力球,可以在不同办公室之间弹来弹去,办公室数量很少,但弹力球可以有好几万个,而且每个球都觉得自己一直在某个办公室里连续工作。
创建虚拟线程的几种写法
虚拟线程的API设计得极其简单,没有引入新的类型。创建方式有两种:通过Thread.ofVirtual()构建,或者用Executors.newVirtualThreadPerTaskExecutor()拿到一个执行器。后者更常用,因为可以直接替代现有的线程池。
最简单的起步代码长这样:
// 方式一:直接创建并启动
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("跑在虚拟线程里: " + Thread.currentThread());
});
vThread.join();
// 方式二:先创建,再启动
Thread unstarted = Thread.ofVirtual().unstarted(() -> {
// 任务体
});
unstarted.start();
更实用的场景是用ExecutorService来管理:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 提交一万个任务
for (int i = 0; i < 10_000; i++) {
int taskId = i;
executor.submit(() -> {
// 模拟IO耗时
Thread.sleep(Duration.ofMillis(100));
return "task-" + taskId + " finished";
});
}
// executor关闭时会等待所有虚拟线程结束
}
注意这里的try-with-resources。这个executor的close方法会阻塞直到所有提交的任务都执行完毕,非常贴心,省的自己写一堆计数逻辑。如果不用这种写法,也可以手动调用shutdown和awaitTermination,但close直接搞定。
和传统线程池硬碰硬:一个对比试验
光说概念有点虚,直接跑个测试看效果。场景很简单:模拟1万个任务,每个任务睡眠50毫秒假装在等数据库响应,分别用固定线程池(200个线程)和虚拟线程执行,比较总耗时和内存占用。
public class ThroughputComparison {
private static final int TASK_COUNT = 10_000;
private static final int SLEEP_MS = 50;
public static void main(String[] args) throws Exception {
// 记录初始内存(粗略)
System.gc();
long memStart = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
// 平台线程池版本
long start = System.currentTimeMillis();
try (var pool = Executors.newFixedThreadPool(200)) {
var futures = new ArrayList<Future<String>>();
for (int i = 0; i < TASK_COUNT; i++) {
int id = i;
futures.add(pool.submit(() -> {
Thread.sleep(SLEEP_MS);
return "done";
}));
}
for (var f : futures) f.get();
}
long platformTime = System.currentTimeMillis() - start;
System.gc();
long platformMem = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) - memStart;
// 虚拟线程版本
memStart = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new ArrayList<Future<String>>();
for (int i = 0; i < TASK_COUNT; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(SLEEP_MS);
return "done";
}));
}
for (var f : futures) f.get();
}
long virtualTime = System.currentTimeMillis() - start;
System.gc();
long virtualMem = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) - memStart;
System.out.printf("平台线程池耗时: %d ms, 内存增量: %d 字节%n", platformTime, platformMem);
System.out.printf("虚拟线程耗时: %d ms, 内存增量: %d 字节%n", virtualTime, virtualMem);
}
}
在我的机器上跑了几次,结果稳定在平台线程池耗时约2.6秒,虚拟线程耗时约0.15秒,内存增量前者接近90MB,后者不到6MB。差距主要来自两点:平台线程池只有200个线程,1万个任务要排队等前面的人释放线程;而虚拟线程为每个任务分配一个虚拟线程,1万个虚拟线程同时睡眠,几乎瞬时全部挂起,等50毫秒结束后又重新调度,并发度拉满。内存方面更夸张,虚拟线程不预占栈,每个只占用几百字节的元数据,1万个虚拟线程的内存开销比200个平台线程还少。
再看下创建线程的开销对比。单独测创建10万个线程(不执行任务):
// 平台线程——直接OOM,别试
// for (int i=0; i<100_000; i++) new Thread(()->{}).start();
// 虚拟线程——轻松跑完
long start = System.currentTimeMillis();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 100_000; i++) {
threads.add(Thread.ofVirtual().unstarted(() -> {}));
}
threads.forEach(Thread::start);
for (var t : threads) t.join();
System.out.println("10万虚拟线程创建耗时: " + (System.currentTimeMillis() - start) + " ms");
// 输出在200ms左右
10万个虚拟线程在200毫秒内创建并启动完毕,这个数字在以前是不可想象的。当然,这不是说可以无脑创建几百万个虚拟线程——JVM内部对虚拟线程的调度还是有一些开销的,只不过这个上限比平台线程高出了几个数量级。
虚拟线程在阻塞操作上的真正价值
虚拟线程最大的优势体现在阻塞操作上,尤其是网络IO和数据库调用。如果任务纯粹是CPU密集型的循环计算,虚拟线程并不会带来性能提升,反而可能因为频繁的挂起调度增加开销。但现实中的服务端应用,90%以上的请求处理时间都耗在了等待上——等数据库返回结果、等下游HTTP响应、等MQ消息。这时候虚拟线程能把那些原本被阻塞占用的载体线程释放出来,让少量的OS线程支撑起海量并发。
一个很容易验证的现象是,把上面的测试中的Thread.sleep替换成真正的网络调用,比如调用一个模拟慢速的HTTP接口,效果会一模一样。这是因为Thread.sleep、Socket.read、LockSupport.park这些操作在虚拟线程里都被JVM拦截了,自动触发挂起。
不过有个容易被忽略的坑:如果阻塞操作发生在synchronized块或者JNI本地方法里,虚拟线程没办法自动卸下,它会一直霸占着载体线程直到执行完毕。官方文档建议把synchronized替换成ReentrantLock,后者能被JVM识别并挂起。这一点在迁移遗留代码时需要特别注意,否则虚拟线程的优势完全发挥不出来。
结构化并发:让虚拟线程的父子关系更清晰
虚拟线程刚出来的时候,很多人拿它直接executor.submit一堆任务然后等着,这在功能上没问题,但缺少对任务生命周期的统一管理。比如某个子任务失败了,其他子任务还在傻跑;或者你想取消整个任务树,却没有方便的入口。
Java 21同时引入了结构化并发的预览特性,正好补上这块。核心类是StructuredTaskScope,它把一组相关的虚拟线程约束在一个作用域里,作用域会等待所有子任务完成(或失败)再退出。
// 引入预览功能需要编译参数 --enable-preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> userFuture = scope.fork(() -> fetchUser(userId));
Future<List<Order>> ordersFuture = scope.fork(() -> fetchOrders(userId));
scope.join(); // 等待所有fork完成
scope.throwIfFailed(); // 任一子任务抛异常则这里抛出
String user = userFuture.resultNow();
List<Order> orders = ordersFuture.resultNow();
return new UserOrders(user, orders);
}
// 离开try块时,尚未完成的子任务会被自动取消
这个模式比分别提交任务然后用CompletableFuture组合要直白得多,而且天然支持错误处理和取消传播。不过截至Java 21它仍然是预览特性,生产使用需要掂量一下。好消息是它在JDK 23里已经转正,新的LTS版本(Java 25预计)大概率会包含它。
一个完整的案例:用虚拟线程构建HTTP服务器
聊了这么多原理,最后落在一个能实际运行的例子上。用Java自带的com.sun.net.httpserver.HttpServer搭一个HTTP服务,每个请求交给虚拟线程处理,模拟真正的业务延迟。
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpExchange;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
import java.nio.charset.StandardCharsets;
public class VirtualThreadHttpServer {
public static void main(String[] args) throws Exception {
int port = 8080;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
// 关键:用虚拟线程执行器替换默认的执行器
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
// 注册一个路由:模拟从数据库查数据
server.createContext("/api/user", exchange -> {
// 模拟耗时操作,比如查数据库
try {
Thread.sleep(80);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
String json = "{"id":1, "name":"李四", "age":30}";
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, json.getBytes(StandardCharsets.UTF_8).length);
try (var os = exchange.getResponseBody()) {
os.write(json.getBytes(StandardCharsets.UTF_8));
}
});
// 再加一个简单的echo端点
server.createContext("/echo", exchange -> {
String method = exchange.getRequestMethod();
String response = "Echo: " + method + " " + exchange.getRequestURI();
exchange.sendResponseHeaders(200, response.getBytes(StandardCharsets.UTF_8).length);
try (var os = exchange.getResponseBody()) {
os.write(response.getBytes(StandardCharsets.UTF_8));
}
});
server.start();
System.out.println("虚拟线程HTTP服务器启动,端口: " + port);
// 按任意键停止
System.in.read();
server.stop(1);
}
}
这段代码里最核心的一行是server.setExecutor(Executors.newVirtualThreadPerTaskExecutor())。它把HttpServer默认的线程池(是个固定大小的工作队列线程池)替换成了虚拟线程执行器。现在每个HTTP请求都会生成一个新的虚拟线程来处理,请求结束虚拟线程就被回收。服务端代码依然是同步阻塞风格,但支撑高并发的能力已经完全不同。
用wrk或ab简单测一下:50个并发连接、持续10秒,压测/api/user,在我这台普通笔记本上QPS稳定在5000以上,内存使用几乎没涨。换成默认线程池的话,队列溢出导致连接被拒是早晚的事。
如果你想在Spring Boot 3.2以上版本里启用虚拟线程,更简单,只需要在配置文件里加一行:
// application.properties
spring.threads.virtual.enabled=true
Spring会在Tomcat或Undertow的请求处理线程、异步任务执行器等位置自动替换为虚拟线程。不过Spring的定时任务调度器目前还没有切换到虚拟线程,那个场景还是平台线程更合适,因为需要精确的时间触发,虚拟线程的调度不太适合做定时器。
踩过的三个坑
实际项目中从传统线程迁移到虚拟线程,我碰到了几个典型问题,这里一并列出来。
第一,ThreadLocal的使用。 虚拟线程数量巨大且生命周期短,如果仍然像以前那样往ThreadLocal里塞大对象,内存会迅速膨胀。官方建议改用ScopedValue(同样是预览特性,用来替代ThreadLocal的不可变作用域值),它把值绑定到调用链上而不是线程上,更适合虚拟线程的生命周期模型。如果暂时不能迁移到ScopedValue,至少要做到及时清理。
第二,线程池队列的问题。 有些人习惯把虚拟线程执行器和有界队列搭配使用,认为可以控制并发度,这其实是误解。虚拟线程执行器本身没有任何队列,每个提交的任务都会立即分配一个虚拟线程。如果确实需要限制并发数,应该在业务层面引入信号量,或者用Semaphore控制同时正在执行的任务数量。
第三,CPU密集任务不要用虚拟线程。 如果你的代码里有一段非常耗CPU的循环(比如图像处理、加密解密),用虚拟线程不仅不会快,还会因为载体线程频繁被打断而降低吞吐量。这种情况下还是应该用固定大小的线程池,让CPU核数来决定并行度。
什么时候该用虚拟线程
这个问题其实很好回答:只要你现在的代码里出现了“为了不阻塞线程而不得不写成异步回调”的情况,虚拟线程就是个几乎零成本的替代方案。它的设计目标就是让你用同步代码写出异步的性能。如果你的应用本身就是纯CPU计算,那虚拟线程帮不上忙;如果大部分时间花在等待IO上,那虚拟线程带来的收益是立竿见影的。
还有一点需要正视——虚拟线程不是银弹,它解决的是并发模型的简化问题,而不是把一台机器的性能凭空翻几倍。但它确实让“一个请求一个线程”这种最自然的编程模型重新回到了高并发场景的候选列表里,而且这次不会再被内存和上下文切换卡脖子。对于大多数业务开发者来说,这比学会一套又一套异步编程模型要划算得多。
从JDK 21起,虚拟线程已经可以放心用在生产环境了。如果你的项目还在跑JDK 17,升级到21几乎不会在语言层面踩坑,但能立刻用上虚拟线程,特别是Spring Boot 3.2以上的自动适配,迁移成本趋近于零。花一个下午把线程池换成虚拟线程,可能比花几周重构异步逻辑更有产出。

