假设你维护一个 Spring Boot 聚合接口。一次请求会经历三段工作:

应用原来使用 200 个请求线程,HikariCP 最大连接数是 50,下游风控接口只允许 40 个并发。升级到 Java 24 以后,团队加入一行配置:
复制代码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 秒、重复三次;请求采用相同到达率,超时与重试配置保持一致。
复制代码static ExecutorService newExecutor(boolean virtual) {
return virtual
? Executors.newVirtualThreadPerTaskExecutor()
: Executors.newFixedThreadPool(200);
}
复制代码static String waitingTask() throws InterruptedException {
Thread.sleep(Duration.ofMillis(100));
return "ok";
}
这组负载主要检验“等线程”的成本。固定线程池会分批处理,虚拟线程允许更多任务同时进入等待。它只覆盖 JDK 可挂起等待,不代表网络驱动、连接池、native 或 FFM 行为。
复制代码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 已经承担数据库连接容量管理。只有需要更早失败、限制等待者数量、按租户分配预算或把某类请求隔离时,才值得在业务入口增加另一层准入控制。
复制代码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 个并发调用,可以在调用点表达这个预算:
复制代码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 个线程”。当下游扩容、租户配额变化或超时策略调整时,维护者知道自己改的是哪一种资源预算。
类似地:
Spring Boot 的虚拟线程功能要求 Java 21+,当前文档强烈建议 Java 24+。配置通常很简单:
复制代码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 日。