上周我刚从一个线上事故里爬出来,准确说是一次压测事故:JDK 21 + Spring Boot 3.2,随手打开虚拟线程,JMeter 跑 100 并发,结果接口直接挂掉,日志里全是连接等待超时,应用像是睡着了。当时第一反应是“这不科学”,虚拟线程不是号称能吃下几十万并发吗?怎么 100 个请求就卡死了。
排查两天,最后发现根子不在虚拟线程本身,而在我们这些靠着虚拟线程“起飞”的人,根本没搞懂它背后的资源边界。这篇文章就把整个过程复盘一遍:现象、根因、Thread Dump 怎么定位、配置怎么改、代码怎么修,全给你讲透。适合正在用或者准备用 Spring Boot 3.2 + JDK 21 的朋友,尤其是做 Web 服务、对并发有要求的团队,看完应该能少走不少弯路。
1. 事故现场:100 并发就把虚拟线程“打瘫”了
1.1 复现路径:从启动参数到 JMeter 压测
先交代一下技术栈。服务是基于 JDK 21 的 Spring Boot 3.2.5,Web 容器用的内置 Tomcat,数据库是 MySQL,连接池用的 HikariCP。启用虚拟线程的方式非常简单,就是在配置文件里加一行:
spring.threads.virtual.enabled=true这个配置是 Spring Boot 3.2 开始提供的,打开之后,Tomcat 接收请求的工作线程就会切换成虚拟线程。注意,这里切换的是 Web 请求处理线程,不涉及你代码里自己 new 的线程池。
压测我用的是 JMeter,线程组设置为 100 个线程,Ramp-Up 时间 5 秒,循环次数 50。接口是一个很普通的订单列表查询,SQL 逻辑不复杂,单次查询大概在 30 到 80 毫秒。这个量级平时用平台线程跑 100 并发也毫无压力,所以我一开始压根没想过会出问题。
结果跑起来就魔幻了。前 10 秒左右请求还能陆续返回,之后响应时间开始直线上升,从几百毫秒涨到 20 秒、30 秒,最后 JMeter 里直接飘红超时。应用日志里疯狂刷异常,典型的报错是这样:
HikariPool-1 - Connection is not available, request timed out after 30000ms.最诡异的是,系统 CPU 占用率只有 20% 左右,内存也正常,没有频繁 Full GC,进程也活着,但接口就是堵死。这种“活死人”状态是最难排查的,因为它不是崩溃,而是所有线程都堵在某处。
1.2 卡死前的三个典型征兆
复盘时我发现,卡死并不是一瞬间发生的,中间有几个非常典型的征兆,如果早一点留意,能少走很多弯路。
第一个征兆是请求明明进入了 Controller,但后续却没有任何日志输出。我们每个接口在入口和出口都会打日志,当时入口日志一直在刷,出口日志却迟迟不来。这说明请求没有被拒绝,而是卡在了处理链路中间某个环节。
第二个征兆是数据库连接池相关报错开始密集出现。Hikari 默认的 connectionTimeout 是 30 秒,所以大量请求表现成“等待 30 秒后报错”,而不是立即失败。
第三个征兆是应用占用的线程数出现了“假性暴涨”。用 jstack 看线程时,能看到成百上千的虚拟线程被阻塞在获取连接的代码上,因为虚拟线程本身很轻量,JVM 不会拒绝创建它们,所以看起来像线程很多、很活跃,实际上全是排队等资源的。
这三个征兆组合在一起,基本可以确定问题不在 Web 容器层,而在更底层的资源竞争上。
2. 为什么会卡死:虚拟线程的底层逻辑与资源边界
2.1 虚拟线程不是“更多线程”
要理解卡死的原因,得先搞清楚一个容易被误解的点:虚拟线程并不是让你的 CPU 能同时干更多活儿,它只是把“线程”这个单位变得更廉价了。
平台线程是操作系统级别的线程,每个线程都对应一个内核线程,创建成本、上下文切换成本都很高。而虚拟线程是 JVM 自己管理的轻量级线程,底层复用一组平台线程来执行任务。当一个虚拟线程遇到 IO 阻塞时,JVM 会把它从平台线程上卸下来,让那个平台线程去跑其他虚拟线程。
我用一个比较形象的方式解释:平台线程就像餐厅里的工位,虚拟线程就像排队的食客。食客在等菜(IO)的时候,不需要一直占着工位,可以先去旁边等,工位腾出来给下一个食客。这个机制让系统能够同时接待海量“等待中”的请求。
但这带来一个新的问题:既然虚拟线程这么轻量,为什么还会卡死?答案在于,有些资源是虚拟线程共享的,比如数据库连接、外部接口连接池、锁。虚拟线程解决的是“线程数量”的瓶颈,解决不了“共享资源数量”的瓶颈。
2.2 真正的元凶之一:固定(Pinning)问题
这里要引入一个关键概念:固定(Pinning)。当虚拟线程进入 synchronized 同步块时,在 JDK 21 这个版本里,它会被“钉”在底层平台线程上,不能释放。简单说,一旦虚拟线程在 synchronized 块里执行阻塞操作,它就不会“让位”给其他虚拟线程了。
还是拿餐厅打比方:有些食客进了包厢(synchronized),点完菜之后就一直坐在包厢里等菜,服务员没办法让其他人进包厢。外面排队的食客再多,也只能干瞪眼。
JDK 21 的虚拟线程实现中,如果 synchronized 块内部是一个阻塞操作(比如 JDBC 查询),那么这个虚拟线程就会一直占用一个平台线程。平台线程是有限的,通常取决于 CPU 核数。假设服务器 8 核 16 线程,一旦大量虚拟线程同时进入 synchronized 块并执行数据库查询,16 个平台线程全被占住,后面的虚拟线程就彻底无法调度了。
我们的代码里确实存在这种写法,比如在查询商户信息的地方用了synchronized (cacheLock)包裹,以防止缓存击穿。问题就出在这里:同步块内部包含了 DB 查询操作,在虚拟线程环境下,这个锁会无限放大阻塞范围。
2.3 被忽略的第二元凶:数据库连接池
如果说 synchronized 让平台线程被“钉死”,那数据库连接池就是把钉死的结果变成灾难的第二根稻草。
HikariCP 默认的 maximumPoolSize 是 10。也就是说,无论你开启多少虚拟线程,能同时执行的数据库查询最多只有 10 个。100 个并发请求进来,假设每个请求都需要一个数据库连接,那么最多只有 10 个请求能拿到连接,剩下 90 个全部在连接池外面排队等待。
虚拟线程的好处在于,等待连接时它会释放平台线程,理论上不应该卡死。但如果等待的线程同时又被 synchronized 钉住,那就变成平台线程也被耗尽,整个 Tomcat 处理线程全部堵塞。两个问题叠加,100 并发直接击穿整个服务。
这里我想强调一个很多教程不会说的点:开启虚拟线程这件事只是解决了 Web 容器线程数量的问题,但它把瓶颈转移到了下游依赖上。你是用同步 JDBC 还是异步 R2DBC、连接池够不够大、锁的粒度控制得好不好,这些决定了下游瓶颈有多严重。
2.4 其他几个隐藏坑
除了上面两个主因,还有几个常见问题会让虚拟线程下的故障被进一步放大。
ThreadLocal 算一个。虚拟线程复用了平台线程来执行任务,所以同一个平台线程可能被多个虚拟线程使用。如果你在代码里用 ThreadLocal 存储用户上下文、请求 ID,这些数据可能在虚拟线程调度时被“继承”下来,造成脏数据。JDK 21 中虚拟线程支持 ThreadLocal,但建议谨慎使用,尤其是线程池场景。
Tomcat 的server.tomcat.threads.max配置也容易误导人。启用虚拟线程后,这个配置不再表示真实线程数,而是作为一个信号量控制同时处理请求的数量。默认值是 200,意思是同时最多 200 个请求进入应用逻辑。如果你设置了 50,那理论上 100 并发时会有 50 个请求直接被拒,表现也是卡死或超时。
还有并行流。IntStream.range(1, 100).parallel()使用的是公共 ForkJoinPool,默认线程数等于 CPU 核数减 1。虚拟线程模式下,并行流并不会自动使用虚拟线程,如果里面有阻塞操作,该堵还是堵。
3. 排查实录:从监控指标到 Thread Dump 定位
3.1 用接口耗时数据快速区分瓶颈
排查时不要一上来就抓 Thread Dump,先用粗粒度数据缩小范围。我在出问题的接口里加了两段耗时统计:一段记录 Service 层总耗时,一段记录数据库查询耗时。把日志打出来之后,对比非常明显:
- 正常情况:总耗时 50ms,查询耗时 40ms,误差很小。
- 故障情况:总耗时 30000ms,查询耗时 40ms,剩下 29960ms 全部消耗在“获取连接”这一步。
这意味着 SQL 本身不慢,真正慢的是连接池拿不到连接。这个数据一出来,排查方向就从“代码逻辑慢”变成了“资源竞争”,问题基本锁定在连接池或者锁上。
如果你想在代码里做类似埋点,可以参考这个方式:
long start = System.currentTimeMillis(); // 核心业务逻辑 long sqlStart = System.currentTimeMillis(); List<Order> orders = orderMapper.selectList(query); long sqlEnd = System.currentTimeMillis(); // 结束 long end = System.currentTimeMillis(); log.info("total={}ms, sql={}ms, wait={}ms", end - start, sqlEnd - sqlStart, (end - start) - (sqlEnd - sqlStart));如果 wait 部分远大于 sql 部分,那么恭喜你,问题八成不在数据库本身,而是资源获取环节。
3.2 Thread Dump 定位具体卡点在哪个锁上
粗定位之后,就需要精确到代码行。最有效的手段是抓 Thread Dump。JDK 21 提供了比较好用的命令:
jcmd <pid> Thread.dump_to_file -format=json dump.json这个命令会把所有线程(包括虚拟线程、平台线程)的堆栈输出成 JSON 文件。打开后搜关键词VirtualThread或者parkOnCarrierThread,能看到大量虚拟线程阻塞在什么位置。
以我们的情况为例,dump 文件里有大量线程栈停在这么一行:
java.lang.VirtualThread.parkOnCarrierThread再往下翻,能看到 HikariCP 的连接获取代码:
com.zaxxer.hikari.pool.HikariPool.getConnection结合 synchronized 锁的栈帧,就能定位到具体是哪个类、哪一行代码在锁里面拿了连接。这一步特别重要,因为它把“系统卡死”这个抽象问题变成了“某一行代码导致”的具体问题。
3.3 用连接池监控指标实锤
如果你启用了 Micrometer,可以直接看 Hikari 的监控指标。在配置文件里加上:
spring.datasource.hikari.metrics.enabled=true然后调用 Actuator 的/actuator/metrics/hikaricp.connections.active和/actuator/metrics/hikaricp.connections.pending两个端点。故障期间,active 指标稳定在 10(连接池上限),pending 指标飙到 80 以上。看到这个数据,基本不用再怀疑其他环节了,就是连接池被塞满。
这个排查流程建议收藏一下:先看接口耗时分布,再看连接池指标,最后抓 Thread Dump 定位锁,三步下来基本能把虚拟线程相关的坑摸得明明白白。
4. 解决方案:从参数调整到架构避坑
4.1 先治标:把连接池参数调到一个合理水位
当时为了让线上先恢复,我做了两个紧急改动。
第一是调大 HikariCP 连接池大小。这个值不能盲目调大,数据库毕竟有上限,通常经验值是核数乘 2 加磁盘数,但也要看数据库压测结果。我们的 Toufic 服务在数据库压力测试下能稳定扛住 50 个并发连接,所以我把配成了:
spring.datasource.hikari.maximum-pool-size=50 spring.datasource.hikari.minimum-idle=10 spring.datasource.hikari.connection-timeout=5000把 connectionTimeout 从默认的 30 秒调低到 5 秒,也是故意为止。与其让请求傻等 30 秒然后超时,不如快速失败,至少让用户感知到错误重试,而不是一直转圈。但注意,这只是一种取舍,如果你的下游有重试机制,这个策略是合理的;如果没有重试机制,调低连接超时会让用户直接看到报错。
第二是调整 Tomcat 的信号量限制。前面说过,server.tomcat.threads.max在虚拟线程模式下变成了信号量,默认 200。我们的服务模块较多,单机 QPS 不算高,200 本来够用,但为了保险,还是改成:
server.tomcat.threads.max=200 server.tomcat.threads.min-spare=50其实默认就是 200,我不改也没关系。但如果你的服务对并发要求高,可以把这个值调大。它不消耗平台线程,只是控制同时进入应用逻辑的请求数量,可以理解为网关处的信号量。
这两步改完,100 并发压测立刻好了很多,接口不再大面积超时,但响应时间仍然不稳定,偶尔会有 1 到 2 秒的毛刺。治标完成,接下来必须处理根因。
4.2 根因修复:从 synchronized 走向 ReentrantLock
前面提到 synchronized 在 JDK 21 虚拟线程下存在 pinning 问题,所以根因修复必须从锁入手。
JDK 24 已经通过 JEP 491 修复了 synchronized 固定虚拟线程的问题,但如果你还在用 JDK 21 或 22,这个坑必须自己绕开。绕开方式很简单:不要用 synchronized,改用 ReentrantLock。
ReentrantLock 是基于 JUC 的锁,虚拟线程阻塞在lock.lock()时不会被钉在平台线程上,它会正确地让出底层线程,等锁可用后再被调度回来。把代码里所有同步块替换成 ReentrantLock 并不复杂,但要注意几个细节。
如果锁是用来做单例初始化或者缓存更新的,可以用ReentrantLock配合tryLock做限时等待:
private final ReentrantLock lock = new ReentrantLock(); public Order queryOrder(String orderId) { if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 查缓存,查数据库 } finally { lock.unlock(); } } else { // 获取锁超时,直接查数据库或返回默认策略 } }这里有一个容易踩的坑:ReentrantLock 必须在finally中释放锁,否则一旦业务代码抛异常,锁会一直不释放,造成死锁。synchronized 依靠 JVM 自动释放,ReentrantLock 没有这个特性,团队里管不住手的人很容易写漏。
另外,如果锁的保护范围特别小,只是保护一个变量的读写,可以考虑用AtomicReference替代整个锁。虚拟线程场景下,锁的竞争成本被放大了,能不用锁就不用锁,能缩小范围就缩小范围。
4.3 架构层面:给下游资源加上限流
改完锁之后,服务已经恢复正常。但我在复盘时意识到,即使没有 synchronized 的问题,100 并发打 10 个数据库连接也只是时间问题,不一定卡死,但响应时间一定会恶化。
虚拟线程数量可以无限大,可数据库连接数量是固定的。这就好比餐厅可以有无限多把椅子(虚拟线程),但后厨只有 10 个炉子(数据库连接)。食客再多,后厨也做不出菜。
更好的做法是在连接池之上再加一层业务级信号量限流,控制“真正会访问数据库”的并发量。这里我用了一个比较稳妥的方案:
private final Semaphore dbSemaphore = new Semaphore(30); public List<Order> queryOrders(QueryRequest request) { if (!dbSemaphore.tryAcquire(3, TimeUnit.SECONDS)) { throw new BizException("系统繁忙,请稍后重试"); } try { return orderMapper.selectList(buildQuery(request)); } finally { dbSemaphore.release(); } }这样做的意义在于,不管 Tomcat 接收了多少请求、虚拟线程创建了多少个,真正打到数据库的并发会被限制在 30 以内。多出来的请求快速失败或者排队,不会把连接池和数据库打爆。
如果你的团队愿意做更大的改动,可以考虑把同步 JDBC 换成响应式客户端(R2DBC),或者引入 WebClient 处理外部 HTTP 调用。但我不建议为了虚拟线程把所有代码都改成异步范式,那等于放弃了虚拟线程带来的编程模型简化优势。对于大多数业务,虚拟线程 + 连接池限流已经是性价比很高的组合。
4.4 关于升级 JDK 24 的取舍
如果你现在还在用 JDK 21,又不想大面积改代码,可以考虑直接升级到 JDK 24。JDK 24 通过 JEP 491 解决了 synchronized 固定虚拟线程的问题,这意味着 synchronized 代码块可以放心用了。
但要注意,JDK 24 是比较新的版本,相关的生态兼容性需要确认,比如 Spring Boot 版本是否支持、字节码工具(如 Cglib 修改字节码的框架)是否兼容、监控工具是否支持 JDK 24 的线程模型。我们出于稳定考虑没有在生产环境直接升级,而是在测试环境验证过,后续再考虑。
如果你决定不升级,那么记住一条红线:在 JDK 21 / 22 下,不要在 synchronized 块内执行阻塞 IO 操作。这条红线比任何参数调优都重要,它可以解释虚拟线程环境下一半以上的“莫名卡死”问题。
5. 压测时怎么确认系统的“真实并发数”
5.1 线程组数不等于并发数
这个坑在压测虚拟线程服务时特别明显。很多人用 JMeter 压测时,习惯性认为“我设置了 100 个线程,就有 100 并发”。这个认知在虚拟线程环境下是不准确的。
JMeter 的线程数代表的是你能发起的请求线程数,但系统真正的并发度取决于活跃请求数。压测时要关注的指标是 TPS(每秒事务数)和响应时间曲线。当你不断加大线程数,TPS 先上升、然后进入平台期、最后开始下降,那个“平台期的起点”就是系统真实并发容量的参考值。
比如我们用 100、200、500 三组线程分别压测,100 线程时 TPS 稳定在 800,200 线程时 TPS 只涨到 850,500 线程时 TPS 反而跌到 600。这个结果表明系统的业务并发上限大约在 200 左右,继续加线程只会增加排队和上下文切换成本。
虚拟线程场景下,由于线程数量不是瓶颈,压测时更要注意「下游资源」是否成了隐形的限制条件。最好在压测前先确认数据库连接池最大连接数、外部接口 QPS 上限、缓存服务的连接上限,然后按照从下游到上游的顺序逐层压测。
5.2 虚拟线程压测的观察重点
压测虚拟线程服务时,我建议重点观察四个指标:
- 数据库连接池 active 和 pending 数量,看是否打满连接池。
- 平台线程(carrier)的使用率,看是否存在 pinning 问题,可以用 jstack 观察到线程名称中带有
ForkJoinPool或直接是虚拟线程调度器的线程。 - 响应时间的 P99 和 P99.9,虚拟线程在低并发下 P99 通常很好,但资源争抢激烈时 P99 会快速恶化。
- GC 指标,尤其是发生 Full GC 时的响应时间毛刺。
如果你观察到一个奇怪现象:CPU 不高、线程很多、TPS 上不去,那大概率是某个共享资源被打满了。这时候先看连接池指标,再看锁竞争,而不是傻傻地加机器。
5.3 一个可以落地的压测检查清单
我整理了一份自己每次压测前都会过的清单,这里分享出来:
| 检查项 | 预期结果 | 异常时的排查方向 |
|---|---|---|
| Web 容器信号量配置 | 实际进入逻辑的并发 ≤ 配置值 | 调大 server.tomcat.threads.max |
| 数据库连接池大小 | active 接近 maximumPoolSize 但 pending 不高 | 调大连接池或加业务限流 |
| synchronized 使用范围 | 同步块内没有阻塞 IO | 改用 ReentrantLock |
| 连接超时配置 | 最多等待 5 秒 | 优化 SQL 或加索引 |
| carrier 线程利用率 | 没有大量虚拟线程被 pinned | Thread Dump 分析栈帧 |
| 下游依赖 QPS 上限 | 压测量级在下游承受范围内 | 对下游调用做降级/重试策略 |
压测永远是在验证系统的一个真实边界,而不是验证 JMeter 线程组数据。你把虚拟线程说得再好,压测数据摆出来才能说话。
6. 常见问题速查与避坑心得
最后把我这次排查中遇到的和朋友交流时听到的典型问题整理成速查表,方便大家遇到类似问题直接对号入座。
| 现象 | 可能原因 | 首选排查手段 | 解决方案 |
|---|---|---|---|
| 100 并发就大面积超时 | Hikari 连接池打满 | 看 hikaricp.connections.active / pending | 调大连接池、加信号量限流 |
| CPU 不高但接口无响应 | synchronized 内阻塞 IO 导致 pinned | jcmd Thread.dump_to_file 分析栈帧 | 换 ReentrantLock 或升级 JDK 24 |
| 日志出现 Tomcat threads max 相关警告 | Tomcat 信号量过小 | 检查 server.tomcat.threads.max | 调大该配置,它只当信号量用 |
| ThreadLocal 值混乱 | 虚拟线程复用平台线程 | 打日志观察用户上下文 | 改用方法参数传递或谨慎清理 |
| 并行流执行很慢 | 公共 ForkJoinPool 线程不足 | 查看 ForkJoinPool 活动线程数 | 自定义线程池,不用公共池 |
| 请求快速失败大量报错 | connection-timeout 设置过短 | 查看错误码分布 | 配合重试机制,或调大超时时间 |
再补充一个我在实际踩坑中发现的细节:很多从 JDK 17 升到 JDK 21 的项目,会习惯性地用newVirtualThreadPerTaskExecutor替换原有线程池,这没问题,但要注意这个执行器默认是无限创建虚拟线程的。如果你用一个不设上限的执行器去处理消息队列任务,而每个任务都访问数据库,那连接池会被瞬间打满,表现和这次事故非常像。建议给虚拟线程执行器也加上信号量控制,或者用一个Semaphore限制提交的任务量。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); Semaphore semaphore = new Semaphore(100); public void submitTask(Runnable task) { semaphore.acquire(); executor.submit(() -> { try { task.run(); } finally { semaphore.release(); } }); }这个模式在接入虚拟线程的初期阶段非常实用,它不会让你错过虚拟线程带来的红利,同时又给系统留了一层保护。
踩过这个坑之后,我对虚拟线程的理解发生了很大变化。它确实是一项革命性的技术,让“线程成本趋近于零”变成了现实,但线程成本趋近于零不意味着所有资源成本都趋近于零。数据库连接、外部 IO 连接、锁的竞争、连接池的容量,这些共享资源的边界并不会因为虚拟线程而消失,它们只是被推到了你必须主动面对的位置。
现在团队内部有一个不成文的规定:上线前先查三件事——有没有 synchronized 包着阻塞操作、数据库连接池参数是多少、下游依赖有没有限流。如果这三样都过关,虚拟线程才真的能让我们睡得安稳。