Java 21 虚拟线程实战——它真的能替代 WebFlux 吗?我的判断
Java 21 虚拟线程实战——它真的能替代 WebFlux 吗?我的判断
适读人群:关注 Java 新特性、正在做技术选型的工程师 | 阅读时长:约15分钟 | 核心价值:用真实测试数据回答这个高频问题,给出有依据的判断
Java 21 GA 发布之后,我的微信里收到的问题里,被问得最多的一个是:
"老张,虚拟线程出来了,WebFlux 还有用吗?"
这个问题我答了很多次,每次都想写一篇完整的分析,但一直没时间。这次把我的测试数据和判断完整写出来。
结论先说:不能完全替代,但在某些场景下,虚拟线程确实让你可以不用 WebFlux 了。 具体要看场景。
一、虚拟线程是什么,一句话版本
虚拟线程是 Java 21 正式引入的(JEP 444),它是由 JVM 管理的"轻量级线程",可以创建几百万个,内存占用极低,阻塞时不会占用 OS 线程。
和传统平台线程的对比:
| 维度 | 平台线程(OS线程) | 虚拟线程 |
|---|---|---|
| 内存占用 | ~1MB(默认栈) | ~几KB |
| 数量限制 | 几千(受OS限制) | 几百万 |
| 阻塞时行为 | 占用OS线程,等待 | 自动卸载,OS线程去做别的 |
| 调试体验 | 正常的线程栈 | 正常的线程栈(和平台线程一样!) |
| 代码改动 | - | 几乎不用改代码 |
"几乎不用改代码"是虚拟线程最大的优势。你原来用 Tomcat + JDBC 的 Spring MVC 代码,理论上只需要把 Tomcat 的线程池换成虚拟线程,就能获得接近响应式的并发能力,代码逻辑完全不用动。
二、Spring Boot + 虚拟线程,多简单
# application.yml
spring:
threads:
virtual:
enabled: true # 就这一行,Tomcat 线程池换成虚拟线程就是这一行配置,Spring Boot 3.2+ 支持。原来的 MVC 代码一行不用改。
如果你用的是早期版本,或者需要手动配置:
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutor() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
// 同步的数据库操作也可以跑在虚拟线程上(JDBC)
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
// 连接池大小不需要那么大了,因为虚拟线程等待连接时不占OS线程
config.setMaximumPoolSize(20); // 不需要设很大
// ... 其他配置
return new HikariDataSource(config);
}
}三、性能测试:虚拟线程 vs WebFlux vs 传统MVC
测试环境:同上篇(8核16G,JDK 21)。
三个方案:
- MVC Classic:Tomcat 200线程,JDBC,Spring MVC
- MVC Virtual:Tomcat 虚拟线程,JDBC,Spring MVC(代码完全相同)
- WebFlux:Netty,R2DBC,WebFlux
场景1:IO密集型(单表数据库查询,平均延迟8ms)
| 并发 | MVC Classic P99 | MVC Virtual P99 | WebFlux P99 |
|---|---|---|---|
| 100 | 31ms | 29ms | 27ms |
| 500 | 143ms | 47ms | 52ms |
| 1000 | 612ms(排队) | 73ms | 61ms |
| 2000 | 大量超时 | 132ms | 108ms |
| 5000 | 不可用 | 318ms | 247ms |
惊喜:虚拟线程和 WebFlux 在 IO 密集型场景下性能非常接近!
在5000并发的极端场景下,WebFlux P99 247ms 略优于虚拟线程的318ms,但已经非常接近了。更重要的是,虚拟线程版本的代码完全没有改,就是普通 Spring MVC 代码。
场景2:重IO(复杂查询+2个HTTP下游,平均约280ms)
| 并发 | MVC Classic P99 | MVC Virtual P99 | WebFlux P99 |
|---|---|---|---|
| 100 | 318ms | 294ms | 281ms |
| 500 | 1640ms(严重排队) | 347ms | 312ms |
| 1000 | 大量超时 | 589ms | 437ms |
| 2000 | 不可用 | 1128ms | 743ms |
这里差距出来了:高并发重IO场景下,WebFlux 比虚拟线程仍然有约30-40%的性能优势。
原因:虚拟线程虽然不阻塞OS线程,但等待期间仍然会有虚拟线程的调度开销;WebFlux 的事件驱动模型在极高并发下更"节省"。
场景3:纯计算(无IO)
| 并发 | MVC Classic P99 | MVC Virtual P99 | WebFlux P99 |
|---|---|---|---|
| 500 | 18ms | 19ms | 22ms |
| 2000 | 31ms | 28ms | 35ms |
纯计算场景:三者差异很小,WebFlux 略慢(操作符开销),虚拟线程和传统MVC接近。
四、内存占用对比
| 方案 | 空载内存 | 1000并发稳态内存 |
|---|---|---|
| MVC Classic(200线程) | ~280MB | ~420MB |
| MVC Virtual | ~260MB | ~310MB(虚拟线程本身很轻) |
| WebFlux | ~230MB | ~270MB |
WebFlux 内存最省,虚拟线程次之,MVC Classic 最多。差距不是特别大,但在大规模部署时有意义。
五、虚拟线程的局限性
虚拟线程不是万能的,有几个地方需要注意:
1. Pinning(固定)问题
当虚拟线程在执行 synchronized 块或 native 方法时,如果发生阻塞,OS 线程不会被释放,虚拟线程被"固定"在那根 OS 线程上。
// 这会导致 pinning,影响虚拟线程的并发效果
synchronized (this) {
jdbcTemplate.query("..."); // 阻塞发生在 synchronized 里,pinning!
}
// 改成 ReentrantLock 可以避免
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
jdbcTemplate.query("..."); // 不会 pinning
} finally {
lock.unlock();
}有些老库大量使用 synchronized,在虚拟线程下可能有 pinning 问题。JDK 21+ 可以用 -Djdk.tracePinnedThreads=full 检测。
2. 不适合 CPU 密集型任务
虚拟线程多,但 CPU 还是那几个。CPU 密集型场景,虚拟线程没有优势,用 ForkJoinPool 更合适。
3. 响应式流的背压和组合能力
WebFlux/Reactor 提供了丰富的流处理操作符:zip、flatMap(并发控制)、buffer、window、groupBy……这些是专门为数据流设计的,虚拟线程没有对应的替代。
在需要精细控制数据流的场景(Kafka 消费、SSE 推送、实时数据处理),WebFlux 的优势是虚拟线程替代不了的。
六、虚拟线程的最佳实践
既然决定用虚拟线程,有几个工程上的注意事项:
1. 连接池大小要重新评估
传统 MVC 下,线程池大小往往和连接池大小有对应关系(200线程 → 200连接)。用了虚拟线程之后,虚拟线程等待连接时不占 OS 线程,所以你不需要那么多连接了:
# 虚拟线程环境下,连接池不需要很大
spring:
datasource:
hikari:
maximum-pool-size: 20 # 从100-200降到20-30就够用了
minimum-idle: 5
connection-timeout: 3000连接数减少了,数据库服务器的压力也小了,这是个额外的收益。
2. 避免 ThreadLocal 的误用
虚拟线程也支持 ThreadLocal,但要注意:虚拟线程数量可能非常多(几万个),如果每个虚拟线程都有很多 ThreadLocal 数据,总内存占用可能超出预期。
Java 21 引入了 ScopedValue,是虚拟线程时代更好的"上下文传递"方案:
// ScopedValue:比 ThreadLocal 更适合虚拟线程
final static ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
// 设置
ScopedValue.where(USER_CONTEXT, userContext)
.run(() -> doBusinessLogic());
// 取值
UserContext ctx = USER_CONTEXT.get();ScopedValue 是不可变的,跟随当前执行范围,不会因为线程共享导致数据污染。
3. 监控虚拟线程的 pinning
# 启动参数:打印所有 pinning 事件
-Djdk.tracePinnedThreads=full
# 或者在代码里注册监听
VirtualThread.Builder builder = Thread.ofVirtual();
// 暂时没有官方 API 直接监控 pinning,只能通过 JFR 事件用 JFR(Java Flight Recorder)可以看到 jdk.VirtualThreadPinned 事件,找出哪些代码造成了 pinning。
七、我的实际判断
说实话,虚拟线程出来之后,我对 WebFlux 的态度发生了变化。
以前我会说:IO密集型高并发,必须考虑 WebFlux。
现在我的判断是:
如果你的系统是普通业务服务(CRUD、查询、接口聚合),并发量在几千以内——直接用虚拟线程,不要用 WebFlux。代码更简单,调试更容易,性能足够。
如果你的系统需要处理数据流(Kafka、SSE、WebSocket、批量数据处理)——WebFlux 更合适,流处理操作符是核心优势。
如果你的系统是极高并发且IO重(几万并发),需要极致性能——WebFlux 仍然有优势,但差距不像以前那么大了,需要实测决策。
如果你的团队已经在用 WebFlux,并且用得比较顺——继续用,没必要切,两者共存也是可以的。
后来我帮一个团队做选型,他们的业务是常规 CRUD,并发量不超过3000,我直接建议他们用虚拟线程,放弃了原计划的 WebFlux 迁移。省了大约两个月的改造工期,效果达到了预期。
这个问题没有绝对的答案,要看具体场景。但有一点是确定的:Java 21 虚拟线程让"一定要学响应式才能做高并发"这个说法不再成立了。这是一件好事。
下一篇回归 Java 基础,写 Stream API 的深度实战,这块很多人用了几年都有误区,特别是并行流,踩坑率极高。
