SpringBoot4系列27 - 使用虚拟线程提升吞吐

先摆一下我遇到的问题。订单详情页有一个聚合接口:请求进来后要串行调库存、定价、物流三个下游,每次调用 200ms,接口总耗时 600ms。大促峰值要撑 3000 QPS,按排队论算容量,需要的并发处理线程数是 3000 × 0.6s = 1800 个。Tomcat 的请求线程池默认上限 200,吞吐天花板是 200 / 0.6s ≈ 333 req/s,距离目标差 5 倍。把线程池调到 2000 也能算出差不多的容量,但每个平台线程预分配 1MB 栈内存,2000 个线程就是 2GB 的固定开销,加上上下文切换和调度成本,这条路我在评审里见过太多回,基本都走到头了。

瓶颈不在 CPU:线程 99% 的时间在等下游返回,CPU 利用率不到 10%。等待型负载恰好是 Java 21 虚拟线程的目标场景。虚拟线程是 JVM 管理的轻量线程,阻塞时卸载载体线程,创建成本接近一个普通对象。

另一条路是横向扩容。3000 QPS 按 333 req/s 的单实例上限要 9 个副本,副本数翻倍的话数据库连接池、下游限流阈值都要跟着调,多付的是九份的常驻内存与运维成本。虚拟线程把「一个请求占一个 OS 线程 600ms」改成「一个请求占一个廉价虚拟线程 600ms」,单实例容量逼近并发上限,这是我看中它的地方。

Spring Boot 4.1 把它做成一个属性开关。我在 Boot 4.1.1(Spring Framework 7.0.9、JDK 21.0.10)上用同一套代码在两档配置下实测线程模型和吞吐,所有数字都是同一份验证脚本一口气测出来的,每一行原始记录我都留了底;配套工程 springboot4-virtualthreads 是订单域「调用下游汇总」接口,端口 18270。

https://static.xiongneng.me/virtualthreads-switch-surface-20260922070210.png

开关与作用面

工程依赖照旧只有 webmvc 一个 starter,虚拟线程能力在 JDK 与 Boot 里都是内置的,不需要引第三方库:

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

Boot 4 中 spring-boot-starter-web 已弃用,本系列统一用 spring-boot-starter-webmvc;内嵌 Tomcat 由 spring-boot-tomcat 模块自动配置,后文的所有取证都发生在这个模块与 spring-boot-autoconfigure 里。

开启虚拟线程只需要一个属性:

1
2
3
4
spring:
  threads:
    virtual:
      enabled: true

这个属性在 Boot 4.1.1 的属性元数据里是唯一入口,类型 Boolean,默认 false。网上流传的 spring.threads.virtual.name-prefix 在 4.1.1 的属性元数据里不存在,我第一回就把这个流传的属性抄了上去,启动日志里一条未知属性警告给我当头一棒;线程前缀由各组件自己硬编码。

开关背后的判定机制是条件注解。Boot 定义了 Threading 枚举(PLATFORM 与 VIRTUAL 两个值,位于 org.springframework.boot.thread 包)和 @ConditionalOnThreading 注解,自动配置类用它声明「这段配置只在虚拟线程启用时生效」。属性写入环境后,OnThreadingCondition 在评估时读取它决定条件走向,命中 VIRTUAL 分支的配置才会注册。这里还有一个 Boot 4 的结构性变化值得交代:条件使用点分散在 tomcat、amqp、data-redis 等多个模块里,Boot 4 按技术栈拆分了自动配置模块。我第一轮扫描就栽在这里:只 grep 了 autoconfigure 一个 jar,数出来的使用点少了一半,全量解包扫描才补齐。

把本机仓库里 Boot 4.1.1 全部模块的 jar 解包扫描,@ConditionalOnThreading 的使用点覆盖六个位置:

