踩坑记:Spring 启用虚拟线程后 100 并发就卡死?根源与解决方案
在 JDK 19 将虚拟线程(Virtual Thread)正式转正,Spring Framework 6.1 + 也通过spring.threads.virtual.enabled = true提供了开箱即用的虚拟线程支持后,不少开发者都迫不及待地在生产环境启用这一特性,期望借助虚拟线程的轻量级特性提升系统并发能力。
但实际落地中,很多同学遇到了诡异的问题:原本基于平台线程(Platform Thread)运行稳定的系统,启用虚拟线程后仅 100个并发压测就完全卡死 —— 接口无响应、日志无报错,系统仿佛 “假死”。本文结合真实生产场景,拆解这一问题的核心原因,并给出可落地的解决方案。
一、问题复现:现象与背景
1. 环境与配置
- 框架:Spring Boot 3.4.5(内置 Spring Framework 6.2.11)
- JDK 版本:JDK 21(LTS,虚拟线程成熟版本)
- 核心配置:仅开启
spring.threads.virtual.enabled = true,无其他虚拟线程相关定制 - 业务场景:系统以数据库操作为核心(大量查询、计算、插入),无显式
synchronized/ReentrantLock锁竞争 - 压测现象:100 并发请求后系统完全卡死,接口超时无返回,日志无任何异常堆栈,关闭虚拟线程(
spring.threads.virtual.enabled = false)后压测恢复正常
二、根因分析:虚拟线程的 “隐性阻塞” 陷阱
虚拟线程的设计核心是 “轻量级” 和 “高吞吐”,但它并非 “银弹”—— 其优势的发挥依赖一个关键前提:虚拟线程在阻塞时能释放载体线程(Carrier Thread)。而本次问题的核心,正是违背了这一前提。
1. 载体线程池的默认瓶颈
Spring 启用虚拟线程后,默认使用 JDK 内置的ForkJoinPool.commonPool()作为载体线程池,该线程池的默认大小为CPU核心数 - 1(例如 8 核 CPU 仅 7 个载体线程)。
虚拟线程本身是 “挂载” 在载体线程上执行的:
- 若虚拟线程执行非阻塞逻辑:可快速切换,7 个载体线程能支撑数千个虚拟线程并发;
- 若虚拟线程执行阻塞逻辑(如同步 JDBC):虚拟线程会 “霸占” 载体线程,直到阻塞结束 ——10 个并发请求直接占满 7 个载体线程,剩余 3 个请求无载体线程可用,系统彻底卡死。
2. 同步 JDBC 的阻塞特性
本次场景的核心业务是数据库操作,而默认的 JDBC 是同步阻塞式 IO:虚拟线程执行jdbcTemplate.query()/jdbcTemplate.update()时,会等待数据库响应,且这一阻塞过程无法释放载体线程。
叠加数据库连接池的默认配置(如 HikariCP 默认最大连接数 10),最终形成 “双重阻塞”:
- 虚拟线程阻塞在 “获取数据库连接”(连接池满);
- 已获取连接的虚拟线程阻塞在 “数据库 IO”(霸占载体线程)。
3. 无报错的本质:阻塞而非异常
系统卡死但无报错,是因为所有线程都处于 “阻塞等待” 状态,而非 “异常终止”:
- 载体线程被阻塞的虚拟线程占用,无空闲线程处理新请求;
- 阻塞的虚拟线程未抛出异常,仅持续等待资源(数据库连接 / IO 响应),因此日志无报错。
三、解决方案:从配置到代码的全维度优化
针对上述根因,我们需要从 “载体线程池”“数据库连接池”“业务代码” 三个维度进行优化,让虚拟线程适配数据库密集型场景。
1. 定制虚拟线程载体线程池
核心目标是摆脱默认ForkJoinPool的容量限制,自定义独立的载体线程池,适配数据库阻塞场景。
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
@Configuration
public class VirtualThreadConfig {
/**
* 自定义虚拟线程工厂
* 核心:脱离默认的ForkJoinPool,避免载体线程耗尽
*/
@Bean
public ThreadFactory virtualThreadFactory() {
// 载体线程数设置为CPU核心数*4(适配数据库IO阻塞场景)
int carrierThreadCount = Runtime.getRuntime().availableProcessors() * 4;
return Thread.ofVirtual()
.name("db-virtual-thread-", 0) // 命名便于排查
.factory();
}
/**
* 让Tomcat请求处理线程使用自定义虚拟线程
* 关键:接管Web层线程调度,避免默认池瓶颈
*/
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
AsyncTaskExecutor executor = new TaskExecutorAdapter(
Executors.newThreadPerTaskExecutor(virtualThreadFactory())
);
protocolHandler.setExecutor(executor);
};
}
}
2. 扩容并优化数据库连接池
虚拟线程会放大并发请求量,默认的连接池配置(10 个连接)无法支撑,需针对性扩容并调整超时参数:
# application.yml
spring:
threads:
virtual:
enabled: true # 保持虚拟线程开启
datasource:
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true
username: root
password: root
hikari:
maximum-pool-size: 50 # 核心:扩容至压测并发数以上
minimum-idle: 20 # 最小空闲连接,避免频繁创建连接
connection-timeout: 5000 # 缩短连接获取超时,避免线程长期阻塞
idle-timeout: 600000 # 空闲连接超时回收
max-lifetime: 1800000 # 连接最大生命周期,避免无效连接
leak-detection-threshold: 30000 # 开启连接泄漏检测,定位慢查询
3. 优化数据库操作:减少阻塞时长
即使扩容了连接池,慢 SQL 仍会让虚拟线程长期霸占载体线程,需优化核心数据库操作:
(1)批量操作代替单条操作
减少 IO 交互次数,降低阻塞频率:
// 优化前:单条插入(频繁IO阻塞)
for (User user : userList) {
jdbcTemplate.update("INSERT INTO user (name, age) VALUES (?, ?)", user.getName(), user.getAge());
}
// 优化后:批量插入(减少IO次数)
String sql = "INSERT INTO user (name, age) VALUES (?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
User user = userList.get(i);
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
}
@Override
public int getBatchSize() {
return userList.size();
}
});
(2)优化 SQL 与索引
- 为查询字段添加索引(如
user表的name字段),避免全表扫描; - 拆分长事务,缩短事务执行时间,减少连接占用时长;
- 避免在事务内执行复杂计算,将计算逻辑移出事务。
4. 接口层限流:兜底保障
若暂时无法彻底优化数据库,可通过限流避免系统雪崩:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
@RestController
public class DbOperateController {
// 限流并发数 = 数据库连接池最大数,避免超出连接池能力
private final Semaphore semaphore = new Semaphore(50);
@GetMapping("/db/operate")
public String dbOperate() {
try {
// 3秒内获取不到许可则返回友好提示,避免线程阻塞
if (!semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
return "系统繁忙,请稍后再试";
}
// 核心数据库操作逻辑
return "操作成功";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "操作中断";
} finally {
semaphore.release(); // 释放许可,避免泄漏
}
}
}
四、验证与监控:确保优化生效
修改配置后,需通过工具验证优化效果:
1. 线程状态排查
# 1. 查看Java进程PID
jps
# 2. 导出虚拟线程栈(JDK21+支持)
jcmd <PID> Thread.dump_virtual > vt_dump.txt
# 3. 查看载体线程占用情况
jstack <PID> | grep "carrier" | wc -l
2. 连接池监控(引入 Actuator)
# 开启Actuator监控
management:
endpoints:
web:
exposure:
include: hikaricp, threads
访问http://localhost:8080/actuator/hikaricp查看连接池状态,确认无连接耗尽;访问http://localhost:8080/actuator/threads查看线程状态,确认无大量阻塞线程。
五、总结:虚拟线程落地的核心原则
本次踩坑案例,本质是对虚拟线程的 “认知偏差”—— 虚拟线程并非 “无脑提升并发”,而是需要适配业务场景:
- 载体线程池是核心瓶颈:默认载体线程池容量小,数据库阻塞场景需自定义扩容;
- IO 密集型场景需适配 IO 模型:同步 JDBC 无法释放载体线程,需通过扩容连接池 / 载体池适配,或迁移至异步 JDBC;
- 虚拟线程放大原有问题:系统卡死的本质是数据库连接池不足 / 慢 SQL,虚拟线程只是让问题提前暴露;
- 限流是兜底方案:任何高并发场景,都需设置并发上限,避免资源耗尽。
虚拟线程是 Java 并发领域的重大升级,但落地时需结合业务场景做针对性优化 —— 只有让虚拟线程的 “轻量级” 特性与业务的 “阻塞特性” 匹配,才能真正发挥其高吞吐优势。
更多推荐



所有评论(0)