Java 中的虚拟线程:可扩展性与复杂性平衡
传统并发的问题
Java 传统的并发靠平台线程(Platform Threads),每个线程直接映射到一个操作系统线程,1:1 的关系。每个线程大约吃掉 1-2 MB 内存,外加操作系统级别的资源开销。创建几千个线程就开始捉襟见肘了,再多就 OutOfMemoryError 或者上下文切换把 CPU 拖垮。
Web 应用里最常见的"每个请求一个线程"模型,写起来直观,但到了高并发场景(比如超过 10k 并发请求)就力不从心。响应式编程——Project Reactor、RxJava 那些——确实能提高吞吐量,代价是代码变得难写、难读、难调试。
什么是虚拟线程(VT)?
Project Loom 搞出来的虚拟线程,在 JDK 21(JEP 444)正式稳定。思路很简单:JVM 自己管理轻量级线程,每个大约只占 1 KB 内存,你可以轻松开几百万个不会撑爆内存。
虚拟线程是 java.lang.Thread 的实例,现有代码几乎不用改就能用。它针对的是 I/O 绑定场景——HTTP 调用、数据库查询、文件读写这类会阻塞的操作,不是 CPU 密集型的银弹。概念上类似 Go 的 goroutines。从 JDK 21 开始稳定,22 到 25 持续在改进。
内部工作原理:M:N 调度
M:N 调度,M 个虚拟线程映射到 N 个平台线程上。平台线程充当载体(carrier),虚拟线程挂载到载体上执行。当虚拟线程遇到 I/O 阻塞——sleep()、套接字操作、数据库查询——它会从载体上卸载,载体就可以去跑别的虚拟线程。
I/O 完成后,虚拟线程回到调度器队列,等任何空闲载体重新挂载它继续执行。载体线程几乎不会因为等 I/O 而闲着,CPU 利用率被拉满。
最近的改进值得说一下。JDK 24(JEP 491)解决了 synchronized 块里的线程固定问题,虚拟线程在 synchronized 代码块中遇到阻塞也能正确卸载了。JDK 25(JEP 506)引入了作用域值(Scoped Values)作为 ThreadLocal 的不可变替代品,防止虚拟线程场景下的内存泄漏。
实际示例
虚拟线程最适合 I/O 绑定的场景——HTTP 请求、数据库访问、文件操作。
示例 1:简单虚拟线程
Thread.startVirtualThread(() -> {
System.out.println("来自 VT 的问候!" + Thread.currentThread());
});示例 2:ExecutorService(高并发)
import java.util.concurrent.Executors;
import java.time.Duration;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10k 并发任务
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // 模拟 I/O
// 业务逻辑
})
);
} // 自动等待完成newVirtualThreadPerTaskExecutor() 给每个任务分配一个虚拟线程。一万个并发任务每个睡一秒,传统线程池早 OOM 了,虚拟线程毫无压力。
示例 3:Spring Boot + VT(真实基准测试)
最近的基准测试(JDK 24 + Spring Boot + Postgres),虚拟线程在 10k req/s 时延迟比平台线程低了大约 50%。
@RestController
public class ApiController {
@GetMapping("/data")
String getData() {
// VT 自动处理请求(Tomcat/Undertow 配置)
return fetchFromDb(); // 阻塞但可扩展
}
}Spring Boot 3.2+ 配置虚拟线程很简单,Tomcat 或 Undertow 的线程池直接换成虚拟线程就行。你写的还是同步阻塞代码,底层已经是虚拟线程在跑了。
高级考虑和最佳实践
CPU 绑定 vs I/O 绑定
虚拟线程不是万能的。I/O 绑定的任务——数据库、HTTP 调用——用虚拟线程加 newVirtualThreadPerTaskExecutor(),大规模扩展性非常好。但 CPU 绑定的任务,比如加密运算或机器学习推理,用虚拟线程反而有害。CPU 密集型任务不会让出载体线程,会导致载体饥饿:少数载体被占满,其他虚拟线程排不上队。这类场景还是老老实实用平台线程池,线程数设成 CPU 核心数。
问题和解决方案
synchronized 块的固定问题在 JDK 24 已经解决了,但 JNI 或原生代码还是可能遇到固定,能避开就避开。
ThreadLocal 跟虚拟线程配合很糟糕,几百万个虚拟线程各自持有一份 ThreadLocal 数据,内存直接爆炸。JDK 25 的作用域值是官方推荐的替代方案。
监控方面,Thread.currentThread().isVirtual() 可以判断当前线程是不是虚拟线程。JFR 从 JDK 21 开始就支持虚拟线程的录制和分析。框架支持也很成熟了,Spring Boot 3.2+、Quarkus、Helidon 都原生支持。
最佳实践(2025):
- Web 服务器默认开虚拟线程,最简单的收益。
- 用 JDK Mission Control 或 JFR 做分析,检测有没有线程固定。
ThreadLocal能迁就迁到作用域值。- CPU 密集型用固定大小的线程池。
- 压测用 wrk 或 ab,跑到 100k 并发看实际表现。
最终总结
如果你刚开始接触并发,直接上虚拟线程。Thread.startVirtualThread() 一个方法调用搞定,不用操心线程池配置。如果你是有经验的开发者,I/O 绑定场景用虚拟线程,CPU 密集型还是平台线程池,注意监控线程固定。
打个比方:平台线程像是给每个人配一部专用电梯,很舒服但成本爆炸。虚拟线程像气动管道系统,几千个轻量胶囊在少数物理管道里穿梭。
来源:OpenJDK JEPs、Spring Boot 基准测试(Reddit/Java 24)、RockTheJVM/RabinaNPatra 指南(2025)。