模块使用点影响的组件
spring-boot-tomcatTomcatWebServerConfigurationTomcat 请求处理线程
spring-boot-autoconfigureSimpleAsyncTaskExecutorBuilderConfigurationapplicationTaskExecutor(@Async 默认执行器)
spring-boot-autoconfigureSimpleAsyncTaskSchedulerBuilderConfiguration任务调度器(@Scheduled)
spring-boot-amqpRabbitAnnotationDrivenConfigurationRabbitMQ 监听器容器工厂
spring-boot-data-redisLettuceConnectionConfiguration 等Redis 连接配置
spring-boot-micrometer-metricsJvmMetricsAutoConfiguration 等虚拟线程 JVM 指标、OTLP 导出

Tomcat 的请求处理有一个经典三层结构:Acceptor 线程负责接收连接,Poller 线程负责监听 socket 可读事件,事件就绪后把请求交给执行器(Executor)里的工作线程跑完整个 Servlet 调用。平时说的「Tomcat 线程池」就是最后这一层,server.tomcat.threads.max 约束的也是它。600ms 的下游等待发生在这里:工作线程拿到请求、调下游、阻塞 600ms、写响应、回池,等待全程占着池名额。理解了这一层就明白虚拟线程的改造点为什么选在执行器上:把「池里的平台线程」换成「按需新建的虚拟线程」,Acceptor 与 Poller 保持不动。

Web 层的作用点可以直接看字节码。取证不用下载源码,本机 Maven 仓库里就有现成的 jar:

1
2
3
4
5
# 列出 jar 内虚拟线程相关的类,确认作用点的落点
unzip -l spring-boot-tomcat-4.1.1.jar | grep -i virtual

# 反汇编定制器,看它对 Tomcat 执行器做了什么
javap -c -p org.springframework.boot.tomcat.autoconfigure.TomcatVirtualThreadsWebServerFactoryCustomizer

第一条命令定位到 TomcatVirtualThreadsWebServerFactoryCustomizer,第二条看它的方法体。javap 反汇编 customize 方法,对 Tomcat 的 ProtocolHandler 设置了这样一个执行器:

1
2
3
private static void lambda$customize$0(ProtocolHandler handler) {
    handler.setExecutor(new org.apache.tomcat.util.threads.VirtualThreadExecutor("tomcat-handler-"));
}

Tomcat 11 自带的 VirtualThreadExecutor 把每个请求分派到一个新建的虚拟线程,前缀固定为 tomcat-handler-。这就是我后文压测里看到的线程名的来源。

开关打开后有一类配置会静默失效:所有配置线程池的属性。官方文档的表述是「properties which configure thread pools don’t have an effect anymore」,因为虚拟线程统一调度在 JVM 级的载体线程池上,没有专用线程池可配。受影响的包括 server.tomcat.threads.max、server.tomcat.threads.min-spare、spring.task.execution.pool.*、spring.task.scheduling.pool.*,属性元数据里 spring.task.execution.pool.keep-alive 的描述原文就带着「Doesn’t have an effect if virtual threads are enabled」。旧配置留着不报错,但读配置的人会以为线程池还在按参数工作,最容易闹笑话。

配套工程的完整配置很短,application.yml 全文如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
server:
  port: ${SERVER_PORT:18270}
  tomcat:
    threads:
      max: 20
spring:
  application:
    name: springboot4-virtualthreads
  main:
    keep-alive: true
logging:
  level:
    root: INFO

server.port 让环境变量优先,验证脚本接管端口时不用改配置;keep-alive: true 按官方建议常开,对平台线程档没有副作用;tomcat.threads.max: 20 只在平台档生效,下一节的对照实验靠它撑起差异。

另一个官方提示的副作用:虚拟线程都是守护线程。开启后 @Scheduled 的调度器线程也变成虚拟线程,JVM 里全是守护线程时会直接退出,定时任务应用可能启动完就挂。官方建议配 spring.main.keep-alive=true,Boot 会用非守护线程维持 JVM 存活。版本前置是 Java 21,官方强烈推荐 Java 24,原因在后文 pinned 一节展开;JDK 21 上运行 Boot 4.1 完全可用,本篇实测就在 21.0.10 上完成。

线程模型变化实测

