Spring Boot 开启虚拟线程后接口反而变慢了?4 个排查方向和对应修法

Spring Boot 开启虚拟线程后接口反而变慢了?4 个排查方向和对应修法

环境:Spring Boot 3.4.x / 4.1.x、JDK 21+、MySQL 8.x、Redis 7.x。文中排查思路基于本地复现和公开技术文档,不涉及不可考的生产事故。

目录

  • 虚拟线程不是"开了就快"的开关
  • 方向 1:钉住(Pinning)------虚拟线程退化成平台线程
  • 方向 2:连接池成为瓶颈------数据库、Redis、HTTP 客户端都没跟上
  • 方向 3:ThreadLocal 内存压力------上下文传递没有适配
  • 方向 4:锁竞争从线程层转移到了资源层
  • 一份开启虚拟线程前的检查清单

虚拟线程不是"开了就快"的开关

Spring Boot 3.2 开始支持一行配置开启虚拟线程:

yaml 复制代码
spring:
  threads:
    virtual:
      enabled: true

开启后 Tomcat 的请求处理线程、@Async 方法、@Scheduled 任务默认都会使用虚拟线程。很多项目改完配置一压测,QPS 确实有提升,但继续加压后发现响应时间反而比平台线程时更长,P99 延迟飙升。

虚拟线程的设计目标是提高 IO 密集型任务的吞吐量------当线程在等待网络响应时,JVM 可以把载体线程(carrier thread)切换去执行其他虚拟线程。但它不能加速 CPU 计算,也不能凭空创造数据库连接和 Redis 连接。

接口变慢通常不是虚拟线程本身的开销,而是虚拟线程暴露了系统里其他资源的瓶颈。下面按四个最常见的排查方向展开。

方向 1:钉住(Pinning)------虚拟线程退化成平台线程

虚拟线程在遇到 synchronized 代码块或 native 方法时,无法从载体线程上卸载(unmount),只能跟着载体线程一起阻塞。这叫"钉住"(pinning)。被钉住的虚拟线程和平台线程没有区别------它占着载体线程,其他虚拟线程无法复用这个载体。

如果你的业务代码或依赖库中有大段 synchronized 块,虚拟线程的并发优势会被严重削弱。

排查方法:

JDK 21+ 可以加 JVM 参数观察钉住事件:

bash 复制代码
java -Djdk.tracePinnedThreads=short -jar your-app.jar

short 模式只输出简短的钉住堆栈;换成 full 会输出完整堆栈。压测时观察日志,如果出现大量 Thread pinned 相关输出,说明钉住正在发生。

常见钉住来源:

  1. 老旧的 JDBC 驱动。MySQL Connector/J 8.0.33 之前的版本在内部使用了 synchronized,会钉住虚拟线程。升级到 8.0.33+ 或 8.3.x 可以解决。
  2. HikariCP 5.1.0 之前的版本。HikariCP 在获取连接时使用了 synchronized,5.1.0 开始改用 ReentrantLock,虚拟线程可以正常卸载。
  3. 业务代码中自定义的 synchronized 块。改成 ReentrantLock 即可------ReentrantLock 在等待时不会钉住虚拟线程。
java 复制代码
// 会钉住虚拟线程
synchronized (lock) {
    blockingIO();
}

// 不会钉住
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    blockingIO();
} finally {
    lock.unlock();
}

JDK 24+ 的变化: 从 JDK 24 开始,JVM 对 synchronized 的钉住问题做了大幅优化(JEP 491 相关改进),很多场景下 synchronized 不再导致钉住。如果你的生产环境能升级到 JDK 25 LTS,这个排查方向的优先级可以降低。

方向 2:连接池成为瓶颈------数据库、Redis、HTTP 客户端都没跟上

这是开启虚拟线程后最常见的变慢原因。虚拟线程让并发请求数从几百飙升到几万,但下游资源的连接池大小没变。

数据库连接池: HikariCP 的 maximum-pool-size 默认 10。1000 个虚拟线程同时查数据库,990 个在排队等连接。如果 connection-timeout 设的是默认 30 秒,这些请求会堆积 30 秒才超时,P99 延迟直接爆表。

Redis 连接池: Lettuce 默认是共享单连接模式。虚拟线程高并发下,单连接可能成为瓶颈。如果切换到连接池模式(Lettuce pooling),需要确认池大小和虚拟线程并发量的关系。

HTTP 客户端: RestTemplate 底层的 HttpClient 有连接池上限。虚拟线程同时发起 5000 个外部调用,但 HTTP 连接池只有 50 个连接,其余 4950 个请求排队。

排查方法:

  1. 开启 HikariCP 的 metrics 监控(配合 Spring Boot Actuator),观察 hikaricp.connections.active、hikaricp.connections.pending。如果 pending 持续大于 0,说明连接不够用。
  2. 在 Redis 客户端侧观察活跃连接数和等待队列。
  3. 在 HTTP 客户端侧观察连接池使用率。

修正方向:

不是把连接池无限放大。数据库连接是有限资源,MySQL 的 max_connections 有上限。正确的做法是:

  • 连接池大小绑定数据库承载能力,不绑定虚拟线程数。
  • connection-timeout 缩短到 3-5 秒,快速失败优于长时间堆积。
  • 在上层加限流(Sentinel、Gateway 限流),控制进入业务层的并发量。

