Java 21 虚拟线程实战:别再无脑加线程池了
写在前面
Java 21 是 LTS 版本,里面最重磅的特性非 虚拟线程(Virtual Threads,JEP 444) 莫属。它的目标只有一句话:让"每个请求一个线程"这种简单直接的编程模型,在 Java 里重新变得可行。
很多同学升级了 JDK 21,但代码里还是老一套 ThreadPoolExecutor,虚拟线程等于白装。这篇文章带你把虚拟线程用对,而不是用废。
一、为什么需要虚拟线程?
传统 Java 服务端的核心模式是 线程池 + 阻塞 IO:
// 传统写法:平台线程池
ExecutorService pool = Executors.newFixedThreadPool(200);
问题在于:
- 平台线程 1:1 映射操作系统线程,创建成本约 1MB 栈内存 + 内核调度开销,所以线程数不敢开大;
- Tomcat/JDBC 这类同步阻塞代码,线程大部分时间在 等 IO,200 个线程可能 180 个都在干等;
- 想提升吞吐只能加大线程池 → 上下文切换爆炸 → 稳定性反而变差;
- 想省资源只能改异步(CompletableFuture / WebFlux),但回调地狱、调试困难、ThreadLocal 失效等问题接踵而来。
虚拟线程的思路是:代码继续写同步阻塞的样子,但阻塞时把线程"让"出去。
二、虚拟线程到底是个啥?
虚拟线程是 由 JVM 调度的用户态线程,底层跑在少量"载体线程"(Carrier Thread,默认等于 CPU 核数)之上:
虚拟线程 A ─┐
虚拟线程 B ─┤ 挂载/卸载(Mount/Unmount) 载体线程 1 (OS Thread)
虚拟线程 C ─┘ ─────────────────────► 载体线程 2 (OS Thread)
...
关键机制:当虚拟线程执行到阻塞操作(如 socket.read()、sleep())时,JVM 会把它从载体线程上 卸载(unmount),载体线程立刻去跑别的虚拟线程;IO 就绪后再重新挂载。
所以:
- 创建成本极低(几百字节),开百万级虚拟线程是常规操作,不是事故;
- 阻塞 IO 不再浪费 OS 线程;
- 你可以放心地写
Thread.sleep()、InputStream.read()这类同步代码。
三、上手:三行代码改造
1. 创建单个虚拟线程
// 方式一:直接 start
Thread.startVirtualThread(() -> {
System.out.println("跑在虚拟线程上: " + Thread.currentThread());
});
// 方式二:Builder
Thread.ofVirtual()
.name("worker-")
.start(task);
2. 用虚拟线程替代线程池
// 每个任务一个虚拟线程,用完即弃
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var order : orders) {
executor.submit(() -> handle(order));
}
} // AutoCloseable,close() 会等待所有任务完成
注意:不要试图池化虚拟线程,它本身就是为"一次一个"设计的,池化反而引入无意义的排队。
3. Spring Boot 3.2+ 一行配置
spring.threads.virtual.enabled=true
Tomcat 的请求处理线程会换成虚拟线程,同步 MVC 代码不动,吞吐直接受益。
四、实战验证:一个 IO 密集接口
模拟一个"聚合下游 3 个慢服务"的接口,每个下游耗时 1 秒:
static String fetchUpstream(String url) throws Exception {
Thread.sleep(1000); // 模拟 IO 阻塞
return url + "-ok";
}
// 平台线程池版本 vs 虚拟线程版本
static List<String> aggregate(List<String> urls, Executor executor)
throws Exception {
List<Future<String>> futures = new ArrayList<>();
for (String url : urls) {
futures.add(executor.submit(() -> fetchUpstream(url)));
}
List<String> result = new ArrayList<>();
for (var f : futures) result.add(f.get());
return result;
}
压测 10000 个并发请求:
| 模型 | 配置 | 结果 |
|---|---|---|
| 平台线程池 | 200 线程 | 大量请求排队,P99 超过 50s,线程 CPU 上下文切换飙升 |
| 虚拟线程 | 每请求一个 | 排队消失,P99 接近单请求耗时,CPU 占用反而更低 |
结论很直白:瓶颈在 IO 等待时,虚拟线程用"同步代码的写法"拿到了"异步的吞吐"。
五、踩坑清单(重要!)
虚拟线程不是银弹,以下几类场景反而会更差:
❌ 1. CPU 密集型任务
虚拟线程在 CPU 计算时 不会主动让出载体线程,100 万个虚拟线程算哈希,最终还是在 N 个核上排队。CPU 密集任务请继续用固定大小线程池。
❌ 2. synchronized 钉住(Pin)载体线程
JDK 21 中,虚拟线程在 synchronized 块内阻塞会导致载体线程被 钉住(pinning),无法卸载,吞吐急剧下降。解决办法:
// 旧代码
synchronized (lock) { doSlowIo(); }
// 改为 ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try { doSlowIo(); } finally { lock.unlock(); }
好消息:JDK 24(JEP 491)已经解决这个问题,
synchronized不再 pin 载体线程。如果你还在 21,请检查热点路径。
❌ 3. 池化虚拟线程
Executors.newFixedThreadPool(n) 配虚拟线程毫无意义,newThreadPerTaskExecutor 才是正确姿势。
⚠️ 4. ThreadLocal 慎用于海量虚拟线程
百万级虚拟线程各挂一份 ThreadLocal,内存可能扛不住。尤其是 ThreadLocal<SimpleDateFormat> 这种重量级对象,考虑改用 ScopedValue(预览)或直接无状态化。
⚠️ 5. 调试与监控习惯要变
线程 dump 里将出现海量 VirtualThread,排查问题时学会用 toString() 里的 virtual 标识和 JFR 事件(jdk.VirtualThreadStart 等)。
六、什么场景该用?一句话判断
任务大部分时间在等(等 IO、等锁外部的慢响应)→ 用虚拟线程;任务大部分时间在算 → 用有限线程池。
适合的典型场景:
- Web 请求处理(同步 MVC / Tomcat / Jetty)
- RPC / HTTP 下游聚合调用
- 消息消费的并发处理
- gRPC 服务端
不适合的场景:
- 加解密、压缩、图像/视频处理
- 密集循环计算
- 需要严格限制对下游连接数的场景(此时该用信号量限流,而不是靠线程数限流)
七、总结
- 虚拟线程把"同步阻塞写法"和"高并发吞吐"解绑了——这是 Java 并发编程十年一遇的模型升级;
- 正确姿势是 每个任务一个虚拟线程,不要池化;
- 三大军规:CPU 密集别用、JDK 21 里
synchronized慢 IO 别用、海量 ThreadLocal 慎用; - Spring Boot 3.2 用户先打开
spring.threads.virtual.enabled=true跑一轮压测,很可能白捡几倍吞吐。
升级 JDK 21 只是第一步,把并发模型真正切换过来,才算把这个 LTS 用回本。
需要的话我可以再帮你:
- 换个选题(比如 Stream 性能陷阱、record 进阶用法、JVM GC 调优等)
- 调整篇幅或风格(更口语化 / 更学术一些)
- 起几个吸睛的标题备选
- 点赞
- 收藏
- 关注作者
评论(0)