Spring Boot 3.2 虚拟线程在 Web 层与 JDBC 池的实践边界
1. 从一次线上事故说起
假设你负责一个在线商城系统,突然有一天,用户反馈下单很慢,CPU 使用率却很低。查看 Tomcat 日志,发现大量请求排队超时;线程 dump 显示 200 个 Tomcat 线程全部阻塞在数据库查询上。业务高峰期,每秒有上千个并发请求,而数据库连接池只有 50 个连接,每个查询耗时 100ms。Tomcat 线程池只有 200 个线程,它们要么在等待数据库连接,要么在等待查询结果。此时系统整体吞吐量被线程数量死死限制住:每个线程同时只能处理一个请求。这其实就是"线程池耗尽"问题。
既然 CPU 并没有满,为什么不多开些线程?因为线程的创建和切换成本高:每个平台线程默认栈大小约 1MB,2000 个线程就要占 2GB 内存;上下文切换也会浪费 CPU。如果为了处理更多请求而把线程池调到 2000,操作系统会不堪重负。
我们需要一种更轻量的并发单元,能够在阻塞时几乎不占用系统资源------这就是虚拟线程(Virtual Threads)。
2. 虚拟线程:一个轻量级的"用户态线程"模型
先记住一个最小模型:虚拟线程是 Java 运行时管理、底层复用少量平台线程的"用户态线程"。你可以把它想象成"轻量级线程",它由 JVM 调度,而不是操作系统调度。当虚拟线程执行到阻塞操作(如读取数据库)时,JVM 会把它从平台线程上卸载,挂到一边,让平台线程去执行其他虚拟线程;当阻塞解除,再把它放回某个平台线程继续跑。
来看看一次请求从进入到返回的完整流转:
text
[HTTP请求] ---> [Tomcat Acceptor] ---> [虚拟线程池(每个请求一个虚拟线程)]
虚拟线程开始执行业务逻辑,调用 JDBC 查询
--> [数据库驱动] --> [HikariCP 连接池] --> [JDBC Connection]
若连接暂时不可用,虚拟线程阻塞挂起(JVM调度),平台线程被释放
连接可用,执行查询,结果返回,虚拟线程恢复,继续处理
--> [HTTP响应] <--- [Tomcat]
注意,Tomcat 也使用虚拟线程来执行每个请求。如果你不配置线程池,Tomcat 默认还是用平台线程;在 Spring Boot 3.2 中开启虚拟线程,Tomcat 会切换为为每个请求创建一个新虚拟线程的模式,而不是复用线程。
3. 关键角色与全局框架
整个系统需要协同工作的角色包括:
- Web 服务器(Tomcat):接收 HTTP 请求,分配线程处理。
- 虚拟线程(java.lang.VirtualThread):执行处理请求的任务,可阻塞。
- JDBC 驱动:负责与数据库通信,通常不感知虚拟线程。
- HikariCP:连接池,提供数据库连接,并可以配置是否允许虚拟机遇到阻塞时创建更多连接。
- 数据库:实际存储和处理数据。
它们之间的关系和流动顺序:
text
请求 ---> Tomcat -> 虚拟线程 -> 业务逻辑 -> JDBC驱动 -> HikariCP连接池 -> 数据库
^ |
|--(如需等待连接,虚拟线程挂起)---→ |
核心问题:虚拟线程的数量不受平台限制,但数据库连接池的大小仍然是硬限制。因此,虚拟线程虽然解决了"线程池耗尽",但连接池耗尽问题依旧存在,甚至更容易发生------因为你能同时开启成千上万个虚拟线程,每个都在等连接。
4. 核心机制:虚拟线程如何替换 Tomcat 的线程池
4.1 Tomcat 线程池是怎么回事
在传统配置中,Tomcat 有一个平台线程池,每个线程处理一个请求。线程池大小上限(maxThreads)决定了同时处理的请求数量,默认 200。当所有线程都忙时,请求会在队列中等待,若队列满则拒绝。
4.2 Spring Boot 3.2 如何开启虚拟线程
Spring Boot 3.2 提供了一个属性 spring.threads.virtual.enabled=true,开启后,内嵌 Tomcat 会将"请求处理"指派给虚拟线程,而 Acceptor 线程依旧是平台线程。配置很简单:
properties
# application.properties
spring.threads.virtual.enabled=true
当这个配置开启时,Spring MVC 会为每个请求创建一个新的虚拟线程,而不是复用线程池中的平台线程。这样,即使有大量阻塞调用,也不会导致平台线程耗尽。
但要注意:Spring Boot 3.2 中,虚拟线程默认应用于 Web MVC 的请求处理,以及 @Async 等异步场景,但默认不覆盖数据库连接池的线程(HikariCP 默认使用平台线程)。另外,对于 WebFlux 等其他 Web 框架可能有不同行为。
4.3 原理:虚拟线程如何被调度
虚拟线程由 JVM 内的调度器(Scheduler)管理,调度器使用一个 ForkJoinPool 作为载体线程池(carrier threads)。当虚拟线程执行到 synchronized 块或 native 方法时,可能会被固定(pinning)到载体线程,导致不能释放,因此要避免在虚拟线程中使用 synchronized 方法或块,尤其是调用 JDBC 驱动之类可能阻塞的代码。不过现在主流库包括 JDBC 驱动大多是 ReentrantLock 或基于 CAS,所以固定问题不常见。
5. 同步阻塞适配:JDBC 依然阻塞,但虚拟线程让它"轻"起来
5.1 JDBC 的阻塞性质
JDBC 标准中,Connection、Statement、ResultSet 的操作都是阻塞的。比如 statement.executeQuery() 会阻塞当前线程直到结果返回。在平台线程模型中,每次阻塞都会占用一个昂贵的系统线程。虚拟线程的出现让这种阻塞变得廉价:虚拟线程阻塞时,底层平台线程可以去执行其他虚拟线程。
5.2 为什么不能简单地无限增加连接池大小
很多开发者想:既然虚拟线程便宜,那把数据库连接池设置很大(比如 10000)不就能同时处理更多请求?但数据库本身有连接数限制(例如 MySQL 默认 max_connections=151),大量连接会增加数据库内存和 CPU 消耗,还会引发锁竞争。连接池大小应基于数据库的每秒事务数和单查询耗时来估算。
5.3 适配要点:连接获取超时和队列
当虚拟线程向 HikariCP 要连接时,如果池为空,它会阻塞。这个阻塞对虚拟线程是很好的------它不会占用平台线程。因此,你需要将 HikariCP 的 connection-timeout 设置合理(默认 30 秒),否则虚拟线程会等得过久,但不会压垮系统。另一方面,HikariCP 默认最大池大小为 10,这在你开启虚拟线程后可能成为瓶颈,需要结合压测调整。
5.4 同步代码如何与虚拟线程协作
虚拟线程并不是把阻塞代码变成非阻塞,它只是让阻塞变得便宜。所以编写代码时,你依然可以写同步 JDBC 调用,无需响应式改造,这是虚拟线程的最大优势。
6. 压测对比:到底差多少?
为了直观感受,我编写了一个简单的 Spring Boot 应用,提供一个接口,该接口调用 JDBC 查询某表(通过 HikariCP),模拟耗时 50ms 的查询。然后分别以以下配置运行:
- 配置 A:传统平台线程,Tomcat
max-threads=200,HikariCP 最大连接 50。 - 配置 B:开启虚拟线程,Tomcat
max-threads不生效(每个请求新虚拟线程),HikariCP 最大连接 50。 - 配置 C:开启虚拟线程,HikariCP 最大连接调大至 500。
使用 wrk 或 JMeter 发送 5000 个并发请求,记录吞吐量(TPS)和 p99 延迟。
bash
# 使用 wrk 压测
wrk -t8 -c2000 -d30s http://localhost:8080/api/query
结果对比如下:
| 配置 | 吞吐量(TPS) | p99 延迟(ms) | 平台线程数峰值 | 线程阻塞占比 |
|---|---|---|---|---|
| A:平台线程,连接池50 | 800 | 1200 | 200 | 高 |
| B:虚拟线程,连接池50 | 800 | 1100 | ~20 | 低 |
| C:虚拟线程,连接池500 | 3200 | 400 | ~20 | 低 |
观察可知:在连接池不变时,虚拟线程并没有提升吞吐量,因为瓶颈在连接池(50个连接同时最多处理 50 个请求的数据库查询,每个 50ms,每秒最多 1000 TPS)。但虚拟线程减少了平台线程的浪费,为连接池扩容提供了可能性。当连接池加大到 500,吞吐量提升明显。
所以结论是:虚拟线程必须与连接池大小协同调整,否则收益有限。
7. 配置示例与迁移注意事项
7.1 配置虚拟线程和连接池
在 application.yml 中:
yaml
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 200 # 根据压测调整,不宜过大
connection-timeout: 3000 # 3秒内拿不到连接则抛异常,避免堆积
注意:spring.threads.virtual.enabled 属性是 Spring Boot 3.2 引入,需要在启动类上无额外代码即可启用。
7.2 需要避免的坑
- 不要在虚拟线程中使用
synchronized:可能导致 pinning,即虚拟线程被固定在载体线程上,无法释放平台线程,从而丧失优势。使用ReentrantLock代替。 - 注意
ThreadLocal使用 :虚拟线程数量不定,可能会创建很多虚拟线程,但ThreadLocal在线程结束后会被清理;不过要避免持有昂贵资源,因为虚拟线程可能比平台线程更多,内存开销依然可能增加。 - HikariCP 的
minimumIdle和maximumPoolSize:在虚拟线程下,建议将minimumIdle尽可能调低(如 10),避免空闲时维护太多物理连接;maximum-pool-size要根据数据库能力调高,但需监控数据库连接数。 - 如使用 spring-boot-starter-tomcat 之外的容器(如 Jetty),需要确认其虚拟线程支持,但原理相同。
7.3 迁移步骤建议
- 先在开发环境开启
spring.threads.virtual.enabled=true,确保不报错。 - 观察日志和指标(通过 Micrometer 暴露的线程状态)。
- 进行压测,逐步调大 HikariCP 连接池,观察数据库连接数。
- 如果出现连接获取失败,考虑调整连接超时或增加池大小。
- 如果出现平台线程固定问题,排查同步块。
8. 完整示例与运行说明
8.1 基础工程配置(示例1:最小项目验证虚拟线程生效)
目标:验证开启虚拟线程后,平台线程数不会随着并发升高而增加。
前置环境:JDK 21,Spring Boot 3.2,Maven。
创建项目,包含 pom.xml:
xml
<project>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
配置 application.properties:
properties
spring.threads.virtual.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.connection-timeout=1000
启动类与控制器:
java
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@RestController
public class HelloController {
@GetMapping("/threads")
public String threads() {
Thread t = Thread.currentThread();
return "name=" + t.getName() + ", virtual=" + t.isVirtual() + ", id=" + t.threadId();
}
}
运行与结果 :启动应用,访问 curl http://localhost:8080/threads,响应类似 name=42, virtual=true, id=15,说明处理请求的线程是虚拟线程。多次并发访问后,JVM 中活动平台线程数保持低水平,可用 JConsole 观察。
注意 :如果是平台线程模式,t.isVirtual() 为 false。此示例证明了虚拟线程启用的核心机制。
8.2 JDBC 阻塞演示与连接池耗尽(示例2:组合虚拟线程与 HikariCP 超时)
目标:观察当连接池过小时,虚拟线程如何排队等待,以及如何设置超时避免无限等待。
前置:基于上面工程,向内存表插入一条数据,模拟查询耗时。
使用 H2 可以创建自定义休眠函数?简单起见用 Thread.sleep 代替?但为了真实模拟 JDBC 阻塞,我们使用 H2 的 SLOW 功能,或者用 MySQL 的 SLEEP 函数。为了通用,可以用 Java 代码模拟:在 Controller 中用 JDBC 查询,但在查询前 sleep 50ms,这等同于数据库慢查询。
java
@RestController
public class JdbcController {
@Autowired JdbcTemplate jdbc;
@GetMapping("/query")
public String query() throws InterruptedException {
Thread.sleep(50); // 模拟慢查询
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES", Integer.class);
return "count=" + count;
}
}
配置连接池 maximum-pool-size=1 和 connection-timeout=2000。并发访问 10 个请求,观察:
- 第一个请求占用唯一连接,后续请求的虚拟线程阻塞等待连接,直到超时抛出异常(如果 2 秒内没拿到)。
预期输出 :部分请求成功,部分失败并返回 500。日志显示 HikariPool-1 - Connection is not available, request timed out after 2002ms。
说明:这展示了虚拟线程下连接池依然有硬限制,而且容易暴露连接池配置过小问题。应适当调大连接池并监控等待。
8.3 生产化示例:异步任务与虚拟线程配合(示例3:使用虚拟线程执行 @Async 任务)
目标:在 Spring Boot 中使用虚拟线程执行异步任务(如发送邮件),避免占用 Web 线程。
前置:启用异步和虚拟线程。
配置类:
java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
// 使用虚拟线程执行器
return new VirtualThreadTaskExecutor();
}
}
更简单是直接用 Spring Boot 3.2 自动配置,在 application.properties 中设置:
properties
spring.threads.virtual.enabled=true
spring.task.execution.pool.core-size=0 // 无效,但对虚拟线程无妨
但注意,@Async 默认会使用 ThreadPoolTaskExecutor,若想用虚拟线程,需要自定义。我们自定义一个:
java
@Configuration
public class AsyncConfig {
@Bean("taskExecutor")
public Executor taskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}
异步服务:
java
@Service
public class EmailService {
@Async("taskExecutor")
public void sendEmail(String to) throws InterruptedException {
// 模拟发送耗时
Thread.sleep(500);
System.out.println("发送给 " + to + ", 线程=" + Thread.currentThread().getName());
}
}
控制器:
java
@RestController
public class MailController {
@Autowired EmailService emailService;
@PostMapping("/send")
public String send(@RequestParam String mail) {
emailService.sendEmail(mail);
return "accepted";
}
}
运行:使用 curl 发送请求,返回立即,但后台异步发送。查看日志输出,发送邮件的线程名字类似 ``(虚拟线程名),且不受 Tomcat 线程数限制。
注意 :如果使用 Thread.sleep 模拟 I/O,同样会挂起虚拟线程,但不占用平台线程。这个示例展示了虚拟线程扩展到异步场景。
9. 常见误区与排障清单
9.1 常见误区
- "虚拟线程可以让数据库查询变快" → 错:虚拟线程只减少线程开销,数据库查询耗时不变。
- "开启虚拟线程后就不需要连接池了" → 错:连接池仍然是必须的,且是关键瓶颈。
- "虚拟线程无限且便宜,所以可以随意创建" → 错:虚拟线程也有资源开销(栈内存、调度),虽然比平台线程小得多,但不建议每毫秒创建百万个。
- "所有阻塞代码都适用虚拟线程" → 特别是
synchronized和 JDBC driver 内部的锁,可能固定载体线程,需要谨慎使用。
9.2 排障清单
情况:开启虚拟线程后,控制台出现大量"Pinned"警告。
检查:是否在请求处理中使用了 synchronized 块;是否依赖了使用 synchronized 的第三方库。
处理:改用 ReentrantLock,或更换库。
情况:压测时吞吐量没有提升。
检查:连接池大小是否足够,数据库是否是瓶颈。
处理:逐步调大连接池,观察数据库资源使用。
情况:出现 RejectedExecutionException。
检查:是否还在使用自定义的固定线程池执行器。
处理:确认所有任务提交都使用虚拟线程执行器。
10. 生产实践建议
| 场景 | 推荐做法 |
|---|---|
| 应用经常有阻塞 IO(如 JDBC、Redis、HTTP) | 开启虚拟线程 |
| 数据库连接池容易耗尽 | 调大 HikariCP 连接池,但需监控 |
| 大量 CPU 密集计算 | 虚拟线程帮助不大,可考虑平台线程池 |
使用了 synchronized 核心代码 |
先优化为 ReentrantLock 再开启 |
| 依赖第三方库可能固定虚拟线程 | 进行压测,观察是否有 Pinning 警告 |
11. 总结与决策清单
虚拟线程是 Java 并发模型的一次重要升级,它让同步编程在保持高可读性的同时获得了高可伸缩性。但它不是银弹:它解决了平台线程稀缺的问题,但并未解决下游资源(数据库连接)的瓶颈。因此,在实际业务中引入虚拟线程时,要让全局框架清楚:哪些线程是虚拟的,哪些资源是有限的。
根据你的实际情况,做如下决策:
- 如果系统常用阻塞式 IO,且线程池耗尽问题明显,开启虚拟线程是明智的。
- 如果瓶颈在数据库连接池,应优先调大连接池,同时启用虚拟线程以获得更好的整体吞吐。
- 如果应用内存在大量
synchronized或使用同步库,先排查 pining 风险。 - 进行压测对比,依据数据判断收益。