Java 21 虚拟线程(Virtual Threads)
虚拟线程是 JDK 21 通过 JEP 444 正式引入的轻量级线程实现,源自 Project Loom 项目。一句话定义:
A virtual thread is an instance of
java.lang.Threadthat is not tied to a particular OS thread.
换句话说,虚拟线程是 JVM 管理的"用户态线程",不再是操作系统线程的 1:1 映射。
一、为什么需要虚拟线程
传统 Java 线程(现称"平台线程")有两个痛点 :
- 1:1 绑定 OS 线程 :每条
java.lang.Thread都是 OS 线程的薄包装,在其生命周期内独占底层 OS 线程 - 资源昂贵:默认栈 1MB 左右,创建/切换成本高,单机通常只能撑几千条
这迫使开发者为了高并发不得不写异步/响应式代码(CompletableFuture、Reactor、WebFlux),代价是代码复杂度陡增、调试困难、栈轨迹支离破碎。
虚拟线程的目标就是:让你用最简单的"每请求一线程"同步写法,跑出接近异步的吞吐量 。
💡 一个很好的类比是虚拟内存:OS 用"大虚拟地址空间 → 少量物理 RAM"的映射制造了内存充裕的假象;JVM 则用"大量虚拟线程 → 少量 OS 线程"的映射制造了线程充裕的假象 。
二、核心机制:挂载(Mount)与卸载(Unmount)
虚拟线程并非脱离平台线程运行,而是运行在一组称为 carrier threads(载体线程) 的平台线程之上。载体线程池默认是 ForkJoinPool,大小等于 CPU 核数 。
调度流程:
- 虚拟线程就绪 → 被挂载(mount) 到某个载体线程上开始执行
- 遇到阻塞 I/O /
Thread.sleep()/ 锁等待 → JVM 自动将其卸载(unmount) ,载体线程立即释放 - 阻塞操作完成 → 虚拟线程回到就绪队列,被任意可用载体线程重新挂载,从挂起点继续执行
关键差异对比:
| 维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 底层载体 | 1 个 OS 线程(独占) | 多个虚拟线程共享少量 OS 线程 |
| 内存占用 | ~1MB 固定栈 | 几百字节起步,动态增长 |
| 阻塞行为 | 阻塞 → 浪费整个 OS 线程 | 阻塞 → 自动卸载,载体线程被复用 |
| 并发规模 | 几千 | 百万级 |
| 创建成本 | 高(内核态分配) | 极低(用户态) |
三、三种创建方式
scss
// 方式 1:快捷创建并启动
Thread.startVirtualThread(() -> {
System.out.println("Hello from " + Thread.currentThread());
});
// 方式 2:Builder 模式(可命名、可配置)
Thread vt = Thread.ofVirtual()
.name("worker-", 0) // 名称前缀
.start(() -> doWork());
// 方式 3:Executor(推荐用于批量任务)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // 阻塞的是虚拟线程,载体线程不阻塞
return "done";
});
}
} // 关闭时自动等待所有任务完成
⚠️ 虚拟线程永远不应该被池化。JEP 444 明确指出:虚拟线程廉价且充足,应为每个任务创建新虚拟线程;而平台线程昂贵,才需要池化复用 。
四、适用场景与不适用场景
✅ 适合
- 高并发 I/O 密集型:HTTP 请求、数据库查询、Redis 调用、消息消费
- Web 服务器每请求一线程模型:Tomcat、Jetty、Spring MVC 等
- 批量 I/O 任务:如 10 万条数据同步、并行调用多个外部服务
❌ 不适合
- CPU 密集型任务 :图像处理、加密、大数据计算------瓶颈在 CPU 核数,不在线程数,应使用传统的
ForkJoinPool - 长时间运行的无限循环:会让载体线程调度器"饿死"
- synchronized 块内长时间阻塞 :会导致线程固定(pinning) ,载体线程无法释放
- JNI / Native 方法调用:虚拟线程会被固定到载体线程上
五、关于 "Pinning"(线程固定)
这是 Java 21 虚拟线程最关键的坑:当虚拟线程满足以下条件时,它无法被卸载,载体线程会跟着一起阻塞 :
- 在
synchronized块/方法内发生阻塞 - 执行 native 方法
Java 21 中的应对策略:
csharp
// ❌ 危险:synchronized 内阻塞会导致 pinning
synchronized(lock) {
Thread.sleep(1000); // 载体线程被"固定",无法服务其他虚拟线程
}
// ✅ 推荐:使用 ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(1000); // 虚拟线程可被正常卸载
} finally {
lock.unlock();
}
诊断命令:-Djdk.tracePinnedThreads=full,会在发生 pinning 时打印栈轨迹 。
📌 好消息:Java 24+ 已对此进行了显著改善 ,但 Java 21 仍是当前许多企业的生产基线,需要特别注意。
六、与 Spring Boot 3.2+ 的衔接
回到上一轮我们提到的配置,在 Java 21+ 环境下:
yaml
spring:
threads:
virtual:
enabled: true
开启后,Spring Boot 会自动将以下组件切换到虚拟线程 :
- Tomcat / Jetty 的请求处理线程
@Async方法执行器@Scheduled调度器- Kafka / RabbitMQ 监听器
但要注意:数据库连接池(HikariCP 等)仍然必要------数据库的连接数瓶颈在于数据库自身,不在于 Java 线程。虚拟线程只保证"等待连接时不会占用载体线程",但连接池大小仍需合理设置 。
七、一个直观的性能参照
Oracle Helidon 4.0 在生产环境采用虚拟线程后 :
- 高并发场景下内存减少 90%
- I/O 密集型工作负载吞吐量提升 5 倍
- 代码库简化:用同步阻塞 I/O 替换了原有的响应式代码
Apache Tomcat 10.1+ 的基准测试:5 万并发用户下仅需 2GB RAM(平台线程模型需要 16GB),p95 延迟降低 45% 。
八、核心要点回顾
- 虚拟线程是 JVM 管理的轻量级
java.lang.Thread,不 1:1 绑定 OS 线程 - 它通过挂载/卸载机制将大量虚拟线程复用到少量载体线程上
- 不是"更快的线程",而是"更便宜、更多的线程" ------解决的是伸缩性问题,不是延迟问题
- 适合 I/O 密集型高并发;不适合 CPU 密集型
- 不要池化虚拟线程 ,用
newVirtualThreadPerTaskExecutor() - 警惕
synchronized导致的 pinning,优先用ReentrantLock
虚拟线程的本质,是把"线程"这种本应廉价的抽象,从操作系统手里拿回来交给了 JVM------让开发者重新拥有"一个请求一条线程"这种直观、易调试的编程模型,同时不失高并发能力。这也是为什么 Spring Boot 3.2 将其作为一等公民支持的原因。