Spring Boot 3.2 虚拟线程在 Web 层与 JDBC 池的实践边界

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 标准中,ConnectionStatementResultSet 的操作都是阻塞的。比如 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 需要避免的坑

  1. 不要在虚拟线程中使用 synchronized :可能导致 pinning,即虚拟线程被固定在载体线程上,无法释放平台线程,从而丧失优势。使用 ReentrantLock 代替。
  2. 注意 ThreadLocal 使用 :虚拟线程数量不定,可能会创建很多虚拟线程,但 ThreadLocal 在线程结束后会被清理;不过要避免持有昂贵资源,因为虚拟线程可能比平台线程更多,内存开销依然可能增加。
  3. HikariCP 的 minimumIdlemaximumPoolSize :在虚拟线程下,建议将 minimumIdle 尽可能调低(如 10),避免空闲时维护太多物理连接;maximum-pool-size 要根据数据库能力调高,但需监控数据库连接数。
  4. 如使用 spring-boot-starter-tomcat 之外的容器(如 Jetty),需要确认其虚拟线程支持,但原理相同。

7.3 迁移步骤建议

  1. 先在开发环境开启 spring.threads.virtual.enabled=true,确保不报错。
  2. 观察日志和指标(通过 Micrometer 暴露的线程状态)。
  3. 进行压测,逐步调大 HikariCP 连接池,观察数据库连接数。
  4. 如果出现连接获取失败,考虑调整连接超时或增加池大小。
  5. 如果出现平台线程固定问题,排查同步块。

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=1connection-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 风险。
  • 进行压测对比,依据数据判断收益。

12. 参考资料

相关推荐
Bingo_BIG1 小时前
LOV列表的查询
前端·javascript·lov
盖伦发发1 小时前
SpringCloud Alibaba Sentinel 教学:限流、熔断、降级、线程/信号量隔离、活动过载保护
后端·软件工程
QQ_21696290961 小时前
基于SpringBoot+Vue的小生活平台的设计与实现
java·数据库·vue.js·spring boot·spring·微信小程序·生活
mldong8 小时前
给若依加审批流,不用 Flowable
java·spring boot·架构
Setsuna_F_Seiei8 小时前
前端转型 Agent 开发 04 之 MCP 与 Skill(赋予 Agent 更广工作能力)
前端·agent
考虑考虑9 小时前
数据库中的EXISTS
运维·数据库·后端
刘发财9 小时前
前端2秒生成500页矢量PDF,rust真的强到没朋友
前端·javascript·rust
郑州光合科技余经理10 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
毅炼10 小时前
Rover-Suite 开源发布:轻量服务注册中心与网关一体化方案
java·后端·系统架构·gateway