本文摘要:虚拟线程拆掉了 Tomcat 的线程上限,数据库连接池会在数秒内被打满。平台线程池原本是隐式限流,可用信号量等显式背压替代它。方案适用于下游连接数固定的服务,数据库已是瓶颈时应回到查询与索引优化。
一、问题与结论
Spring Boot 3.2.5 + JDK 21,在application.yml加一行spring.threads.virtual.enabled: true后压测:吞吐没有明显提升,P99 反而变差,日志开始刷:
HikariPool-1 - Connection is not available, request timed out after 30000ms同一份代码、同一份连接池配置,把开关改回false后,连接池偶尔繁忙,但从未在几十秒内被彻底打满。
结论先行:这个开关只替换请求处理的线程模型,不改变任何下游资源池的容量。HikariCP 的maximum-pool-size仍是 10,数据库能承受的并发连接数也没变。虚拟线程移除了"最多 200 个请求同时执行"这道隐式限流,连接池因此以更快的速度被填满并压满——瓶颈从线程数平移到了连接数。
三个可观测的次生症状:
- 吞吐不涨:请求卡在
getConnection()上排队,CPU 花在调度而非执行上。 - P99 变差:
connection-timeout之内的排队时间全部计入请求耗时。 - 日志串号:常见于把请求上下文放在可被载体线程复用的
ThreadLocal(自定义线程包装、异步 appender 在别的线程读 MDC)。这与 pinning 无关,具体表现依日志框架版本而定,需在你的框架上单独验证。
二、排查与选择依据
先复现,再归因。用wrk对同一接口压两轮,第二轮切到带信号量的路径:
wrk-t4-c200-d30s--latencyhttp://localhost:8080/query wrk-t4-c200-d30s--latencyhttp://localhost:8080/query-limited同时每秒抓一次连接池状态(第四节的/pool-status):虚拟线程模式下active立刻等于maximum-pool-size、waiting持续增长;平台线程模式下waiting上限被maxThreads(默认 200)压住。
若 P99 依旧异常,再排查 pinning。这是虚拟线程场景下另一类独立问题,JDK 21 可用启动参数直接抓:
java-Djdk.tracePinnedThreads=full-jardemo.jar示意输出(行内容取决于真实堆栈,本文未在你的环境验证):
Thread[#42,ForkJoinPool-1-worker-1] is pinned at: com.demo.DemoController.sleep(DemoController.java:41) com.demo.DemoController.query(DemoController.java:35) java.base@21/java.lang.VirtualThread.run(VirtualThread.java:592)三类常见成因逐条说清:
synchronized:JDK 21 中,虚拟线程进入synchronized块后无法从载体线程卸载,整个块执行期间占死一个载体线程。JDK 24 起不再由synchronized触发 pinning,但跑在 JDK 21 上仍需自己规避。- JNI 调用:native 方法内部如果做了阻塞 I/O(老版本数据库驱动的 socket 读),同样无法卸载,载体线程被长期占用。
ThreadLocal:这是最容易被误判的一项——读写ThreadLocal本身不会导致 pinning,每个虚拟线程都有独立的ThreadLocal表。它的真实代价是内存(每个虚拟线程一份)与上下文传递(异步线程上取不到),排查时不要把它当卡顿根因。
替代方案与取舍
| 做法 | 选择条件 | 代价与边界 |
|---|---|---|
Semaphore显式背空(本文方案) | 下游连接池容量固定,想保留虚拟线程的低成本等待 | 需保证异常路径也release();每个访问下游的入口都要包 |
回退平台线程并调大maxThreads | 瓶颈本就不在应用线程,I/O 等待不多 | 线程栈内存开销大,上限受 OS 约束 |
| R2DBC 非阻塞数据访问 | 并发极高且能接受整层重写 | 事务模型不同,迁移与学习成本高 |
不该用本文方案的两种情况:一是数据库本身已是瓶颈(慢查询、锁竞争),加任何应用层控制都是掩盖问题,应先做索引与查询优化;二是请求几乎不碰数据库(纯外部 HTTP 调用),虚拟线程的收益本就集中在这里,不必额外引入信号量。
三、关键原理
平台线程模式下,Tomcat 的 200 个线程是一道天然减速带:最多 200 个请求在执行,最多 200 个请求持有连接,其余在 accept 队列排队。虚拟线程让"等待"几乎免费——初始栈仅数百字节,挂起与恢复由 JVM 调度器接管,不占操作系统线程。于是更多请求毫无代价地涌进getConnection()的等待队列,队列膨胀速度远高于平台线程模式;同时数千虚拟线程并发竞争 HikariCP 的ConcurrentBag,CAS 与内部锁的争用在 200 并发下可忽略,在数千并发下变成可观测损耗。
一句话:背压不会凭空消失,只会在你没设防的位置堆起来。
四、可运行示例
最小工程(JDK 21、Spring Boot 3.2.5、内嵌 H2)。在spring-boot-starter-parent3.2.5 的基础上增加两个依赖:
<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>配置文件application.yml:
spring:threads:virtual:enabled:truedatasource:url:jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1driver-class-name:org.h2.Driverusername:sapassword:""hikari:maximum-pool-size:5connection-timeout:10000pool-name:demo-pool控制器:
importcom.zaxxer.hikari.HikariDataSource;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.transaction.support.TransactionTemplate;importorg.springframework.web.bind.annotation.*;importjava.util.Map;importjava.util.concurrent.Semaphore;@RestControllerpublicclassDemoController{privatefinalJdbcTemplatejdbc;privatefinalTransactionTemplatetx;privatefinalHikariDataSourceds;privatefinalSemaphorelimit;// permits 取连接池上限,避免无效排队publicDemoController(JdbcTemplatejdbc,TransactionTemplatetx,HikariDataSourceds){this.jdbc=jdbc;this.tx=tx;this.ds=ds;this.limit=newSemaphore(ds.getMaximumPoolSize());}@GetMapping("/query")publicStringquery(){returntx.execute(s->{jdbc.queryForList("SELECT 1");try{Thread.sleep(3000);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}return"done";});}@GetMapping("/query-limited")publicStringqueryLimited()throwsInterruptedException{limit.acquire();try{returnquery();}finally{limit.release();}}@GetMapping("/pool-status")publicMap<String,Object>poolStatus(){varp=ds.getHikariPoolMXBean();returnMap.of("active",p.getActiveConnections(),"idle",p.getIdleConnections(),"total",p.getTotalConnections(),"waiting",p.getThreadsAwaitingConnection());}}启动并压测:mvn spring-boot:run,再执行第二节的两条wrk命令。/query在事务里持有连接 3 秒,用来放大排队效应。
一个常见失败:把limit.acquire()写进try块内部,异常路径漏掉release(),信号量几轮请求后泄漏并永久卡死。修复方式是固定"先acquire()、try/finally中release()"的顺序,如queryLimited()所示。
五、验证结果与边界
预期输出:/query下active恒为 5、waiting稳定在并发数减 5 的水平,wrk 的 P99 逼近connection-timeout;切到/query-limited后waiting不再增长,P99 回落到约 3 秒,超时报错消失。
实际输出:本文示例在 H2 内存库与本地wrk下验证过"连接池秒级被打满、信号量路径消除等待堆积"这一现象;tracePinnedThreads的具体堆栈与生产驱动相关,上文输出仅为格式示意,未在你的环境验证。启用 pinning 排查前,先确认自己确实在 JDK 21 上运行。
参考资料
- JEP 444: Virtual Threads(JDK 21)
- Spring Boot Reference Documentation: Virtual Threads
- Apache Tomcat 10.1 Documentation
- HikariCP Wiki