方向 3:ThreadLocal 内存压力------上下文传递没有适配

虚拟线程的生命周期极短,创建和销毁成本很低。但如果每个虚拟线程都绑定了 ThreadLocal 变量,这些变量的总量会随虚拟线程数膨胀。

典型场景:Spring Security 的 SecurityContextHolder 默认用 ThreadLocal 存储认证信息。虚拟线程下每个请求创建一个虚拟线程,每个虚拟线程都有一份 SecurityContext 的 ThreadLocal 副本。如果请求处理链路中还有其他 ThreadLocal(MDC 日志上下文、租户标识、链路追踪 ID),叠加起来内存占用会明显上升。

排查方法:

  1. 压测时观察 JVM 堆内存趋势。如果虚拟线程模式下老年代增长明显快于平台线程模式,大概率是 ThreadLocal 泄漏。
  2. 用 jmap -histo:live <pid> 查看堆中对象分布,关注 ThreadLocalMap$Entry 相关类型。

修正方向:

  • 确保所有 ThreadLocal 在请求结束时调用 remove()。Spring 的 RequestContextFilter 和 SecurityContextPersistenceFilter 默认会清理,但如果你自定义了 Filter 或 Interceptor 使用了 ThreadLocal,需要自己处理。
  • 考虑迁移到 ScopedValue(JDK 25 正式特性)。ScopedValue 的作用域绑定和自动回收机制天然适合虚拟线程。
  • SLF4J 的 MDC 底层也是 ThreadLocal。如果日志上下文在虚拟线程下出现丢失或膨胀,需要检查 MDC 的清理逻辑。

方向 4:锁竞争从线程层转移到了资源层

平台线程时代,Tomcat 的 maxThreads=200 天然限制了并发。200 个线程抢数据库连接,最多 200 个并发。虚拟线程把这个限制去掉了------并发数不再受线程成本约束。

但数据库的行锁、表锁、MySQL 的 innodb_buffer_pool_size、操作系统的文件描述符上限------这些资源约束仍然存在。当并发从 200 变成 5000,这些资源层面的竞争会急剧加剧。

表现:

  • 数据库 CPU 飙升,但应用服务器 CPU 不高。
  • SHOW ENGINE INNODB STATUS 中出现大量锁等待。
  • 慢查询日志中出现以前不慢的 SQL 突然变慢。

排查方法:

  1. 在 MySQL 侧执行 SHOW PROCESSLIST,观察 Waiting for lock 状态的连接数。
  2. 用 performance_schema 查看锁等待统计。
  3. 在应用侧开启 HikariCP 的 leak-detection-threshold,排查是否有连接长时间未归还。

修正方向:

  • 缩小事务范围。@Transactional 方法里不要包含 RPC 调用、文件 IO 等非数据库操作。事务越大,连接占用时间越长。
  • 读写分离。查询操作不要走写连接,减少写连接池的压力。
  • 在应用层做限流,不要让虚拟线程的并发量无限制地打到数据库。

一份开启虚拟线程前的检查清单

在开启虚拟线程之前,逐项确认以下条件:

检查项 要求 不满足的后果
JDK 版本 21+(推荐 25 LTS) 无法使用虚拟线程
Spring Boot 版本 3.2+ 不支持一行配置开启
MySQL Connector/J 8.0.33+ synchronized 导致钉住
HikariCP 5.1.0+ 获取连接时钉住
业务代码中的 synchronized 排查并替换为 ReentrantLock IO 操作期间钉住载体线程
数据库连接池配置 maximum-pool-size 按数据库承载力设定 连接排队导致超时
connection-timeout 缩短到 3-5 秒 请求堆积导致内存上升
ThreadLocal 使用 确认所有自定义 ThreadLocal 有清理逻辑 内存泄漏
事务范围 确认 @Transactional 方法内无非数据库 IO 连接长时间占用
限流配置 应用层有限流,控制进入业务层的并发量 下游资源被打满

如果以上检查项有 3 个以上不满足,建议先把这些基础工作做好,再开启虚拟线程。否则虚拟线程带来的不是性能提升,而是把已有问题放大。


参考资料

相关推荐
用户69371750013841 小时前
AI 能写代码,但工程架构还是应该由人来定
前端·后端
七夜zippoe1 小时前
LangChain4j vs Spring AI:Java AI 框架怎么选(对比表)
java·人工智能·spring·ai·langchain4j
悠仁さん1 小时前
【C++】set map 使用
java·前端·c++
回家路上绕了弯1 小时前
让 AI 成为团队的共同能力
后端
70后写java的老码农1 小时前
一个 starter 通吃 9 家主流大模型:Spring Boot 3.x + Spring AI 1.x 极简落地
后端
yuniko-n2 小时前
【JUC】Thread 内存泄露
java
freejackman2 小时前
【架构篇】双遍历 + 多栈协同:手撸 SQL 血缘引擎的架构思考
java·大数据·数据库·hive·sql·架构·spark
企业数字化笔记2 小时前
业务单据怎样接入流程中心?启动条件、实例关联与失败回退
java·spring boot·mysql
武子康2 小时前
MultiNet 只听到一次命令,为什么连续触发?
人工智能·后端·agent