配套工程把线程名直接暴露出来。GET /api/threads/status 返回开关取值和当前请求线程的 Thread.toString(),GET /api/orders/{id}/summary 串行调 3 个下游(工程内用 Thread.sleep 模拟可控延迟的阻塞调用,sleep 与 socket 读在虚拟线程上的卸载行为一致),响应里带请求线程和每个下游的执行线程。为了让两档差异可测,application.yml 把平台档的请求池上限压到 20:

1
2
3
4
server:
  tomcat:
    threads:
      max: 20

虚拟线程档下这一行自动失效,正好构成同一份配置、两种线程模型的对照组。

模拟下游的服务只有一个方法,工程内它是这样实现的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Service
public class DownstreamSimulator {
    public DownstreamResult call(String downstream, long delayMs) {
        long start = System.nanoTime();
        try {
            Thread.sleep(delayMs);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("模拟调用被中断", e);
        }
        long costMs = (System.nanoTime() - start) / 1_000_000;
        return new DownstreamResult(downstream, costMs, Thread.currentThread().toString());
    }
}

Thread.sleep 在虚拟线程上的行为与 socket 阻塞读一致:阻塞期间虚拟线程卸载载体、载体回去调度别的虚拟线程,醒来后可能挂到另一个 worker 上继续跑。返回值里的 Thread.toString() 让线程身份随响应透出,后文所有线程名证据都取自这里。工程刻意不发真实 HTTP,省掉第二台「下游服务」的部署;在请求线程里阻塞等待这一点上,sleep 与真实 RPC 的线程模型消耗相同。

控制器串行版的主体就三行调用:

1
2
3
4
5
6
7
8
@GetMapping("/api/orders/{id}/summary")
public OrderSummary summary(@PathVariable String id,
                            @RequestParam(name = "delayMs", defaultValue = "200") long delayMs) {
    downstreams.add(simulator.call("inventory", delayMs));
    downstreams.add(simulator.call("pricing", delayMs));
    downstreams.add(simulator.call("logistics", delayMs));
    return new OrderSummary(id, Thread.currentThread().toString(), downstreams, costMs);
}

三个下游依次阻塞调用,请求线程从头占到尾,requestThread 字段记录的是 Thread.currentThread() 在入口时刻的 toString()。

平台线程档(enabled=false)的返回:

