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 相关输出,说明钉住正在发生。
常见钉住来源:
- 老旧的 JDBC 驱动。MySQL Connector/J 8.0.33 之前的版本在内部使用了
synchronized,会钉住虚拟线程。升级到 8.0.33+ 或 8.3.x 可以解决。 - HikariCP 5.1.0 之前的版本。HikariCP 在获取连接时使用了
synchronized,5.1.0 开始改用ReentrantLock,虚拟线程可以正常卸载。 - 业务代码中自定义的
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 个请求排队。
排查方法:
- 开启 HikariCP 的 metrics 监控(配合 Spring Boot Actuator),观察
hikaricp.connections.active、hikaricp.connections.pending。如果pending持续大于 0,说明连接不够用。 - 在 Redis 客户端侧观察活跃连接数和等待队列。
- 在 HTTP 客户端侧观察连接池使用率。
修正方向:
不是把连接池无限放大。数据库连接是有限资源,MySQL 的 max_connections 有上限。正确的做法是:
- 连接池大小绑定数据库承载能力,不绑定虚拟线程数。
connection-timeout缩短到 3-5 秒,快速失败优于长时间堆积。- 在上层加限流(Sentinel、Gateway 限流),控制进入业务层的并发量。
方向 3:ThreadLocal 内存压力------上下文传递没有适配
虚拟线程的生命周期极短,创建和销毁成本很低。但如果每个虚拟线程都绑定了 ThreadLocal 变量,这些变量的总量会随虚拟线程数膨胀。
典型场景:Spring Security 的 SecurityContextHolder 默认用 ThreadLocal 存储认证信息。虚拟线程下每个请求创建一个虚拟线程,每个虚拟线程都有一份 SecurityContext 的 ThreadLocal 副本。如果请求处理链路中还有其他 ThreadLocal(MDC 日志上下文、租户标识、链路追踪 ID),叠加起来内存占用会明显上升。
排查方法:
- 压测时观察 JVM 堆内存趋势。如果虚拟线程模式下老年代增长明显快于平台线程模式,大概率是 ThreadLocal 泄漏。
- 用
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 突然变慢。
排查方法:
- 在 MySQL 侧执行
SHOW PROCESSLIST,观察 Waiting for lock 状态的连接数。 - 用
performance_schema查看锁等待统计。 - 在应用侧开启 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 个以上不满足,建议先把这些基础工作做好,再开启虚拟线程。否则虚拟线程带来的不是性能提升,而是把已有问题放大。
参考资料