Java 虚拟线程发布两年后,我们把它从生产环境移除了
当你们的 Spring Boot 服务在 JDK 21 上跑了半年,P99 延迟不降反升,线程池莫名卡死,Arthas 再也看不到熟悉的线程名...... 你也会做出同样的决定。
2024 年 9 月,JDK 21 发布刚满一年,我们决定把所有核心服务的 JDK 版本升级到 21。看中的头号特性就是 虚拟线程(Virtual Threads)------当时几乎所有技术媒体都在说"这是 Java 并发编程的革命"。
2026 年 3 月,经过 6 个月的灰度测试和生产运营,我们把其中两个核心服务的虚拟线程换回了平台线程。
这篇文章不是要否定虚拟线程------它确实是个好东西。我想分享的是我们在真实生产环境中踩过的坑,以及最终得出的结论:虚拟线程不是银弹,用对场景是利器,用错场景是灾难。
背景
先交代一下我们的场景:
- 服务类型:高并发 API 网关 + 数据处理服务
- 流量规模:日均处理约 5 亿次请求,单机峰值 QPS 约 12k
- 原有架构:Spring Boot 3.x + Tomcat + 平台线程
- 升级路径 :JDK 17 → JDK 21, 开启虚拟线程 (
-Dspring.threads.virtual.enabled=true)
升级之后的第一周,监控面板上各项指标都很好看:吞吐量提升了 30%,P99 延迟下降了约 40%。我们在周报里兴奋地写下:"虚拟线程改造顺利完成,性能显著提升。"
然后问题开始浮出水面。
坑一:synchronized 导致的 pinning
现象
运行两周后,我们开始收到零星的超时告警。某个接口的 P99 延迟从 50ms 飙升到 5s 以上,持续几十秒后自动恢复。
排查
查看线程 dump,发现大量虚拟线程被 "pinned" 到载体线程上,导致载体线程池耗尽。
原因
虚拟线程的一个核心限制:当虚拟线程进入 synchronized 块或执行 native 方法时,会被 pin(钉住)到载体线程上,阻塞其他虚拟线程调度。
我们的代码中有不少 synchronized 的地方------Guava Cache 的加载方法、Logback 的异步 appender、甚至是某些框架内部使用的 synchronized 集合类。
修复
java
// synchronized 会让虚拟线程 pinned
public synchronized String getConfig(String key) {
return configMap.get(key);
}
// 改用 ReentrantLock
private final Lock lock = new ReentrantLock();
public String getConfig(String key) {
lock.lock();
try {
return configMap.get(key);
} finally {
lock.unlock();
}
}
但这不是最头疼的------有些第三方库内部的 synchronized 你根本改不了 。比如某些老版本数据库驱动、HTTP 客户端内部使用的 synchronized 代码块,这些才是真正的定时炸弹。
数据
实际压测数据:在被 synchronized 频繁 pinning 的情况下,虚拟线程的吞吐量优势从 30% 降到几乎为零,甚至不如平台线程。
| 场景 | 吞吐量(vs 平台线程) | P99 |
|---|---|---|
| 无 synchronized(纯计算+IO) | +32% | -42% |
| 少量 synchronized | +15% | -18% |
| 频繁 synchronized | -5% | +63% |
坑二:ThreadLocal 的内存泄漏
现象
服务运行一段时间后出现明显的 GC 压力,Old Gen 持续增长。排查后发现大量 ThreadLocal 对象没有被回收。
原因
JDK 21 的虚拟线程实现中,虚拟线程池会缓存已使用过的虚拟线程实例(Loom 团队称之为 "carrier thread pool")。如果代码在虚拟线程中设置了 ThreadLocal 但没有及时清理,这些对象就会一直存活。
我们的问题主要集中在:
- Spring Security 的 SecurityContextHolder ------默认使用
InheritableThreadLocal - MDC(Mapped Diagnostic Context)------Logback 基于 ThreadLocal 的上下文传递
- 各种拦截器/AOP 中隐式设置的 ThreadLocal
更坑的是:虚拟线程数量可以远大于平台线程,这意味着 ThreadLocal 的泄露风险被放大了一个数量级。
修复
yaml
# 在 Spring Boot 中,虚拟线程模式下需要额外配置
spring:
threads:
virtual:
enabled: true
task:
scheduling:
pool:
size: 10 # 务必设置合理的线程池大小
代码层面:
java
// 依赖自动清理
SecurityContextHolder.setContext(ctx);
try {
// 业务逻辑
} finally {
// 显式清理
SecurityContextHolder.clearContext();
}
但这句话说得轻巧,真正检查全项目所有 ThreadLocal 使用、确保每个都有 try-finally 清理,是相当大的工程。如果你们用了第三方框架隐式设置 ThreadLocal,排查起来更头疼。
坑三:线程池 + 虚拟线程 = 负优化
现象
我们有一个场景:上游发送一批请求过来(约 500-2000 个),每个请求需要对多个下游系统做并行调用。原来用的是 CompletableFuture + 自定义线程池。
换用虚拟线程后,代码简化了:
java
// 用虚拟线程后的"简化"写法
List<Future<Result>> futures = tasks.stream()
.map(task -> executorService.submit(task))
.toList();
看起来没问题对吧?问题出在 executorService 上。
原因
如果你还是用传统线程池(newFixedThreadPool 等)来运行虚拟线程,那简直是灾难------虚拟线程的最大优势(轻量、大量)被完全阉割了。
正确的做法应该是用 Executors.newVirtualThreadPerTaskExecutor():每个任务创建一个新虚拟线程,完全不限制数量。
但问题又来了:不限制数量不意味着真不限制。 当你的虚拟线程在做 IO 等待时不消耗平台线程,但每个虚拟线程至少要占几 KB 的内存。如果同时创建 10 万个虚拟线程,光是内存占用就接近 1GB,而且 GC 压力大增。
教训
java
// 错误:用平台线程池管理虚拟线程
ExecutorService pool = Executors.newFixedThreadPool(200);
Future<?> f = pool.submit(virtualThreadTask);
// 错误:无限制创建虚拟线程
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100_000; i++) {
executor.submit(task); // 内存直接爆炸
}
// 正确:限制虚拟线程的并发数
Semaphore semaphore = new Semaphore(1000);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (Task task : tasks) {
semaphore.acquire();
executor.submit(() -> {
try {
task.run();
} finally {
semaphore.release();
}
});
}
坑四:监控和排障工具集体失效
现象
线上出问题后,我们习惯性用 Arthas 看线程状态,结果:
arduino
[arthas]$ thread
Threads: Total: 12 (platform threads only)
虚拟线程在 Arthas 里根本不显示为独立线程!排查问题全靠猜。
真正的痛苦
- Arthas:看不到虚拟线程状态,无法做线程级的问题定位
- JDK Flight Recorder(JFR):JDK 21 对虚拟线程的支持还不够完善,很多事件不准确
- Prometheus + Micrometer:默认指标不区分虚拟线程和平台线程,需要额外配置
- 日志中的线程名 :虚拟线程的名字类似
ForkJoinPool-1-worker-1,根本对应不到业务请求
我们后来不得不写一套自定义的工具,把虚拟线程的调度信息汇集到日志里,才勉强恢复可观测性。这一项的工作量就花了两周。
坑五:Spring Boot 中的隐性问题
如果你的项目和大多数人一样用 Spring Boot,这些隐形问题可能你也会遇到:
问题 1: Tomcat 虚拟线程模式下的连接器问题
yaml
server:
tomcat:
threads:
max: 200 # 这个配置在虚拟线程模式下含义变了
在平台线程模式下,Tomcat 线程池大小决定了最大并发请求数。但在虚拟线程模式下,这个值变成了请求队列长度------因为每个请求由虚拟线程处理,而不是平台线程。这个语义变化导致我们压测时出现大量请求排队,但实际处理能力远未用满。
问题 2: RestTemplate/WebClient 的线程管理
java
// 虚拟线程模式下,RestTemplate 会为每个请求创建新虚拟线程
// 但连接池配置不当会导致大量虚拟线程等待连接
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(5000);
factory.setHttpClient(HttpClientBuilder.create()
.setMaxConnTotal(200) // 这个数字需要和并发量匹配
.setMaxConnPerRoute(100)
.build());
return new RestTemplate(factory);
}
虚拟线程让并发量翻了几倍,但连接池没跟上,导致大量虚拟线程阻塞在"等待 HTTP 连接"上。它们在等待时确实不消耗平台线程,但等待时间长,用户体验依然差。
问题 3: 数据库连接池
同样的道理------虚拟线程让你的应用能承受更高并发,但数据库连接池、HTTP 连接池、Redis 连接池等外部资源的上限没变。最终大量虚拟线程阻塞在等待连接资源上,整体延迟反而恶化。
所以,虚拟线程到底行不行?
说了这么多坑,我怕大家误会。我们的结论不是"虚拟线程不行",而是要在正确的场景下,以正确的方式使用它。
我们最后留下的虚拟线程使用场景
| 场景 | 是否保留 | 原因 |
|---|---|---|
| IO 密集型任务(HTTP 调用、数据库查询) | 保留 | 提升显著 + 代码简化 |
| CPU 密集型任务 | 换回平台线程 | 无收益且有 pinning 问题 |
| 高吞吐网关 | 有条件使用 | 需要充分调优 + 改 synchronized |
| 数据批处理 | 保留 | 大量 IO 等待,收益明显 |
| 实时交易系统 | 换回平台线程 | P99 抖动不可接受 |
目前的结论
虚拟线程的真正价值不是"让程序跑得更快",而是让编程模型更简单。用虚拟线程,你可以在保持同步编程风格的同时获得异步的性能。但这个价值是有条件的:
- 你的代码/依赖库几乎不用
synchronized - ThreadLocal 使用规范,清理及时
- 你愿意投入时间重构监控和排障工具
- 外部资源(连接池等)的容量跟得上虚拟线程的并发量
如果以上四点你做不到,建议先小范围灰度,不要直接在核心服务上全量铺开。
总结
2026 年了,虚拟线程已经不再是新鲜事物。各个大厂在实践中有成功也有失败,但一个共识正在形成:虚拟线程是工具,不是架构。
它不能替代对并发模型的理解,不能替代连接池的合理配置,不能替代良好的编程习惯------但它确实能让写 IO 密集型代码变得更轻松。
下次如果有人说"我们直接上虚拟线程吧",建议你问三个问题:
你们的代码里有多少
synchronized? ThreadLocal 有没有规范清理? 连接池扩容了吗?
如果回答不上来,不妨先把 JDK 升到 21 尝鲜,但别急着在核心链路上开启虚拟线程。
你在生产环境中试过虚拟线程吗?遇到过什么坑?欢迎在评论区分享你的经历。