先说结论:Virtual Thread 到底改变了什么?
先把重点摊开说清楚:
1)它不是"新一代 ThreadPoolExecutor" ,而是把原来那个沉重的 Thread 拆成了两层:
-
底层少量 平台线程(OS 线程);
-
上面跑着大量 虚拟线程(Virtual Thread)。
2)对你来说,最大变化只有一句话:
你可以继续写"看起来是阻塞"的同步代码,但 JVM 会帮你用"协程式"的方式顶住高并发。
换句话说:
1)以前我们都知道:
"每请求一线程"写起来爽,但线程太重,撑不了多少并发。
2)有了虚拟线程之后,JVM 的态度变成了:
"你就放心每请求一线程吧,我把线程变轻一点。"
这会带来几个非常现实的好处:
1)写法不变 / 思维不变
- Controller / Service 里继续用同步风格:
userService.findUser(id)、orderService.listOrders(id),没有Mono、CompletableFuture满天飞。
2)并发能力上一个数量级
-
原来你不敢把线程池开到几千、几万;
-
现在用虚拟线程,几万、几十万"每请求一线程"都可以认真考虑。
3)大部分普通 Web / RPC 服务,没那么刚需 WebFlux/Netty 了
- 不是说 WebFlux/Netty 没用了,而是:
你终于可以用"最简单的写法"解决绝大多数高并发场景。
某位网友说过一句有点夸张又有点道理的话:
"一个 socket 对应一个虚拟线程就是最佳方案。"
这句话"最佳"二字有点吹过头,但有一点是对的:
在今天的 Java 里,"一个连接/请求一个虚拟线程"确实很可能是新的合理默认。
1. 回到老问题:为什么以前"每请求一线程"顶不住?
传统 Spring MVC / Tomcat 的模型,大概是这样:
-
客户端发来一个 HTTP 请求;
-
Tomcat 从线程池里拿出一个 平台线程(OS 线程) 交给这个请求;
-
你的 Controller 里写一堆同步代码,里面各种阻塞调用:
@GetMapping("/user/{id}")
public UserProfile getUser(@PathVariable String id) {
// 阻塞式 HTTP 调用
User user = restTemplate.getForObject("http://user-service/" + id, User.class);
// 阻塞式数据库调用
List<Order> orders = jdbcTemplate.query("SELECT * FROM orders WHERE user_id = ?", rs -> {
// ...
}, id);
return new UserProfile(user, orders);
}
看起来很优雅,对吧?
问题是:这个请求在等待下游服务的时候,那个 OS 线程也在那儿干等。
一个线程默认栈空间几 MB;线程多了以后:
-
内存被吃光;
-
上下文切换越来越频繁,CPU 时间都浪费在切线程上。
所以才会有这些"补救方案":
1)调大 Tomcat 线程池:顶一阵,但迟早顶不住;
2)把阻塞调用改成异步:
-
Callable/DeferredResult/CompletableFuture/ WebFlux; -
代码复杂度和心智负担一起上来。
抽象成一句话:
以前的"每请求一线程"模式,在高并发时代最大的问题就是:
线程太贵,阻塞太浪费。
于是大家才开始写:
-
sendAsync(...); -
thenApply(...)/thenCompose(...); -
Mono.zip(...)/flatMap(...)。
能顶住并发没错,但可读性、可维护性确实打折扣。
2. Virtual Thread 是什么?和普通 Thread 有啥不一样?
先用一句话把定义讲清楚:
平台线程(Platform Thread) ≈ OS 线程
虚拟线程(Virtual Thread) = JVM 自己调度的"用户态线程"
2.1 关键差异:谁来调度、谁在"阻塞"
传统 Thread:
一个 Thread 就是一个 OS 线程;
当你在里面调 socket.read()、jdbc、HttpClient.send():
-
线程卡在内核里等待 I/O;
-
整个 OS 线程被占住,啥也干不了。
虚拟线程:
虚拟线程自己并不直接对应 OS 线程;
它们是跑在少量 carrier threads(承载线程) 上的一堆"轻量任务";
当虚拟线程遇到支持 Loom 的阻塞 I/O 时,JVM 会做两件事:
-
把这个虚拟线程的调用栈保存到堆里(挂起 / park);
-
让底层那个 OS 线程去执行其他虚拟线程;
-
等 I/O 好了,再把虚拟线程"抬回来"继续跑。
所以:
-
从你代码的角度看,它仍然是"阻塞"调用;
-
从 OS 的角度看,它更像是"协程挂起 + 任务切换"。
2.2 写法有多简单?看一眼代码就懂
传统线程池写法:
ExecutorService executor = Executors.newFixedThreadPool(200);
for (String userId : userIds) {
executor.submit(() -> {
handleUser(userId); // 里面各种阻塞 I/O
});
}
虚拟线程写法:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String userId : userIds) {
executor.submit(() -> {
handleUser(userId); // 同样是阻塞 I/O,但跑在虚拟线程上
});
}
}
注意:
1)handleUser 这个方法里的业务逻辑 完全不用改,还是同步阻塞;
2)唯一的变化,是"执行载体":
-
以前:
newFixedThreadPool(200)→ 你得控制线程数,怕撑爆; -
现在:
newVirtualThreadPerTaskExecutor()→ 几乎就是"每任务一线程"。
再说直白一点:
原来你为了高并发,
要么写复杂的异步 / 响应式代码,
要么死磕线程池参数。
现在换成虚拟线程后,
你可以先回到"最简单的同步写法",
然后把并发调度交给 JVM。
3. 一眼看懂的三段代码:为什么说 Virtual Thread "省心"?
我们用一个 具体小场景,用三段代码对比一下虚拟线程和传统写法的差别。
场景:
有一批
userId,对每个用户要做三件事:1)HTTP 请求拉用户基本信息
2)查数据库拿订单列表
3)拼成一个
UserProfile返回目标:让这些用户并发处理,而不是一个一个顺序跑。
先写几个简单的公共类(方便后面的三段代码复用):
record User(String id, String name) {}
record Order(String id, String userId, int amount) {}
record UserProfile(User user, List<Order> orders) {}
class UserRepository {
List<Order> findByUserId(String userId) {
// 模拟阻塞的 DB 调用
sleep(50);
return List.of(
new Order("o1", userId, 100),
new Order("o2", userId, 200)
);
}
private void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
HTTP 客户端我们用 JDK 自带同步版:
HttpClient httpClient = HttpClient.newHttpClient();
User fetchUserBlocking(String userId) throws Exception {
HttpRequest req = HttpRequest.newBuilder(
URI.create("https://example.com/users/" + userId)
).build();
HttpResponse<String> res = httpClient.send(
req, HttpResponse.BodyHandlers.ofString()
);
// Demo:假装 body 就是用户名
return new User(userId, res.body());
}
下面三种写法,业务逻辑完全一样,只是并发模型不同。
3.1 固定线程池 + 阻塞 I/O:简单,但扩不动
这是大家最熟悉的那种:
ExecutorService threadPool = Executors.newFixedThreadPool(200);
UserRepository repo = new UserRepository();
UserProfile handleUser(String userId) throws Exception {
// 阻塞 HTTP
User user = fetchUserBlocking(userId);
// 阻塞 DB
List<Order> orders = repo.findByUserId(userId);
return new UserProfile(user, orders);
}
List<UserProfile> processUsersWithPlatformThreads(List<String> userIds) throws Exception {
List<Future<UserProfile>> futures = new ArrayList<>();
for (String id : userIds) {
futures.add(threadPool.submit(() -> handleUser(id)));
}
List<UserProfile> result = new ArrayList<>();
for (Future<UserProfile> f : futures) {
result.add(f.get()); // 阻塞等待
}
return result;
}
特点:
写法超级直观:handleUser 就是一行一行的同步代码。
线程池大小必须非常小心:
-
并发量一大,你要么把线程池开很大,内存和上下文切换扛不住;
-
要么线程池开小一点,后面的请求都在排队。
这就是"每请求一线程"在高并发时代顶不住的原因。
3.2 CompletableFuture 铺满全场:性能好一点,但"思维变形"
为了少开线程、提高并发,大家会改成"异步式写法":
CompletableFuture<User> fetchUserAsync(String userId) {
HttpRequest req = HttpRequest.newBuilder(
URI.create("https://example.com/users/" + userId)
).build();
return httpClient
.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(res -> new User(userId, res.body()));
}
ExecutorService dbExecutor = Executors.newFixedThreadPool(50);
UserRepository repo = new UserRepository();
CompletableFuture<List<Order>> fetchOrdersAsync(String userId) {
return CompletableFuture.supplyAsync(
() -> repo.findByUserId(userId),
dbExecutor
);
}
CompletableFuture<UserProfile> handleUserAsync(String userId) {
return fetchUserAsync(userId)
.thenCombine(
fetchOrdersAsync(userId),
(user, orders) -> new UserProfile(user, orders)
);
}
List<UserProfile> processUsersWithCompletableFuture(List<String> userIds) {
List<CompletableFuture<UserProfile>> futures = userIds.stream()
.map(this::handleUserAsync)
.toList();
CompletableFuture<Void> all =
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
return all.thenApply(v ->
futures.stream()
.map(CompletableFuture::join)
.toList()
).join();
}
特点:
不再是"一个请求占一个 OS 线程":
-
HTTP 使用真正的异步 I/O(
sendAsync); -
DB 查询放在有限的线程池里跑。
代价是:整个世界变成了 CompletableFuture<T>:
-
业务函数签名从
UserProfile变成了CompletableFuture<UserProfile>; -
一堆
thenApply / thenCombine / allOf; -
调试异常、添加超时/重试都变得更绕。
一句话:性能更好一点,但"逻辑变成了 Future 流水线"。
3.3 虚拟线程 + 阻塞写法:写回最简单,性能又上去了
现在换成虚拟线程:
核心思想只一句话:
"我们故意回到最朴素的'阻塞 + 每请求一线程',
但这个线程不再是 OS 线程,而是虚拟线程。"
UserRepository repo = new UserRepository();
HttpClient httpClient = HttpClient.newHttpClient();
// 业务逻辑:完全是同步写法
UserProfile handleUserBlocking(String userId) throws Exception {
User user = fetchUserBlocking(userId); // 阻塞 HTTP
List<Order> orders = repo.findByUserId(userId); // 阻塞 DB
return new UserProfile(user, orders);
}
// 用虚拟线程并发处理所有 userId
List<UserProfile> processUsersWithVirtualThreads(List<String> userIds) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<UserProfile>> futures = new ArrayList<>();
for (String id : userIds) {
// 每个任务一个虚拟线程
futures.add(executor.submit(() -> handleUserBlocking(id)));
}
List<UserProfile> result = new ArrayList<>();
for (Future<UserProfile> f : futures) {
result.add(f.get());
}
return result;
}
}
你会发现几件事:
1)业务代码几乎就是 3.1 版本的复制粘贴
-
handleUserBlocking的写法,和最传统的阻塞版本一模一样; -
没有
CompletableFuture/Mono/flatMap之类的心智负担。
2)而执行器这一行,彻底改变了并发模型
Executors.newVirtualThreadPerTaskExecutor()
-
以前:
newFixedThreadPool(200)→ 你必须控制线程数,怕炸; -
现在:几乎就是"每任务一个虚拟线程",虚拟线程由 JVM 以协程方式调度;
-
当
fetchUserBlocking在等网络、findByUserId在等 DB 时,这个虚拟线程会被挂起,不占用 OS 线程。
3)调试体验回到同步世界
-
栈跟同步代码一样完整;
-
try / catch / finally照常工作; -
不用在脑子里"模拟 Future 链"。
小结:为什么说 Virtual Thread "省心"?
把这三段代码放一起看,你会发现一个很有趣的结论:
3.1 固定线程池 + 阻塞:
- 写起来最顺手,但高并发扛不住。
3.2 CompletableFuture + 异步:
- 能扛住高并发,但代码变成"回调/Future 流水线"。
3.3 虚拟线程 + 阻塞写法:
- 写法和 3.1 差不多简单, 而扩展能力更接近 3.2。
这就是虚拟线程真正"省心"的地方:
它没有发明一套新语法,
只是让你可以 继续用同步思维写高并发,
把"怎么调度、怎么挂起、怎么复用 OS 线程"这堆麻烦事,
丢给 JVM 去做了。
4. 那 WebFlux / Netty 还香吗?本质差异到底在哪?
讲到这里,另一个经典问题就出来了:
"那 WebFlux / Netty 这套 NIO 非阻塞栈,还香吗?
Virtual Thread 上来,是要把它们淘汰吗?"
答案很简单:不会被淘汰,但"默认位置"在变化。
4.1 高层对比:两个世界的"并发心智模型"完全不同
Virtual Thread + Spring MVC 的世界:
模型:每请求 / 每连接一个虚拟线程;
1)写法:同步阻塞风格,Controller / Service 基本不用改;
2)遇到 I/O 阻塞时:
-
虚拟线程挂起,
-
承载它的 OS 线程去执行别的虚拟线程;
3)调度者:JVM。
你脑子里的画面:
"一堆轻量线程在跑,写代码就像以前那样写同步逻辑,JVM 在后台帮你把阻塞变挂起。"
WebFlux / Netty 的世界:
1)模型:少量事件循环线程 + 非阻塞 I/O;
2)写法:一切变成 Mono / Flux / 回调 / Handler;
3)I/O 准备好后,通过回调 / operator 推动数据流继续往前;
4)调度者:框架 + 事件循环。
你脑子里的画面:
"几个事件循环线程盯着 Selector(epoll/kqueue),哪个 socket 有事件,就调用对应 Handler/Subscriber。"
用一句话概括本质差异:
Virtual Thread:"阻塞是假的,JVM 会帮你挂起虚拟线程"
WebFlux/Netty:"不准阻塞,一切都要变成事件/流"
4.2 从"你写代码"的角度看,还有两个实质差别
1)代码风格:同步 vs 响应式
Virtual Thread:
-
Controller:返回普通对象;
-
Service:写同步方法,
try/catch即可。
WebFlux:
-
Controller:返回
Mono<T>/Flux<T>; -
Service:到处是
map/flatMap/zip; -
异常、超时、重试都变成流上的 operator。
2)生态适配成本
Virtual Thread:
- 大量历史库本身就是阻塞式(JDBC、MyBatis、很多第三方 SDK),用起来天然契合;
WebFlux:
- 想真正"全响应式",要用 R2DBC、响应式 Redis/Kafka/...,改造成本不小。
4.3 "Selector 没用了 / 一个 socket 一个虚拟线程是最佳方案吗?"
这里顺带澄清两个常见误解:
1)"Selector 没用了"是错的
-
对你来说:是的,你不再需要手写 Selector/NIO/epoll;
-
但对 JVM 实现来说:底层还是得用 epoll/kqueue/Selector 这类机制,只是封装在虚拟线程调度里了。
2)"一个 socket 一个虚拟线程 = 最佳方案"是过于绝对
-
对大部分普通 Web / RPC / 后台服务,"一个连接 / 请求一个虚拟线程"确实很合理;
-
但极端高频、极致延迟、细粒度优化的场景(自研网关、高频交易、海量长连接推送)里,事件驱动 + Netty 依然是王道。
5. 实战选型建议:什么场景用 Virtual Thread,什么场景继续用 WebFlux/Netty?
直接给"拍板"式建议,方便你在实际项目里选。
5.1 普通业务后端(CRUD + 多个下游 HTTP/DB 调用)
特征:
-
Spring Boot + MVC 为主;
-
大量数据库访问、缓存、HTTP 调用;
-
团队多数人更熟悉同步风格。
推荐:Spring MVC + 虚拟线程(优先考虑)
-
改造成本极低: 原来的 Controller / Service 基本不动,只是换一个执行器。
-
调试、排错、带新人都简单。
-
高并发时,虚拟线程可以帮你撑住"每请求一线程"的模式。
一句话:
这类场景基本可以把"Virtual Thread + MVC"当成 新默认。
5.2 已经在跑 WebFlux / 响应式全家桶的项目
特征:
-
Controller 已经是
Mono/Flux; -
用的是 WebClient、R2DBC、响应式 Redis / Kafka 等;
-
团队已经习惯 Reactor 响应式思维。
推荐:继续 WebFlux,不要为了"追新"硬切回 MVC
-
你已经为响应式模型付出了学习成本和改造成本;
-
在流式处理、背压控制方面,响应式模型本来就很合适;
-
可以在非 HTTP 请求路径(比如任务调度、内部计算)引入虚拟线程,不冲突。
5.3 极端性能敏感 / 自研协议 / 高频系统
特征:
-
追求 p99 / p999 延迟;
-
对 buffer、零拷贝、内存布局、线程模型有极致追求;
-
很多时候都不是 HTTP,而是自定义协议。
推荐:Netty / 手写 NIO / 事件驱动仍然是首选
-
在这类场景里,你就是要 100% 掌控线程和 I/O 模型;
-
虚拟线程虽然很强,但多一层抽象就少一点"死抠性能"的空间;
-
Loom 可以在内部某些子任务上加持,但不会替代底层 I/O 的精细控制。
5.4 大量历史阻塞库 / 无法一步切到响应式
特征:
-
JDBC / MyBatis / Hibernate 为主;
-
各种第三方 SDK 只有阻塞 API(云厂商、支付、IM 等)。
推荐:虚拟线程几乎是"天选解"
WebFlux 世界里,这些阻塞库都得丢到独立线程池,很容易污染事件循环;
虚拟线程完全不介意你阻塞:
-
你就安心地
xxxClient.call(); -
JVM 会在阻塞点把虚拟线程挂起,并复用 OS 线程。
6. 结尾:Virtual Thread 不是"银弹",但值得成为你的新默认选项
最后用几句话收个尾:
1)Virtual Thread 不是魔法,也不是银弹
-
它不会让你的 SQL 自动变快,也不会消灭网络延迟;
-
它解决的是一个非常具体的问题:
"Java 里同步阻塞风格的代码,怎么在高并发时代继续活下去?"
2)它让"最简单的写法"重新变得可扩展
-
以前:每请求一线程很爽,但只能做小并发;
-
现在:有了虚拟线程,"每请求一虚拟线程"可以认真当成设计选项。
3)它改变的是"默认姿势",不是要消灭别的栈
-
WebFlux / Netty 依然有用,尤其是在响应式流处理、极端性能场景;
-
但对绝大多数普通 Java 后端开发者来说,你可以先问自己一句:
"这件事,我能不能先用 Virtual Thread + 简单同步写法搞定?"
如果你正在做一个新项目,或者维护一个典型的 Spring Boot 服务,非常建议你做这么一件小事:
1)搭一个最小 Demo:
-
Spring Boot 3.x + Spring MVC;
-
配上虚拟线程执行器;
-
写几个"会慢一点"的 HTTP / DB 调用;
2)再用一个简单压测工具(wrk、ab、JMeter)跑一跑, 体会一下"线程数几乎不用担心、代码又保持同步风格"的感觉。
当你真的亲手跑过一次之后,
你会发现:
Java 并发这件事,确实有点"时代变了"。
不再是"要性能就得写一堆回调",
而是 ------ "先写最简单的同步代码,再交给虚拟线程和 JVM 去撑高并发"。