假设你维护一个 Spring Boot 聚合接口。一次请求会经历三段工作:
- 从 MySQL 读取用户和订单;
- 调用两个下游 HTTP 服务;
- 对结果做一段 JSON 转换和签名计算。
应用原来使用 200 个请求线程,HikariCP 最大连接数是 50,下游风控接口只允许 40 个并发。升级到 Java 24 以后,团队加入一行配置:
properties
spring.threads.virtual.enabled=true
压测时需要重点观察三组信号:入口侧等待是否减少、in-flight 请求是否上升、数据库连接等待和下游超时是否随之变化。
把这个场景放进技术面试,我会继续追问:线程变便宜后,原来由线程池顺手承担的并发上限去了哪里?
本文用一份可核算的容量推演和最小实验框架回答这个问题。只讨论版本、负载、等待点、资源预算和观测指标,不提供未经实际运行的性能数字。
实验前提:先锁死版本与容量模型
虚拟线程是由 JDK 调度的轻量级 Thread。在 JDK 支持卸载的阻塞点,它可以释放载体线程,让大量等待型任务继续使用同步代码写法。它提高并发承载能力,不会缩短 SQL、扩容数据库或增加 CPU 核数。
版本条件也要固定。JDK 21-23 在 synchronized 内执行阻塞操作可能固定载体线程;JEP 491 已在 JDK 24 消除绝大多数这类 pinning。native、FFM 与剩余边界仍要结合运行版本、JFR 和线程转储验证。
下面的容量数字只用于建立实验假设:1000 个请求已被容器接受,旧执行器有 200 个平台线程,HikariCP 最大连接数为 50,每个请求都要查一次数据库。若最先进入业务代码的 200 个请求几乎同时申请连接,那么简化快照里有 50 个持有连接、约 150 个等待连接,其余请求仍在入口侧。真实 Tomcat 还受 maxConnections、acceptCount 和自定义 executor 影响。
切换为每任务一个虚拟线程后,更多请求可能更早到达 HikariCP,但数据库仍只能同时借出 50 个连接。实验要分别记录入口等待、连接等待与获取超时,不能把它们合并成一个"并发提升"数字。
三组最小实验
虚拟线程 Demo 最常见的写法是并发执行一万个 Thread.sleep()。它能证明等待可以被便宜地承载,却无法代表一个有数据库、有 CPU 工作、有下游限额的服务。
更有用的实验应该只改变负载类型,保持机器、JDK、预热时间和请求到达模型一致。
先固定实验条件:使用同一台机器和同一 JDK,分别创建 newFixedThreadPool(200) 与 newVirtualThreadPerTaskExecutor();每组预热 30 秒、采样 60 秒、重复三次;请求采用相同到达率,超时与重试配置保持一致。
java
static ExecutorService newExecutor(boolean virtual) {
return virtual
? Executors.newVirtualThreadPerTaskExecutor()
: Executors.newFixedThreadPool(200);
}
实验一:可挂起等待基线
java
static String waitingTask() throws InterruptedException {
Thread.sleep(Duration.ofMillis(100));
return "ok";
}
这组负载主要检验"等线程"的成本。固定线程池会分批处理,虚拟线程允许更多任务同时进入等待。它只覆盖 JDK 可挂起等待,不代表网络驱动、连接池、native 或 FFM 行为。
实验二:容量受限的 I/O
java
private static final Semaphore DB_BUDGET = new Semaphore(50);
static String databaseLikeTask() throws Exception {
DB_BUDGET.acquire();
try {
Thread.sleep(Duration.ofMillis(100));
return "row";
} finally {
DB_BUDGET.release();
}
}
这里的 Semaphore 仅建模 50 个并发许可;真实项目继续由 HikariCP 管理连接。无论启动多少虚拟线程,真正同时进入关键区的任务仍是 50 个。若要单独测试快速拒绝,再把 acquire() 换成显著短于任务服务时间的 tryAcquire(),并把拒绝数作为独立结果记录。
真实项目里,HikariCP 已经承担数据库连接容量管理。只有需要更早失败、限制等待者数量、按租户分配预算或把某类请求隔离时,才值得在业务入口增加另一层准入控制。
实验三:CPU 计算
java
static long cpuTask(byte[] payload) {
long hash = 0;
for (int round = 0; round < 20_000; round++) {
for (byte value : payload) {
hash = (hash * 31) ^ value;
}
}
return hash;
}
这组任务持续占用 CPU,没有可卸载的 I/O 等待。虚拟线程不会突破核心数限制。大量 CPU 任务更适合交给受控的平台线程池,避免 runnable 任务无限增长。
结果表
总耗时只占一列,结果表至少保留下面这些指标:
| 维度 | 指标 |
|---|---|
| 用户结果 | 成功 RPS、P50/P95/P99、超时率、错误率 |
| 任务状态 | in-flight、开始执行数、等待数、拒绝数 |
| 数据库 | active、idle、pending、acquire timeout |
| 下游服务 | 并发数、429、5xx、调用耗时 |
| JVM | CPU、堆分配、GC、线程转储、JFR 事件 |
| 虚拟线程调度器(JDK 24+) | target parallelism、pool size、mounted estimate、queued estimate |
JDK 24+ 可通过 VirtualThreadSchedulerMXBean 或 jcmd Thread.vthread_scheduler 读取调度器数据。mounted 与 queued 是估计值,可能返回 -1;queued 只表示等待开始或恢复执行的虚拟线程,不包含停在 HikariCP、Semaphore 或 I/O 上的全部任务。迁移是否有效,应按预先定义的 SLO、容量和成本目标判断;吞吐、等待与资源成本不必同时朝同一方向变化。
并发预算应该跟着资源走
平台线程昂贵,所以过去常用固定线程池同时做两件事:复用线程,以及限制进入系统的任务数量。虚拟线程很便宜,不需要池化复用;但第二件事仍然存在。
假设远程风控服务明确只允许 40 个并发调用,可以在调用点表达这个预算:
java
final class RiskClientGate {
private final Semaphore permits = new Semaphore(40);
private final RiskClient client;
RiskClientGate(RiskClient client) {
this.client = client;
}
RiskResult check(Order order) throws Exception {
if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
throw new OverloadedException("risk service is busy");
}
try {
return client.check(order);
} finally {
permits.release();
}
}
}
这个类表达"风控服务的容量是 40",而不是"线程池刚好有 40 个线程"。当下游扩容、租户配额变化或超时策略调整时,维护者知道自己改的是哪一种资源预算。
类似地:
- JDBC 用连接池表达数据库会话容量;
- HTTP 客户端用连接池或信号量表达最大在途并发,速率限制器单独表达单位时间调用量;
- CPU 密集计算用固定大小的计算池表达核心预算;
- 消息消费还要服从分区数、预取和处理时限;
- 过载时用明确的拒绝、降级或排队策略,而不是让等待者无限堆积。
Spring Boot 开关后的四个检查点
Spring Boot 的虚拟线程功能要求 Java 21+,当前文档强烈建议 Java 24+。配置通常很简单:
yaml
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true
keep-alive 用于避免应用只剩虚拟守护线程时提前退出,使用 @Scheduled 等后台任务的服务尤其需要确认。
配置生效后,检查下面四件事:
一,线程池参数是否失去原有语义。 虚拟线程使用 JVM 范围的调度器,部分传统 task executor pool size 配置不会继续按原方式控制并发。
二,ThreadLocal 放了什么。 传递 Trace ID 和用户 ID 与缓存大对象、客户端、formatter 是两类问题。每任务一线程后,后者的分配成本可能被放大。
三,CPU 工作是否混在请求线程里。 大文件压缩、加密、图像处理、复杂模板渲染要单独设计算力预算。
四,压测是否使用同一种到达模型。 固定并发用户和固定到达率会给出不同排队行为。迁移前后的测试必须保持请求模型、连接池、超时和重试设置一致。
收尾
虚拟线程让等待任务更便宜,也让真实瓶颈更难被线程池遮住。
如果你在准备 Java 后端、并发或系统设计面试,可以把目标 JD 和自己的项目经历放进 offer.cc,让 AI 面试助手围绕实验条件、资源预算、观测证据和回滚继续追问,把项目经验整理成可验证的技术决策。
迁移前,等待可能集中在 executor;迁移后,等待可能来到 HikariCP 或 HTTP 客户端,下游可能直接拒绝,CPU 可能出现 runnable 竞争,峰值 live set 也可能上升。这些现象要用不同指标观察,不能都写成"队列"。
上线评审至少填清:入口如何接纳请求、每个资源的最大在途任务、等待或拒绝位置、过载动作、回滚开关。五项都有数据,再进入灰度。
资料核对时间:2026 年 7 月 10 日。
中文参考
- 虚拟线程常见问题总结|JavaGuide
- Java 虚拟线程实战指南:从 Thread API 到 Spring Boot 高并发应用|掘金
- jdk21 到 jdk25,为什么 Java 圈仍在使用老线程池?|知乎