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 尝鲜,但别急着在核心链路上开启虚拟线程


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

相关推荐
小灰灰搞电子18 分钟前
Rust Attribute(属性标记)完整整理
开发语言·后端·rust
tedcloud12335 分钟前
God‘s Eye View 怎么搭建?在云服务器上部署一个实时 3D 地球可视化平台
linux·服务器·开发语言·后端·rust
深入云栈41 分钟前
Netty 4.2.x 源码深度解析 (十三):Epoll 传输 —— Linux 高性能 IO 的 Netty 实现
java·后端
Lyra_Infra1 小时前
从一段错误 JSON 说起:Policy、Role 与 IAM
后端·json·aigc
Charlie_Byte1 小时前
在用户目录安装多版本 JDK,并用环境变量切换
后端
黑马程序员毕设2 小时前
基于微信小程序的节目活动报名与管理系统设计与实现
java·开发语言·spring boot·后端·电脑
掘金者阿豪2 小时前
Windows 部署 Papra:建立私有文档库、标签管理,再配置固定公网访问
后端
Bs_MoneyMagnet2 小时前
基于springboot+vue的会议室预约管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
Bs_MoneyMagnet2 小时前
基于springboot+vue的茶铺管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
她说..2 小时前
RabbitMQ 使用场景详解
java·spring boot·后端·spring·rabbitmq·java-rabbitmq