Java 虚拟线程发布两年后,我们把它从生产环境移除了

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 但没有及时清理,这些对象就会一直存活。

我们的问题主要集中在:

  1. Spring Security 的 SecurityContextHolder ------默认使用 InheritableThreadLocal
  2. MDC(Mapped Diagnostic Context)------Logback 基于 ThreadLocal 的上下文传递
  3. 各种拦截器/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 抖动不可接受

目前的结论

虚拟线程的真正价值不是"让程序跑得更快",而是让编程模型更简单。用虚拟线程,你可以在保持同步编程风格的同时获得异步的性能。但这个价值是有条件的:

  1. 你的代码/依赖库几乎不用 synchronized
  2. ThreadLocal 使用规范,清理及时
  3. 你愿意投入时间重构监控和排障工具
  4. 外部资源(连接池等)的容量跟得上虚拟线程的并发量

如果以上四点你做不到,建议先小范围灰度,不要直接在核心服务上全量铺开。


总结

2026 年了,虚拟线程已经不再是新鲜事物。各个大厂在实践中有成功也有失败,但一个共识正在形成:虚拟线程是工具,不是架构

它不能替代对并发模型的理解,不能替代连接池的合理配置,不能替代良好的编程习惯------但它确实能让写 IO 密集型代码变得更轻松。

下次如果有人说"我们直接上虚拟线程吧",建议你问三个问题:

你们的代码里有多少 synchronized? ThreadLocal 有没有规范清理? 连接池扩容了吗?

如果回答不上来,不妨先把 JDK 升到 21 尝鲜,但别急着在核心链路上开启虚拟线程


你在生产环境中试过虚拟线程吗?遇到过什么坑?欢迎在评论区分享你的经历。

相关推荐
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十五):控制台静态页面、深入研究时间格式化方案
后端·go·ai编程
金金金__3 小时前
腾讯云直连COS优化:CDN 接入
后端
爱勇宝3 小时前
《道德经》第5章:生产环境不相信眼泪
前端·后端·架构
掘金者阿豪3 小时前
LEFT JOIN 凭空消失的背后,是优化器偷偷帮你做了决定
前端·后端
阿维的博客日记3 小时前
spring里面的@RequestParam什么意思
java·后端·spring
金金金__3 小时前
腾讯云COS接入配置
后端
Hyyy3 小时前
Git Worktree 完全讲解
前端·后端·面试
逝水无殇4 小时前
C# 反射详解
开发语言·后端·c#
Cache技术分享4 小时前
465. Java 反射 - 获取类的简单名
前端·后端