Java 21 虚拟线程深度解析:高并发编程的游戏规则改变了
前言
长期以来,Java 开发者面对高并发场景时,一直被一个难题困扰:线程太贵了。
传统 Java 线程是操作系统线程(Platform Thread)的直接封装,一个线程默认占用 1MB 左右的栈内存。当你需要处理 10 万个并发请求时,创建 10 万个 OS 线程不仅内存扛不住,上下文切换的开销也会让 CPU 苦不堪言。
这就是为什么过去几年响应式编程(WebFlux、RxJava)和异步框架(Netty)如此流行——它们本质上都是在绕开"线程太贵"这个问题,代价是代码变得复杂、调试变得困难。
而 Java 21 正式发布的虚拟线程(Virtual Threads,JEP 444),终于从 JVM 层面正面解决了这个问题。
一、虚拟线程是什么?
一句话概括:虚拟线程是由 JVM 而非操作系统调度的轻量级线程。
它的核心机制是:
- 多个虚拟线程在任意时刻会被挂载(mount)到少量 carrier thread(载体线程,即 OS 线程)上运行;
- 当虚拟线程遇到阻塞操作(如 IO 读取、
sleep)时,JVM 会把它从载体线程上卸载(unmount),把载体线程让给其他虚拟线程; - 阻塞结束后,虚拟线程会被重新调度到某个载体线程上继续执行。
传统模型:1 个 Java 线程 = 1 个 OS 线程(昂贵)
虚拟线程:N 个虚拟线程 → 复用 M 个 OS 线程(N >> M)
这意味着你可以用同步阻塞的代码风格,获得异步非阻塞的吞吐量。
二、快速上手
1. 创建虚拟线程
// 方式一:直接启动
Thread.startVirtualThread(() -> {
System.out.println("运行在: " + Thread.currentThread());
});
// 方式二:使用 Builder
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> handleRequest());
// 方式三:Executors(最重要用法)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
final int id = i;
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // 模拟 IO 阻塞
return id;
});
}
} // executor 会等待所有任务完成
注意最后这个例子:10 万个任务,每个都阻塞 1 秒,总耗时约 1 秒。换成平台线程池,这是不可想象的。
2. Spring Boot 3.2+ 中启用
只需一行配置:
spring:
threads:
virtual:
enabled: true
启用后,每个 Web 请求都会在一个独立的虚拟线程中处理。你的 Controller 代码一行都不用改,但并发能力大幅提升。
三、虚拟线程 vs 平台线程 vs 响应式编程
| 维度 | 平台线程 | 虚拟线程 | 响应式(WebFlux) |
|---|---|---|---|
| 调度者 | OS | JVM | Event Loop 线程 |
| 内存开销 | ~1MB/线程 | ~几 KB/线程 | 极低 |
| 编程模型 | 同步阻塞 | 同步阻塞 | 异步非阻塞(Flux/Mono) |
| 调试体验 | 好 | 好 | 差(栈信息破碎) |
| 阻塞代码兼容性 | 天然支持 | 天然支持 | 会打死 Event Loop |
| 学习成本 | 低 | 低 | 高 |
关键结论:虚拟线程让"一个请求一个线程"的传统同步模型重新变得可行且高效,而不必被迫转向响应式。
四、避坑指南:这些场景不要用虚拟线程
虚拟线程不是银弹,用错了反而适得其反。
❌ 1. CPU 密集型任务
虚拟线程的优势在于应对阻塞,而不是加速计算。CPU 密集型任务(图像处理的、复杂算法、加解密)应该继续使用固定大小的平台线程池(核数 × N),虚拟线程不会带来任何收益。
❌ 2. 用池化虚拟线程
// 错误示范:不要池化虚拟线程!
ExecutorService pool = Executors.newFixedThreadPool(10, Thread.ofVirtual().factory());
虚拟线程本身就是为"每个任务一个线程"设计的,池化它们毫无意义,反而人为限制了并发。正确的用法是 newVirtualThreadPerTaskExecutor。
❌ 3. 滥用 synchronized 导致 Pinning(钉子户问题)
虚拟线程在 synchronized 块内阻塞时,无法从载体线程上卸载,这叫线程钉定(pinning),会导致载体线程饥饿:
// 有问题的写法
synchronized (lock) {
httpClient.send(request, ...); // 阻塞 IO 在 synchronized 内 → pinning
}
// 推荐改用 ReentrantLock
private static final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
httpClient.send(request, ...); // 可以正常卸载
} finally {
lock.unlock();
}
JDK 24 的 JEP 491 已从根本上解决了 synchronized 的 pinning 问题,但如果你运行在 21~23,仍然需要注意。
❌ 4. 无节制地创建 ThreadLocal 大对象
虚拟线程可以轻松创建百万个,如果每个都持有大对象或大量 ThreadLocal 缓存,内存照样会爆。官方建议:虚拟线程不适合与 ThreadLocal 一起使用,必要时用 ScopedValue(JEP 446 预览)替代。
五、工作原理初探:Continuation 与调度器
虚拟线程的底层是 java.lang.VirtualThread,它内部持有一个 Continuation(续体)对象:
runContinuation:保存了线程的执行栈(帧数据存放在堆上,而不是固定大小的原生栈),所以它可以按需增长、也可以整体"冻结";scheduler:默认是一个ForkJoinPool,负责把可运行的虚拟线程提交给载体线程执行。
当虚拟线程调用 Thread.sleep() 或 socket read 时,实际流程是:
1. JVM 保存当前栈帧到堆(unmount)
2. 注册一个定时器/IO 回调
3. 载体线程立刻执行其他虚拟线程
4. 回调触发 → 虚拟线程进入调度队列
5. 某个载体线程重新挂载它,从断点继续执行
这套机制让"阻塞"的成本从"占用一个 OS 线程"降为"堆里多存一份栈数据"。
六、性能实测参考
JVM 官方与社区的基准测试给出了这样一个普遍规律:
- IO 密集型应用(HTTP 调用、数据库查询):吞吐量提升明显,从几十倍到上百倍都有可能(取决于并发量与 IO 延迟占比);
- 单请求延迟:基本不变,甚至略有增加——虚拟线程不加速单个请求;
- 内存占用:百万级并发连接的内存占用显著低于平台线程模型。
一句话:虚拟线程优化的是"并发规模",不是"单任务速度"。
七、未来演进:结构化并发与调度器
Java 21 同时带来了两个配套预览特性:
- Structured Concurrency(结构化并发,JEP 453):用
StructuredTaskScope把一组并发任务当作一个单元管理——任一子任务失败可自动取消其他任务,避免孤儿线程泄漏:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join().throwIfFailed();
return new Response(user.get(), order.get());
} // scope 关闭时保证所有子任务已结束
- Scoped Values(JEP 446):面向虚拟线程时代的
ThreadLocal替代品,支持不可变作用域值和在虚拟线程间高效继承。
(这两个特性在后续版本中仍在持续迭代,如 JDK 25 中结构化并发已转正为 JEP 505,落地前请查阅对应版本文档。)
总结
- 虚拟线程把 Java 的高并发门槛拉低了:同步的代码,异步的性能;
- 它的适用面非常明确:IO 密集、高并发、大量阻塞的场景;
- CPU 密集型任务、池化思维、
synchronized中的阻塞 IO、ThreadLocal 滥用是四大常见坑; - Spring Boot 3.2+ 一行配置即可启用,升级成本低;
- 结合结构化并发与 Scoped Values,Java 的并发编程模型正在迎来十年来最大的范式升级。
如果你还在为高并发写复杂的异步代码,是时候重新审视"一个请求一个线程"这个最朴素的模型了——它在 Java 21 之后,真的够用了。
- 点赞
- 收藏
- 关注作者
评论(0)