http-nio-18270-exec-1 是 Tomcat 传统线程池的命名。串行接口在这个档位下连续打三个下游,响应里三个下游线程与请求线程同名同号(Thread[#53,...]),全部工作压在一个池线程上,costMs 602。虚拟线程档(enabled=true)的返回:

VirtualThread[#57,tomcat-handler-0] 是请求虚拟线程,名字前缀就是反汇编里那个 tomcat-handler-;@ 后面的 ForkJoinPool-1-worker-1 是它挂载的载体平台线程。同一个请求,两种档位下线程的身份、命名、数量级完全不同。两档的线程身份可以并排读:

字段平台线程档虚拟线程档
对象类型ThreadVirtualThread
线程名http-nio-18270-exec-1tomcat-handler-0
线程编号#53,进程内递增#57,创建即递增不复用
挂载载体自身即 OS 线程ForkJoinPool-1-worker-1
生命周期复用,跑完请求回池请求结束即丢弃

串行接口在虚拟线程档下连续打三个下游,响应里三个下游线程与请求线程同名,三个调用都在同一个虚拟线程上完成,成本 602ms。挺有意思的是虚拟线程的名字:随请求递增不复用,我压测 400 个请求拿到 400 个不同的 tomcat-handler-N,这也是后文判断「池上限已失效」的铁证。两档的完整执行时序如图:

https://static.xiongneng.me/virtualthreads-thread-model-20260922070214.png

开关只管 Web 层还不够,业务代码里经常需要程序式并发。summary-parallel 接口用 Java 21 的 API 把三个下游扇出到三个虚拟线程:

1
2
3
4
5
6
7
8
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<DownstreamResult>> futures = names.stream()
            .map(name -> executor.submit(() -> simulator.call(name, delayMs)))
            .toList();
    for (Future<DownstreamResult> future : futures) {
        downstreams.add(future.get());
    }
}

newVirtualThreadPerTaskExecutor 每提交一个任务就新建一个虚拟线程,try-with-resources 等全部任务结束后关闭。实测 delayMs=200 时串行接口 costMs 602、并行接口 costMs 202,下游线程名是 VirtualThread[#63]、VirtualThread[#64]、VirtualThread[#65] 三个独立虚拟线程。这个 API 不受开关约束,平台线程档下同样产出虚拟线程,测试类里有对应断言。

后台任务示例用另一个 API。POST /api/orders/{id}/audit 接口起一个命名虚拟线程异步写审计:

1
2
3
4
5
6
7
Thread worker = Thread.ofVirtual().name("audit-" + id).start(() -> {
    try {
        Thread.sleep(50);
    } catch (InterruptedException ignored) {
    }
});
worker.join();

实测返回 VirtualThread[#69,audit-o-1]/terminated,Thread.ofVirtual().name() 的命名生效。@Async 与虚拟线程的关系在第 12 篇展开过:开关打开后 applicationTaskExecutor 由 SimpleAsyncTaskExecutorBuilder 以 virtualThreads(true) 构建,@Async 方法自动跑在虚拟线程上,不需要改任何注解。并行接口的完整交互时序:

https://static.xiongneng.me/virtualthreads-parallel-fanout-20260922070217.png

开关作用面里还有一处面向监控的增量:micrometer 模块的 JvmMetricsAutoConfiguration 在虚拟线程档下会激活 VirtualThreadMetricsConfiguration,暴露虚拟线程的 JVM 指标(创建数、活动数、pin 次数等)。第 24 篇接好的监控体系不用改配置就能看到这些新指标,指标端点里出现虚拟线程序列,就是条件分支命中的旁路证据。

单元测试对两档线程模型各有断言,虚拟档的一个用例长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
@DisplayName("虚拟线程档:status 报 true,请求线程是 tomcat-handler- 虚拟线程")
void requestHandledByVirtualTomcatThread() {
    client.get().uri("/api/threads/status").exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.virtualEnabled").isEqualTo("true")
            .jsonPath("$.requestThread").value(v ->
                    assertThat((String) v).contains("VirtualThread[").contains("tomcat-handler-"));
}

测试类用 @SpringBootTest(properties = "spring.threads.virtual.enabled=true") 拉起独立上下文,@AutoConfigureRestTestClient 注入 Boot 4 的 RestTestClient 发真实 HTTP 请求。mvn test 共 9 个用例全绿:平台档断言请求线程含 exec- 且不含 VirtualThread,虚拟档断言请求线程含 VirtualThread[ 与 tomcat-handler-,连续 3 个请求拿到 3 个不同的虚拟线程名,并行接口的扇出线程不与请求线程重名。同一个工程、同一份主代码,两个测试类只差一个属性,线程名断言随开关整体切换,这是开关语义的直接回归证明。

压测对比

口径先交代清楚:我在同一台机器、同一轮里,两档先后各执行一次「冷启动 + 压测」。100 并发 × 400 请求,每请求串行 3 × 200ms 下游延迟,客户端是 venv Python 多线程 HTTP 客户端,预热 3 个请求后计时。机器负载有波动,两档数字必须取自同一轮才可比,跨轮对比会混入环境噪声;表里每个分位数都取自同一轮 400 个样本的中位口径(p50 即样本中位),吞吐用成功请求数除以墙钟时间。

指标平台线程档虚拟线程档
吞吐32.9 req/s157.4 req/s
客户端 p503017.4 ms610.8 ms
客户端 p993088.2 ms652.9 ms
客户端 max3089.7 ms656.8 ms
应用侧耗时中位601 ms603 ms
请求线程去重数20400
成功 / 失败400 / 0400 / 0

两档的应用侧耗时中位几乎相同(601 与 603ms),说明下游模拟本身没有变化,差异全部来自线程模型。平台档吞吐我用手头的排队公式核算过:20 个工作线程 × (1 / 0.601s) ≈ 33.3 req/s,与实测 32.9 吻合;每个请求在队列里等前面 4 轮排空,p50 停在 3000ms 出头。虚拟线程档不再有 20 个的池上限,400 个请求各占一个 tomcat-handler-N 虚拟线程,吞吐 157.4 req/s 接近 100 并发 ÷ 0.6s 的理论上限,p99 从 3088ms 压到 652.9ms,只比单请求耗时高 50ms。吞吐倍数 4.78,正好约等于线程池上限 20 与并发 100 的比值关系。

线程名样本来自压测响应:平台档去重 20 个 http-nio-18270-exec-N,虚拟档去重 400 个 VirtualThread[...tomcat-handler-N],两份样本我从响应里原样抄进正文,一个字符都没动。两档应用日志 ERROR 均为 0 行。

把池调大到 2000 再对比是另一条值得说明的对照路径。内存账面上 2000 个平台线程按默认 1MB 栈预留就是 2GB,还有调度器在 2000 个可运行实体间的切换成本;容量曲线没有变:线程数成了新的「池上限」,瓶颈从 20 挪到 2000 而已,排队结构原样保留。虚拟线程档不做这笔预留,400 个请求共享 8 个载体线程,栈按需增长,容量跟着并发走而不是跟着预留走。本篇没有实测 2000 线程档,这条对照写在这里作为决策参考,正文引用的实测数字全部来自上面这两档。

数字要按场景外推,这是我给你的判断。这个 4.78 倍来自「线程池上限是主要瓶颈、CPU 空闲」的设定;并发 100 与池上限 20 的比例决定倍数,把 server.tomcat.threads.max 调到 100 以上,两档差距会收窄到调度开销级别。虚拟线程消除的是「等」的成本,「算」的成本它一点都帮不上。

压测由一个验证脚本单次执行完成,两档各跑一轮「启动、等待就绪、压测、关停」,避免人肉操作混入变量:

1
2
3
4
# 每轮:起服务 -> 探测就绪 -> 压测 -> 强杀进程并确认端口释放
java -jar target/springboot4-virtualthreads-1.0.0.jar \
     --spring.threads.virtual.enabled=$virtual &
"$PY" stress_client.py 18270 100 400 200 result.json

客户端本身是 Python 多线程 HTTP 程序:100 个工作线程共享一个提交队列,先打 3 个预热请求再统一计时,逐请求记录客户端耗时、响应里的 requestThread 与 costMs。选自写客户端是因为本机没有 ab、wrk 这类压测工具,而 Python 线程池的 100 并发足以把 20 个 Tomcat 池线程打满。脚本输出就是上表的全部数字,我都留了底,正文与其逐字一致。

复现只需三步:mvn package -DskipTests 打出 jar,跑一遍仓库里附带的验证脚本完成两档取值,mvn test 回归线程名断言。验证脚本与两份应用日志(各含一遍启动记录与压测期间的完整日志)我都留了档,ERROR 行数在脚本末尾单独复核一遍,两档均为 0 才算本轮有效。

还有一个口径细节值得记下:p50 的 3017ms 与 610.8ms 之差,正是「排队时间」与「服务时间」的分野。排队论里并发系统有 M/M/c 近似公式,等待时间随并发与线程数之比急剧上升;实测 3017 ≈ (100/20 − 1) × 601 + 601,与 3 轮排队的估算吻合。看 p99 与 max 的接近程度还能确认没有长尾抖动,虚拟档 652.9 对 656.8,分布很紧。

https://static.xiongneng.me/virtualthreads-benchmark-20260920220845.png

pinned 与适用边界

虚拟线程的载体池默认并行度等于 CPU 核数(可用 jdk.virtualThreadScheduler.parallelism 调整),本机线程名里的 ForkJoinPool-1-worker-1 到 worker-8 就是这个默认值。载体这么少还能撑高吞吐,靠的是阻塞时卸载:虚拟线程在 socket 读、Thread.sleep、Object.wait() 等阻塞点会把载体让出来。

有一个 JDK 21 上的著名例外:synchronized 块内的阻塞。JEP 444 的实现里,JVM 按载体线程记录监视器归属,虚拟线程在 synchronized 内阻塞时无法卸载,只能连人带载体一起钉住(pin),钉住的载体不再参与调度。极端场景下所有载体都被钉住,应用停摆。JDK 21 时代的官方建议是把热点 synchronized 换成 ReentrantLock,后者不引发 pin。

JDK 24 交付了 JEP 491,JVM 改造后虚拟线程可以独立于载体持有监视器,synchronized 内阻塞时正常卸载,Object.wait() 同样处理,这一类 pin 基本清零;剩下的例外只有 native 方法与 FFM 回调中的阻塞、类初始化阻塞,以及 JDK 24 中已移除的 jdk.tracePinnedThreads 属性(它自身在关键路径打栈有缺陷,新诊断入口是 JFR 事件 jdk.VirtualThreadPinned)。这正是官方推荐 Java 24 的原因。改造的思路是把监视器的归属从「载体线程」挪给「虚拟线程自己」:阻塞时照常卸载,唤醒后重新挂载再重试获取,互斥语义不变。

热点代码从 synchronized 换成 ReentrantLock 的改法是机械的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private final ReentrantLock lock = new ReentrantLock();

public void syncCall() {
    lock.lock();
    try {
        // 原 synchronized 块内的阻塞调用
        downstream.call(200);
    } finally {
        lock.unlock();
    }
}

lock() 与 unlock() 之间的阻塞不会 pin 载体,等待的虚拟线程直接卸载;互斥语义与原来一致,多出来的是手动解锁的义务,所以 unlock 必须放在 finally 里。JDK 24 之后这段改造不再是必须项,新代码按团队习惯二选一即可。JDK 21 上我的工程实践分两步:先用 JFR 找 pin 点,把长阻塞的 synchronized 换成 ReentrantLock,能升级再升 JDK。

适用边界按负载类型划分。IO 等待型(RPC、HTTP、JDBC 查询、消息等待)收益最大,本篇 4.78 倍属于这类;CPU 密集型任务没有可让出的阻塞点,虚拟线程不会更快,还会增加调度开销。另一个边界是 ThreadLocal:百万级虚拟线程每个都持有自己的副本,缓存大对象时内存放大明显,JDK 的长期方向是 ScopedValue(JEP 446)。

还有一个结构性误区:开了开关,又把业务任务提交进 Executors.newFixedThreadPool(20) 这类固定池。池上限原样拦在中间,任务排队后仍由 20 个平台线程执行,虚拟线程的容量收益绕不过这道墙。程序式并发的正确姿势是 newVirtualThreadPerTaskExecutor 或 Thread.ofVirtual(),让「每任务一线程」的模型贯穿到业务层。

Boot 3.2 就引入了这个开关,升级到 4.1 的项目不用改属性名;变化的是内部实现与作用面归属,取证与排障时要多看几个 jar。

pin 与调度参数都有现成的运行时入口,排查时两条命令起步:

1
2
3
4
5
# 查看虚拟线程调度器的并行度与当前挂载关系
jcmd <pid> Thread.dump_to_file=/tmp/threads.json -json

# 记录一段负载,事后读 jdk.VirtualThreadPinned 事件
jcmd <pid> JFR.start duration=60s filename=/tmp/pinned.jfr

JSON 格式的线程转储里,每个虚拟线程带着载体信息与阻塞点栈;JFR 录完用 jfr print --events jdk.VirtualThreadPinned 过滤,事件里直接给出 pin 原因和耗时。JDK 21 上转储是排查虚拟线程的唯一全景工具(传统 jstack 不输出虚拟线程),JDK 24 起事件还增加了载体线程标识。

避坑指南

坑一,配置了不存在的 name-prefix 属性。 4.1.1 属性元数据里只有 spring.threads.virtual.enabled,没有 spring.threads.virtual.name-prefix;Tomcat 前缀硬编码 tomcat-handler-,SimpleAsyncTaskExecutor 的前缀沿用 spring.task.execution.thread-name-prefix(默认 task-),按组件分别查。我第一回就踩了这条。

坑二,以为开关只影响 Tomcat。 作用面覆盖 applicationTaskExecutor、任务调度器、RabbitMQ 监听器容器工厂、Redis 连接配置与 OTLP 指标导出,全量使用点见前表;评估开启影响时按这张清单过一遍,别只看 Web 层。

坑三,JDK 21 上 synchronized 内长阻塞引发 pin。 典型现场是单例服务的 synchronized 方法里做远程调用;用 JFR 的 jdk.VirtualThreadPinned 事件定位,热点代码换 ReentrantLock,或者升 JDK 24。

坑四,线程池调优参数失效后还留着。 server.tomcat.threads.max、spring.task.execution.pool.* 开启后不生效也不报错;留下的配置会把排障的人引向错误方向,开开关的同时应删掉这些参数。

坑五,纯定时任务应用开了开关后提前退出。 调度器线程变成守护线程,JVM 里没有非守护线程时直接退出;加 spring.main.keep-alive=true,Boot 用非守护线程维持存活。

坑六,把开关当加速器用在 CPU 密集接口上。 载体并行度默认等于 CPU 核数,计算型负载没有阻塞点可让出,虚拟线程不提供额外算力;先判断接口的等待时间占比,再决定要不要开。

坑七,ThreadLocal 缓存大对象叠加百万线程。 每个虚拟线程一份副本,请求级缓存(用户上下文、大字典)会被放大到百万份;改成方法参数传递或等 ScopedValue 转正。

坑八,请求线程里等自己的线程池。 平台线程档下,池内任务再提交任务回同一个池并阻塞等待,池满时互相等待会饿死(本篇模拟下游刻意用进程内 sleep 规避了这一点);虚拟线程档因每任务一线程天然规避,但代码设计上不应依赖档位。

坑九,用线程数估算容量再套到虚拟线程档。 平台线程档容量 = 池上限 ÷ 单请求耗时;虚拟线程档容量 ≈ 并发数 ÷ 单请求耗时,上限受下游与数据库连接池约束,压测口径必须包含真实下游延迟。拿平台档的容量公式去套虚拟线程档,九成会算错。

坑十,诊断工具按 JDK 版本选。 jdk.tracePinnedThreads 在 JDK 21 可用、JDK 24 已移除;JFR 事件 jdk.VirtualThreadPinned 两个版本都有,JDK 24 起事件内容增加了 pin 原因与载体线程标识,跨版本脚本要分开写。

坑十一,压测客户端并发不足会得出错误结论。 客户端线程数低于服务端瓶颈时,两档都跑不满,差异被压平;客户端并发必须高于平台档池上限才能暴露排队,压测前先确认客户端自己不是瓶颈(CPU 与连接数都要看)。

坑十二,虚拟线程异常处理沿用了线程池直觉。 虚拟线程没有被池化复用,没有「线程泄漏后任务堆积在队列」的形态,出问题直接是线程数暴涨或下游被打挂;监控里要看的是虚拟线程创建速率与下游并发,而不是传统意义上的活跃线程数。

坑十三,按平台线程的思路调 -Xss。 虚拟线程的栈是托管在堆上的轻量结构,随调用深度增长收缩,-Xss 只影响平台线程;想把百万级虚拟线程塞进固定内存,关注的是堆上限与单个请求的栈深度,而不是线程栈参数。

小结

要不要开这个开关,我的标准只有一条:请求线程的时间花在哪。等待占比高的服务(RPC、HTTP、JDBC 查询)开它是稳稳的赚,CPU 密集的服务开了白开,还多付调度开销。开之前做两件事:按作用面清单把六个位点过一遍,清掉失效的线程池参数;JDK 21 上先用 JFR 查 pin 点,长阻塞的 synchronized 换 ReentrantLock 或者升 JDK 24。程序式并发用 newVirtualThreadPerTaskExecutor 和 Thread.ofVirtual(),别再往固定池里塞。

没解决的事我也直说:本篇的下游是进程内 sleep 模拟的,真实 RPC 的序列化开销、连接池竞争、下游限流带来的长尾,都得等接上真实下游再补一轮压测才有数;ThreadLocal 大缓存在百万线程下的内存放大,我目前只有机制分析,没有实测数字。这两块我后面找机会补上。

参考链接