本文是 Spring Boot 4 系列第 12 篇 | 基于 Spring Boot 4.1.0 GA 源码 + Spring Framework 7.0.x + Tomcat 11,跟着断点走完全链路 | 预计阅读 30 分钟
文末附「总结图」与「四个核心设计思想」,排查虚拟线程问题时可直接对照。
写在前面
Spring Boot 3.x 的一个典型场景:Tomcat 默认线程池 200 个线程,1000 并发压测一来线程池就打满,任务在队列里堆积,接口延迟从 30ms 涨到 3 秒。加上 spring.threads.virtual.enabled=true 重启后,Tomcat 不再使用线程池,每个请求跑在一个虚拟线程(Virtual Thread)上,线程数不再是瓶颈。
这行配置背后,Spring Boot 4.1.0 实际做了这些事:
- 谁读取
spring.threads.virtual.enabled?凭什么判定"虚拟线程模式开启"? - Tomcat 的
ProtocolHandler是怎么被换成虚拟线程执行器的?Boot 3.x 和 Boot 4.x 的替换方式有什么不同? @Async方法跑在什么执行器上?4.1 为什么新增了spring.task.execution.propagate-context?- 虚拟线程的 pinning 问题,在 JDK 25 上还存在吗?
开始之前先做一个概念纠偏:系列第十一篇的预告里写了"VirtualThreadPerTaskExecutor 背后发生了什么"。为写这篇文章重新核对 4.1.0 源码后发现这个说法需要修正------Spring Framework 7.0 中并没有一个公开的 VirtualThreadPerTaskExecutor 类 ,Executors.newVirtualThreadPerTaskExecutor() 返回的其实是 JDK 内部的 ThreadPerTaskExecutor。Spring 生态里"每任务一个虚拟线程"这一角色的实际承担者,是 SimpleAsyncTaskExecutor.setVirtualThreads(true) 与 Tomcat 11 自带的 org.apache.tomcat.util.threads.VirtualThreadExecutor。本文按源码的真实形态来讲。
本文基于 Spring Boot 4.1.0 GA 源码(本地 v4.1.0 分支),Spring Framework 7.0.x,JDK 17 基线(虚拟线程需 JDK 21+,推荐 JDK 25)。
内容速览
- 全景图:spring.threads.virtual.enabled 触发的四条装配链路
- 判定源头:Threading 枚举与 @ConditionalOnThreading,JDK 17 上静默失效的坑
- 链路 1:Tomcat 线程池替换,VirtualThreadExecutor 的装配时机与内部实现
- 链路 2:applicationTaskExecutor 同名 Bean 二选一,@Async 执行器解析链路
- 链路 3:4.1 新增 DefaultTaskSchedulerConfiguration,调度器切到 SimpleAsyncTaskScheduler
- 4.1 新增:spring.task.execution.propagate-context,Trace Context 跨线程透传
- 链路 4:Reactor/WebFlux 的 BoundedElasticScheduler 切换
- pinning 问题:JEP 491 之后还剩什么,怎么用 JFR/jcmd 检测
- 落地注意事项:线程池配置失效、daemon 线程退出、JDK 版本选择
一、全景图:一行配置触发的四条装配链路
先看一张总览图(建议右键新标签页打开):
ini
spring.threads.virtual.enabled=true
│
▼
Threading.VIRTUAL.isActive(environment) ← 判定源头(core/spring-boot)
= 属性为 true && JDK 21+
│
└─ 被 @ConditionalOnThreading(Threading.VIRTUAL) 消费(核心条件注解)
│
├─ [链路 1] Tomcat Web 服务器(module/spring-boot-tomcat)
│ TomcatWebServerConfiguration
│ └─ tomcatVirtualThreadsProtocolHandlerCustomizer
│ └─ TomcatVirtualThreadsWebServerFactoryCustomizer
│ └─ protocolHandler.setExecutor(VirtualThreadExecutor("tomcat-handler-"))
│ │ ← Tomcat 11 自带:每请求一个虚拟线程
│ └─ server.tomcat.threads.*(max=200 等)全部失效
│
├─ [链路 2] 应用任务执行器(core/spring-boot-autoconfigure)
│ TaskExecutionAutoConfiguration
│ └─ TaskExecutorConfiguration
│ └─ applicationTaskExecutorVirtualThreads ★ Bean 名仍是 applicationTaskExecutor
│ └─ SimpleAsyncTaskExecutorBuilder(virtualThreads=true)
│ └─ SimpleAsyncTaskExecutor.setVirtualThreads(true)
│ └─ VirtualThreadDelegate(MR-JAR)→ Thread.ofVirtual()
│ └─ 每任务一个虚拟线程(@Async / MVC 异步 / GraphQL...共用)
│
├─ [链路 3] 任务调度器(4.1 新增 DefaultTaskSchedulerConfiguration)
│ TaskSchedulingAutoConfiguration
│ └─ DefaultTaskSchedulerConfiguration(@since 4.1.0)
│ └─ SimpleAsyncTaskScheduler(虚拟线程版,忽略 pool 配置)
│
├─ [链路 4] Reactor / WebFlux(module/spring-boot-reactor)
│ ReactorEnvironmentPostProcessor
│ └─ System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "true")
│
└─ [4.1 新增] @Async 上下文传播(可选,需额外开启)
spring.task.execution.propagate-context=true
└─ TaskExecutorContextPropagationConfiguration
└─ ContextPropagatingTaskDecorator
└─ ContextSnapshotFactory.captureAll().wrap(runnable)
└─ 跨线程恢复 MDC / Trace Context / Baggage
四条链路有一个共同的源头:Threading 枚举。下面一条一条拆。
二、源头:spring.threads.virtual.enabled 如何被判定
2.1 Threading 枚举:一次判定,处处复用
在 Spring Boot 3.2 之前,虚拟线程的判定逻辑散落在各处。3.2 起 Spring Boot 把"当前应用跑在哪种线程模型上"抽象成了一个枚举,4.1 沿用了这个设计:
core/spring-boot/src/main/java/org/springframework/boot/thread/Threading.java
java
public enum Threading {
PLATFORM {
@Override
public boolean isActive(Environment environment) {
return !VIRTUAL.isActive(environment);
}
},
VIRTUAL {
@Override
public boolean isActive(Environment environment) {
return environment.getProperty("spring.threads.virtual.enabled", boolean.class, false)
&& JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE);
}
};
public abstract boolean isActive(Environment environment);
}
两个关键设计:
- 判定条件 = 属性为
true且 JDK 21+ 。注意这里是硬编码的JavaVersion.TWENTY_ONE,也就是说即使你在 JDK 17 上把属性设成true,VIRTUAL.isActive()依然返回false,应用会静默退回平台线程------这是 4.x 在 JDK 17 基线下的一个坑:升级到 Boot 4 但没升级 JDK,虚拟线程配置不会生效,且没有任何报错。排查时优先确认这一点。 PLATFORM是VIRTUAL的取反 ,而不是单独去读另一个属性。这保证了两者互斥、恰好一个生效------这也是后面所有@ConditionalOnThreading双分支 Bean 能够并存的前提。
2.2 @ConditionalOnThreading:所有链路的共同入口
Threading 枚举本身不做任何装配决策,真正把判定结果翻译成"是否装配 Bean"的是条件注解:
core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/ConditionalOnThreading.java
java
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnThreadingCondition.class)
public @interface ConditionalOnThreading {
Threading value();
}
配套的 OnThreadingCondition 继承 SpringBootCondition(core/spring-boot-autoconfigure/.../condition/OnThreadingCondition.java),从注解上读出 Threading 值,调用 isActive(environment) 判定,并把结果写进条件评估报告:
java
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
Map<String, @Nullable Object> attributes = metadata
.getAnnotationAttributes(ConditionalOnThreading.class.getName());
Threading threading = (Threading) attributes.get("value");
return getMatchOutcome(context.getEnvironment(), threading);
}
版本对比 :Boot 3.2 时代还同时存在一个
@ConditionalOnVirtualThreads注解,语义与@ConditionalOnThreading(Threading.VIRTUAL)相同。在 Spring Boot 4.x 中,@ConditionalOnVirtualThreads已经被整体移除 (4.1.0 全仓库 grep 无任何引用),统一收口到@ConditionalOnThreading。如果你在迁移时看到旧的@ConditionalOnVirtualThreads报"找不到符号",改写成@ConditionalOnThreading(Threading.VIRTUAL)即可。
这个注解是本篇所有装配链路的共同入口。接下来看它的四个消费者。
三、链路 1:Tomcat 线程池替换(Web 服务器)
3.1 自动配置:一个条件化的 Customizer Bean
打开 Tomcat 的自动配置类:
module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatWebServerConfiguration.java
java
@ConditionalOnNotWarDeployment
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(WebProperties.class)
public class TomcatWebServerConfiguration {
@Bean
TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(...) {
return new TomcatWebServerFactoryCustomizer(...);
}
@Bean
@ConditionalOnThreading(Threading.VIRTUAL) // ← 虚拟线程模式才注册
TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
return new TomcatVirtualThreadsWebServerFactoryCustomizer();
}
...
}
注意两个细节:
- 类名带
s:是TomcatVirtualThreadsWebServerFactoryCustomizer(注意Threads的复数),网上不少文章写成了不带 s 的名字,4.1.0 中的正式类名以本仓库为准; - 它是一个
WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>------和TomcatWebServerFactoryCustomizer(处理server.tomcat.*配置的常规定制器)是同一族组件。
3.2 Customizer 本体:替换 ProtocolHandler 的执行器
看这个 Customizer 的全部逻辑,只有 5 行:
module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/autoconfigure/TomcatVirtualThreadsWebServerFactoryCustomizer.java
java
public class TomcatVirtualThreadsWebServerFactoryCustomizer
implements WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>, Ordered {
@Override
public void customize(ConfigurableTomcatWebServerFactory factory) {
factory.addProtocolHandlerCustomizers(
(protocolHandler) -> protocolHandler.setExecutor(new VirtualThreadExecutor("tomcat-handler-")));
}
@Override
public int getOrder() {
return TomcatWebServerFactoryCustomizer.ORDER + 1;
}
}
拆解:
setExecutor(...):Tomcat 的ProtocolHandler有一个Executor属性,默认是org.apache.tomcat.util.threads.ThreadPoolExecutor(一个 Tomcat 自研的、对server.tomcat.threads.max等配置敏感的线程池)。setExecutor就是把它整个换掉;new VirtualThreadExecutor("tomcat-handler-"):org.apache.tomcat.util.threads.VirtualThreadExecutor是 Tomcat 11 自带 的虚拟线程执行器(并非 Spring 提供的),构造参数是线程名前缀。它继承了java.util.concurrent.AbstractExecutorService,内部用计数为 1 的CountDownLatch作为停机标志:shutdown()只是countDown(),isShutdown()/awaitTermination()都基于它------注意它并不会等待在途请求收尾 (awaitTermination()在停机后立即放行),实现非常精简;getOrder()返回TomcatWebServerFactoryCustomizer.ORDER + 1(即 1):保证它在常规定制器之后 执行------先由TomcatWebServerFactoryCustomizer把server.tomcat.*配置刷到工厂上,再由它做最后的执行器替换。这样即便配置里写了server.tomcat.threads.max=500,最终也会被覆盖(当然,虚拟线程模式下这个配置本来就没有意义了)。
3.3 应用时机:什么时候调用 customize()?
断点打在 setExecutor 这一行,然后启动应用,看调用栈会走到:
module/spring-boot-tomcat/src/main/java/org/springframework/boot/tomcat/TomcatWebServerFactory.java
java
@SuppressWarnings("unchecked")
private void invokeProtocolHandlerCustomizers(ProtocolHandler protocolHandler) {
LambdaSafe
.callbacks(TomcatProtocolHandlerCustomizer.class, this.getProtocolHandlerCustomizers(), protocolHandler)
.invoke((customizer) -> customizer.customize(protocolHandler));
}
调用链是:
scss
SpringApplication.run()
└─ WebServerFactoryCustomizerBeanPostProcessor.postProcessBeforeInitialization()
└─ 把容器里所有 WebServerFactoryCustomizer 刷到 TomcatServletWebServerFactory 上
└─ TomcatWebServerFactory.getTomcatWebServer()
└─ createTomcat():new Connector(getProtocol()) 创建 ProtocolHandler 之后
└─ customizeConnector(connector)
└─ invokeProtocolHandlerCustomizers(protocolHandler)
└─ TomcatVirtualThreadsWebServerFactoryCustomizer.customize()
└─ protocolHandler.setExecutor(VirtualThreadExecutor("tomcat-handler-"))
(随后 new TomcatWebServer() → initialize() → tomcat.start() 才开始接受请求)
也就是说:Connector 的 ProtocolHandler 在启动(start())之前,执行器就被换成了虚拟线程执行器 ,之后 Tomcat 每收到一个请求,都由 VirtualThreadExecutor 创建一个虚拟线程来处理,而不是从线程池里取平台线程。
3.4 Tomcat 11 的 VirtualThreadExecutor 内部
VirtualThreadExecutor 在 Tomcat 的 tomcat-embed-core 里,源码位于 Tomcat 11 的 org.apache.tomcat.util.threads 包。它的线程创建通过 JreCompat 抽象完成:基类在 JDK <21 上直接抛 UnsupportedOperationException,而 Jre21Compat 用反射调用 JDK 21 的虚拟线程 API。反编译 Jre21Compat.createVirtualThreadBuilder 的字节码可以看到它做的事等价于:
java
// Jre21Compat(Tomcat 11,反编译还原)
public Object createVirtualThreadBuilder(String namePrefix) {
return Thread.ofVirtual().name(namePrefix, 0L); // 名称前缀 + 起始编号 0
}
public void threadBuilderStart(Object threadBuilder, Runnable task) {
((Thread.Builder) threadBuilder).unstarted(task).start();
}
而 VirtualThreadExecutor.execute(Runnable) 的逻辑等价于:
java
// org.apache.tomcat.util.threads.VirtualThreadExecutor(Tomcat 11)
public void execute(Runnable task) {
if (isShutdown()) {
throw new RejectedExecutionException(...); // 停机后拒绝新任务
}
jreCompat.threadBuilderStart(threadBuilder, task); // 为每个任务创建并启动一个虚拟线程
}
所以请求线程长这样:tomcat-handler-0、tomcat-handler-1......每一个都是 JDK 的虚拟线程。
补充 :其实 Tomcat 11 自己也原生支持虚拟线程------
AbstractEndpoint有useVirtualThreads开关(server.xml 的 Connector 上可以直接配useVirtualThreads="true"),createExecutor()时会据此直接创建VirtualThreadExecutor(线程名前缀带 endpoint 名称)。Boot 4.1 之所以绕开它、走setExecutor手动替换,是为了把开关统一收口到spring.threads.virtual.enabled这一个属性上------一次判定(Threading枚举)、处处消费,这也是全文的设计主线。
链路 1 到此完成 :spring.threads.virtual.enabled=true → Threading.VIRTUAL 判定 → @ConditionalOnThreading 注册 Customizer → setExecutor 替换执行器。
对比 Boot 3.2 :替换思路一脉相承,但 Boot 3.2 时代 Tomcat 用的是 10.1,还没有内置的
VirtualThreadExecutor,Spring Boot 在自己的TomcatVirtualThreadsWebServerFactoryCustomizer里直接protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor())。到 Boot 4.1(Tomcat 11)就简单多了------Tomcat 官方实现了虚拟线程执行器,Spring Boot 只需要一行setExecutor(new VirtualThreadExecutor("tomcat-handler-"))把它装上去。
Jetty 侧 :module/spring-boot-jetty/.../JettyVirtualThreadsWebServerFactoryCustomizer同样按@ConditionalOnThreading(Threading.VIRTUAL)装配,替换为 Jetty 自己的org.eclipse.jetty.util.thread.VirtualThreadPool。方案不同,模式相同。
四、链路 2:applicationTaskExecutor 的替换(@Async / MVC 异步)
Tomcat 的线程池换完,还有第二个组件:应用任务执行器 。@Async 方法、Spring MVC 的异步请求、WebFlux 的阻塞调用、Spring GraphQL 的异步返回值......全都挂在它身上。
4.1 入口:TaskExecutionAutoConfiguration
core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutionAutoConfiguration.java
java
@ConditionalOnClass(ThreadPoolTaskExecutor.class)
@AutoConfiguration
@EnableConfigurationProperties(TaskExecutionProperties.class)
@Import({ TaskExecutorConfigurations.TaskExecutorContextPropagationConfiguration.class, // 4.1 新增,见第五节
TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class,
TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration.class,
TaskExecutorConfigurations.TaskExecutorConfiguration.class,
TaskExecutorConfigurations.BootstrapExecutorConfiguration.class })
public final class TaskExecutionAutoConfiguration {
public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor";
}
注意它 import 了 5 个 配置类,其中第一个 TaskExecutorContextPropagationConfiguration 是 4.1 新加的(下文第五节讲)。真正决定执行器"用平台线程还是虚拟线程"的是第四个 import 的 TaskExecutorConfiguration。
4.2 核心:一个 Bean 名,两个条件分支
core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/TaskExecutorConfigurations.java
java
@Configuration(proxyBeanMethods = false)
@Conditional(OnExecutorCondition.class)
@Import({ AsyncConfigurerWrapperConfiguration.class, AsyncConfigurerConfiguration.class })
static class TaskExecutorConfiguration {
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
@ConditionalOnThreading(Threading.VIRTUAL)
SimpleAsyncTaskExecutor applicationTaskExecutorVirtualThreads(SimpleAsyncTaskExecutorBuilder builder) {
return builder.build();
}
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
@Lazy
@ConditionalOnThreading(Threading.PLATFORM)
ThreadPoolTaskExecutor applicationTaskExecutor(ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder) {
return threadPoolTaskExecutorBuilder.build();
}
}
三个关键细节:
- 两个 Bean 同名 (都是
applicationTaskExecutor),靠@ConditionalOnThreading二选一。Threading.PLATFORM是VIRTUAL的取反(第二节说过),所以两个条件恰好一个命中,不会有冲突,也不会有空窗; - 虚拟线程分支的 Bean 类型是
SimpleAsyncTaskExecutor,而不是ThreadPoolTaskExecutor------文档里明确写着:开启虚拟线程后,applicationTaskExecutor是一个"使用虚拟线程的SimpleAsyncTaskExecutor"。线程池配置(spring.task.execution.pool.*)对它完全无效; - 平台线程分支加了
@Lazy,虚拟线程分支没加。原因:虚拟线程场景下applicationTaskExecutor是 Web 栈、@Async的共同依赖,启动期就要用;而平台线程分支主要服务@Async,懒初始化可以减少启动开销(配合spring.main.lazy-initialization的完整故事见系列第十篇启动流程)。
外层的 OnExecutorCondition 决定这两个 Bean 的父配置类 是否生效------它是 AnyNestedCondition,满足其一即通过:
java
static class OnExecutorCondition extends AnyNestedCondition {
OnExecutorCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnMissingBean(Executor.class) // ① 容器里没有用户自定义 Executor
private static final class ExecutorBeanCondition {
}
@ConditionalOnProperty(value = "spring.task.execution.mode", havingValue = "force") // ② 或者强制开启
private static final class ModelCondition {
}
}
也就是说,只要容器里没有用户自定义的 Executor Bean (或者显式配置 spring.task.execution.mode=force),自动配置的执行器就会登场------这就是用户自定义线程池后 Boot 自动退让的机制(spring.task.execution.mode 是 Boot 3.5 引入的"强制装配"开关,4.1 保留)。
4.3 虚拟线程是怎么进入 SimpleAsyncTaskExecutor 的
applicationTaskExecutorVirtualThreads 的入参是 SimpleAsyncTaskExecutorBuilder。这个 Builder 本身也是自动配置出来的,而且同样按线程模式分了两份 (TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration):
java
@Bean
@ConditionalOnMissingBean
@ConditionalOnThreading(Threading.PLATFORM)
SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilder() {
return builder();
}
@Bean(name = "simpleAsyncTaskExecutorBuilder")
@ConditionalOnMissingBean
@ConditionalOnThreading(Threading.VIRTUAL)
SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilderVirtualThreads() {
return builder().virtualThreads(true); // ← 关键:虚拟线程标志
}
注意这里有个同名覆盖 的细节:两个方法 Bean 名都叫 simpleAsyncTaskExecutorBuilder,第二个方法通过 @Bean(name = ...) 显式点名。虚拟线程分支调用 builder().virtualThreads(true),把"使用虚拟线程"这一标志记录进 Builder。
SimpleAsyncTaskExecutorBuilder 位于 core/spring-boot/src/main/java/org/springframework/boot/task/,它把 virtualThreads 存在一个 @Nullable Boolean 字段里,build() 时由 configure(...) 应用到 SimpleAsyncTaskExecutor 上:
java
public SimpleAsyncTaskExecutor build() {
return configure(new SimpleAsyncTaskExecutor());
}
public <T extends SimpleAsyncTaskExecutor> T configure(T taskExecutor) {
PropertyMapper map = PropertyMapper.get();
map.from(this.virtualThreads).to(taskExecutor::setVirtualThreads); // ← 虚拟线程标志进入执行器
// ...threadNamePrefix / taskDecorator / concurrencyLimit / taskTerminationTimeout 等
return taskExecutor;
}
4.4 真正的"每任务一线程":Spring Framework 7 的 SimpleAsyncTaskExecutor
从这里开始,装配逻辑进入 Spring Framework 一侧。Spring Framework 7.0 的一个重大调整是:SimpleAsyncTaskExecutor 从 spring-context 模块搬进了 spring-core 模块 (包名 org.springframework.core.task)------它和 RetryTemplate、ContextPropagatingTaskDecorator 一样,成了 spring-core 的一部分(系列第九篇聊过的"容错进 core"是同一波迁移)。
在 7.0.8 的 SimpleAsyncTaskExecutor 中,虚拟线程开关的存储和消费是这样的:
java
private @Nullable VirtualThreadDelegate virtualThreadDelegate;
public void setVirtualThreads(boolean virtual) {
this.virtualThreadDelegate = (virtual ? new VirtualThreadDelegate() : null);
}
而每次执行任务时(execute → doExecute → newThread):
java
protected Thread newThread(Runnable task) {
if (this.virtualThreadDelegate != null) {
return this.virtualThreadDelegate.newVirtualThread(nextThreadName(), task);
}
else {
return (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task));
}
}
protected void doExecute(Runnable task) {
newThread(task).start();
}
即:每个任务 → 创建一个新的虚拟线程 → start(),这就是"每任务一线程(per-task)"语义。
VirtualThreadDelegate 的默认版本只是个占位符------构造时直接抛 UnsupportedOperationException(保证 JDK <21 上快速失败)。真正生效的是 MR-JAR(Multi-Release JAR) 里 META-INF/versions/21/ 下的实现。反编译 spring-core 7.0.8 的字节码,VirtualThreadDelegate 在 JDK 21+ 上的逻辑等价于:
java
// META-INF/versions/21/org/springframework/core/task/VirtualThreadDelegate(字节码还原)
final class VirtualThreadDelegate {
private final Thread.Builder threadBuilder; // 构造时:Thread.ofVirtual()
VirtualThreadDelegate() {
this.threadBuilder = Thread.ofVirtual();
}
public ThreadFactory virtualThreadFactory() {
return this.threadBuilder.factory(); // 无命名工厂
}
public ThreadFactory virtualThreadFactory(String threadNamePrefix) {
return this.threadBuilder.name(threadNamePrefix, 0).factory(); // 前缀 + 自增编号
}
public Thread newVirtualThread(String name, Runnable task) {
return this.threadBuilder.name(name).unstarted(task);
}
}
Thread.ofVirtual() 返回的 Thread.Builder(实际类型是 Thread.Builder.OfVirtual)背后就是 JDK 21 的虚拟线程创建 API:name(...) 设置线程名,unstarted(task) 创建未启动的虚拟线程,start() 交给 JVM 全局的 ForkJoinPool 载体线程池 调度(默认并行度 = CPU 核数)。虚拟线程的栈是堆上分配、随调用深度动态伸缩 的小栈(初始仅数百字节到几 KB,对比平台线程 -Xss 默认 1MB 的固定预留),所以百万级虚拟线程的内存开销也可控。
概念纠偏(接开头) :JDK 的
Executors.newVirtualThreadPerTaskExecutor()返回的是 JDK 内部的ThreadPerTaskExecutor,并非公开类。Spring 侧对应"每任务一个虚拟线程"的公开实现有两档:
SimpleAsyncTaskExecutor.setVirtualThreads(true)------功能最全(并发限制、TaskDecorator、优雅关闭都支持),Boot 自动配置选的就是它;VirtualThreadTaskExecutor(org.springframework.core.task,Spring 6.1 起)------极简实现,只有一个线程名前缀配置,源码只有 40 行。所以系列第十一篇预告里的"
VirtualThreadPerTaskExecutor"在 4.1.0 中并不存在,正确的名字是SimpleAsyncTaskExecutor + VirtualThreadDelegate(以及 Tomcat 侧的VirtualThreadExecutor)。
4.5 @Async 是怎么拿到这个执行器的
@Async 方法的执行器解析链路在 Spring Framework 的 AsyncExecutionAspectSupport 中:@EnableAsync(AsyncConfigurationSelector → ProxyAsyncConfiguration)会从容器注入唯一的 AsyncConfigurer Bean(AbstractAsyncConfiguration#setConfigurers,没有则跳过),把它的 getAsyncExecutor() 适配成 defaultExecutor 的 Supplier;若返回 null,再兜底按 beanFactory.getBean(TaskExecutor.class)(或名为 taskExecutor 的 Bean)查找。
Boot 4.1 在这里注册了一个桥接组件 ApplicationTaskExecutorAsyncConfigurer(TaskExecutorConfigurations 末尾,两个配置类 AsyncConfigurerConfiguration / AsyncConfigurerWrapperConfiguration 负责注册与包装):
java
static class ApplicationTaskExecutorAsyncConfigurer implements AsyncConfigurer {
private final BeanFactory beanFactory;
private final @Nullable AsyncConfigurer delegate;
@Override
public Executor getAsyncExecutor() {
Executor executor = (this.delegate != null) ? this.delegate.getAsyncExecutor() : null;
return (executor != null) ? executor : getApplicationTaskExecutor();
}
private Executor getApplicationTaskExecutor() {
return this.beanFactory.getBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
Executor.class);
}
}
逻辑很简单:用户自定义了 AsyncConfigurer 就用用户的,否则一律兜底到 applicationTaskExecutor Bean 。而 4.2 节我们看到,虚拟线程模式下这个 Bean 就是虚拟线程版的 SimpleAsyncTaskExecutor------所以:
less
@EnableAsync 装配期(spring-context):
AsyncConfigurationSelector → ProxyAsyncConfiguration(extends AbstractAsyncConfiguration)
└─ AbstractAsyncConfiguration 注入容器中唯一的 AsyncConfigurer Bean
└─ = ApplicationTaskExecutorAsyncConfigurer(Boot 注册)
└─ 其 getAsyncExecutor() 被适配成 defaultExecutor Supplier(7.x 的 configure(Supplier, Supplier) 机制)
调用期:
@Async 方法调用
└─ AsyncExecutionInterceptor.invoke()(spring-aop)
└─ determineAsyncExecutor(method):@Async 指定 qualifier 则按名查,否则取 defaultExecutor
└─ AsyncExecutionAspectSupport.doSubmit(task, executor, returnType) ← doSubmit 定义在父类
└─ ApplicationTaskExecutorAsyncConfigurer.getAsyncExecutor()
└─ applicationTaskExecutor Bean(虚拟线程版 SimpleAsyncTaskExecutor)
└─ VirtualThreadDelegate.newVirtualThread(...).start()
4.6 补充:bootstrapExecutor 别名
TaskExecutorConfigurations 里还有一个容易被忽略的 BootstrapExecutorConfiguration:
java
@Bean
static BeanFactoryPostProcessor bootstrapExecutorAliasPostProcessor() {
return (beanFactory) -> {
boolean hasBootstrapExecutor = beanFactory
.containsBean(ConfigurableApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME);
boolean hasApplicationTaskExecutor = beanFactory
.containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME);
if (!hasBootstrapExecutor && hasApplicationTaskExecutor) {
beanFactory.registerAlias(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
ConfigurableApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME);
}
};
}
Spring Framework 6.2 起的 ConfigurableApplicationContext 定义了一个 bootstrapExecutor Bean 名,专门用于后台 Bean 引导初始化 (background bootstrapping:"no background bootstrapping will be active" 若无此 Bean;配合 @Bean(bootstrap = Bean.Bootstrap.BACKGROUND) 标记,把选中的非关键 Bean 的创建挪到启动之后并行进行------Spring 6.2 为加速启动引入的机制)。Boot 在用户没有自定义 bootstrapExecutor 时,直接把 applicationTaskExecutor 注册成它的别名------虚拟线程模式下,后台初始化也就跑在虚拟线程上。这是 4.x 启动优化和虚拟线程联动的一个细节。
4.7 调度器侧(4.1 新增 DefaultTaskSchedulerConfiguration)
执行器讲完了,调度器也有一份。@EnableScheduling 场景下自动配置的 TaskScheduler 同样二选一(TaskSchedulingConfigurations.TaskSchedulerConfiguration,注意它 @Import 了一个 4.1 新增 的 DefaultTaskSchedulerConfiguration):
core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/task/DefaultTaskSchedulerConfiguration.java(@since 4.1.0)
java
@Bean(name = DEFAULT_TASK_SCHEDULER_BEAN_NAME)
@ConditionalOnBean(ThreadPoolTaskSchedulerBuilder.class)
@ConditionalOnThreading(Threading.PLATFORM)
ThreadPoolTaskScheduler taskScheduler(ThreadPoolTaskSchedulerBuilder threadPoolTaskSchedulerBuilder) {
return threadPoolTaskSchedulerBuilder.build();
}
@Bean(name = DEFAULT_TASK_SCHEDULER_BEAN_NAME)
@ConditionalOnBean(SimpleAsyncTaskSchedulerBuilder.class)
@ConditionalOnThreading(Threading.VIRTUAL)
SimpleAsyncTaskScheduler taskSchedulerVirtualThreads(
SimpleAsyncTaskSchedulerBuilder simpleAsyncTaskSchedulerBuilder) {
return simpleAsyncTaskSchedulerBuilder.build();
}
虚拟线程模式下,调度器变成 SimpleAsyncTaskScheduler(虚拟线程版,spring.task.scheduling.pool.size 等池化配置被忽略)。
五、4.1 新增:@Async 的 Trace Context 透传
Spring Boot 4.1 在 v4.1.0 分支上新增了对任务执行上下文传播的支持(对应提交 644ac088868,2025-11-27)。它解决的问题是:
虚拟线程模式下,"每请求一线程"意味着线程数不再受限,
@Async方法被大量使用。但线程变了,ThreadLocal 里的东西(MDC 日志上下文、Trace ID、Baggage)默认是带不过去的 ------子线程里打的日志没有 traceId,链路追踪到@Async边界就断了。
5.1 一行配置:spring.task.execution.propagate-context
回到第一节那张全景图里的 TaskExecutorConfigurations.TaskExecutorContextPropagationConfiguration:
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ContextSnapshot.class)
static class TaskExecutorContextPropagationConfiguration {
@Bean
@ConditionalOnProperty(name = "spring.task.execution.propagate-context", havingValue = "true")
ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
return new ContextPropagatingTaskDecorator();
}
}
- 条件一:classpath 上有 micrometer 的
io.micrometer.context.ContextSnapshot(引入io.micrometer:micrometer-tracing、io.micrometer:context-propagation等依赖时会带上); - 条件二:显式配置
spring.task.execution.propagate-context=true------默认关闭,需要 opt-in。
官方文档(documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc)的原文是:
If you're working with
@Asyncmethods and theAsyncTaskExecutoris auto-configured, you have to opt-in for context propagation using thespring.task.execution.propagate-contextproperty.
如果不用自动配置而是自定义执行器,则需要自己注册一个 ContextPropagatingTaskDecorator Bean。
5.2 装饰器源码:captureAll().wrap()
ContextPropagatingTaskDecorator 位于 spring-core(Spring Framework 6.1 加入,7.0 继续在 core):
java
public class ContextPropagatingTaskDecorator implements TaskDecorator {
private final ContextSnapshotFactory factory;
public ContextPropagatingTaskDecorator() {
this(ContextSnapshotFactory.builder().build());
}
@Override
public Runnable decorate(Runnable runnable) {
return this.factory.captureAll().wrap(runnable);
}
}
TaskDecorator 是 Spring 给"任务提交前后做手脚"留的钩子:execute() 提交任务时调用 decorate(runnable),拿返回值替换原任务再执行。这里的两个调用:
factory.captureAll():在提交方线程 上,把 micrometer 上下文注册表(io.micrometer.context.ContextRegistry)里所有登记过的ThreadLocalAccessor的当前值快照下来;snapshot.wrap(runnable):返回一个新Runnable,执行时先把快照恢复进执行线程的 ThreadLocal,跑完再清理。
5.3 哪些 ThreadLocal 会被带走:ThreadLocalAccessor SPI
"快照哪些值"由 micrometer-context 库的 ThreadLocalAccessor 接口决定(io.micrometer.context.ThreadLocalAccessor)。classpath 上的各类库通过实现它,把自己的 ThreadLocal 登记进上下文注册表:
| 场景 | ThreadLocalAccessor | 来自 |
|---|---|---|
| 日志 MDC | Slf4jThreadLocalAccessor |
io.micrometer:context-propagation |
| Baggage / 观测上下文 | ObservationAwareBaggageThreadLocalAccessor 等 |
io.micrometer:micrometer-tracing |
于是 propagate-context=true 之后,@Async 方法里打印的日志会带上提交方线程的 traceId,链路追踪在 @Async 边界不再中断。
5.4 装饰器是怎么进入执行器的
还记得 4.3 节 Builder 的代码吗?SimpleAsyncTaskExecutorBuilder 和 ThreadPoolTaskExecutorBuilder 都有 taskDecorator(...) 通道。Boot 在装配 Builder 时做了聚合(TaskExecutorConfigurations 顶部的工具方法):
java
private static @Nullable TaskDecorator getTaskDecorator(ObjectProvider<TaskDecorator> taskDecorator) {
List<TaskDecorator> taskDecorators = taskDecorator.orderedStream().toList();
if (taskDecorators.size() == 1) {
return taskDecorators.get(0);
}
return (!taskDecorators.isEmpty()) ? new CompositeTaskDecorator(taskDecorators) : null;
}
只有一个装饰器直接用;有多个则包一层 CompositeTaskDecorator(spring-core 提供)。contextPropagatingTaskDecorator Bean 就这样被注入到 ThreadPoolTaskExecutorBuilder(平台线程分支)和 SimpleAsyncTaskExecutorBuilder(虚拟线程分支)------两种线程模式下都生效。
完整链路:
ini
spring.task.execution.propagate-context=true
└─ TaskExecutorContextPropagationConfiguration(@ConditionalOnClass(ContextSnapshot.class))
└─ contextPropagatingTaskDecorator Bean
└─ TaskExecutorConfigurations.getTaskDecorator() → (Composite)TaskDecorator
└─ SimpleAsyncTaskExecutorBuilder / ThreadPoolTaskExecutorBuilder 的 taskDecorator
└─ SimpleAsyncTaskExecutor.execute()
└─ taskDecorator.decorate(task) ← 提交时快照,执行时恢复
└─ ContextSnapshotFactory.captureAll().wrap(runnable)
六、链路 4:Reactor / WebFlux 侧
WebFlux 走的不是 Tomcat,而是 Netty + Reactor。响应式编程本身不依赖虚拟线程,但 WebFlux 里的阻塞调用 (block() / 同步 JDBC)默认跑在 BoundedElasticScheduler 上。Boot 4.1 让这部分也用上虚拟线程:
module/spring-boot-reactor/src/main/java/org/springframework/boot/reactor/ReactorEnvironmentPostProcessor.java
java
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
// ...reactor-tools debug agent 相关
if (Threading.VIRTUAL.isActive(environment)) {
System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "true");
}
}
注意两点:
- 它是个
EnvironmentPostProcessor,在环境准备阶段(SpringApplication实例化之后、容器创建之前)就把系统属性设置好------Reactor 是全局静态初始化,必须在任何 Reactor 代码运行之前生效,所以不能用@Configuration这种晚一步的方式; - 设置了之后,Reactor 的
Schedulers.boundedElastic()在虚拟线程模式下会自动换成虚拟线程版本的BoundedElasticScheduler。
七、pinning 问题:JDK 24 之后还剩多少?
7.1 什么是 pinning
虚拟线程由 JVM 全局的 ForkJoinPool(载体线程池)调度。正常情况下,虚拟线程执行阻塞操作(sleep、LockSupport.park、IO 等待)时会让出载体线程,调度器把载体线程分配给别的虚拟线程------这是虚拟线程高并发的根基。
但有一种情况做不到:虚拟线程持有 synchronized 锁(monitor)时被阻塞,或者执行 JNI 原生代码时 ,载体线程无法被让出------因为 monitor 的持有者信息绑定在载体线程上,换人会导致锁语义出错。这个"虚拟线程占据载体线程不放"的现象就叫 pinning(钉扎)。
JDK 21~23 上的经典场景:
java
synchronized (lock) {
Thread.sleep(100); // JDK 21-23:载体线程被钉住 100ms,期间无法服务其他虚拟线程
}
如果某个库用 synchronized 包着长阻塞操作(历史上一些连接池/缓存库在 getConnection() 等关键路径上就有此类同步块,连接池厂商随后陆续针对虚拟线程做了适配),高峰期就会出现"虚拟线程大量创建、载体线程全部被钉住、吞吐反而下降"的反直觉现象------这就是"开了虚拟线程,连接池反而打满"的深层原因之一(配合 4.1 的 spring.datasource.connection-fetch=lazy 缓解,系列第十四篇细讲)。
7.2 JEP 491:JDK 24 起 synchronized 不再钉扎
JDK 24 通过 JEP 491「Synchronize Virtual Threads without Pinning」彻底解决了这个问题 :虚拟线程上的 synchronized 改走锁对象头部 + 虚拟线程私有锁的机制,阻塞时可以正常让出载体线程。JDK 25 延续了这一行为。
所以:
- 在 JDK 21~23 上跑 Boot 4.1:
synchronized仍是 pinning 的主要来源,需要排查(老建议依然有效:把共享锁改成ReentrantLock,或缩短同步块); - 在 JDK 24/25 上跑:
synchronized场景消失,pinning 只剩 JNI 原生调用 (某些 JDBC 驱动的 native 方法、JNA/JNI 库)这一条路。这也是官方文档强烈推荐 JDK 24+ 跑虚拟线程的原因之一 (spring-application.adoc:"For the best experience, Java 24 or later is strongly recommended")。
7.3 怎么检测 pinning
- JFR :JDK 21+ 提供
jdk.VirtualThreadPinned事件,开启 JFR 录制后,pinning 一发生就会记录(能看到被钉住的载体线程与栈); - jcmd :
jcmd <pid> Thread.dump_to_file -format=json dump.json(或 text 格式)。虚拟线程会以"virtual"标志出现在 dump 中,且能看到它"挂载"在哪个载体线程上(jstack只看平台线程,看不到虚拟线程,所以必须用jcmd);配合 JFR 的jdk.VirtualThreadPinned事件,就能把 pinning 场景定位到具体栈帧; - 线程统计 :JFR 的
jdk.VirtualThreadStart/jdk.VirtualThreadEnd事件可以统计虚拟线程创建速率------如果速率异常高且吞吐没涨,先怀疑 pinning 或阻塞范围过大。
八、落地注意事项(4.x 变化盘点)
- 线程池配置全面失效 :虚拟线程模式下,
server.tomcat.threads.*、spring.task.execution.pool.*、spring.task.scheduling.pool.*全部不再生效(文档原文:"properties which configure thread pools don't have an effect anymore" )。spring.task.execution.pool.queue-capacity等配置的 javadoc 里都标了"Doesn't have an effect if virtual threads are enabled"; - 虚拟线程是 daemon 线程 :JVM 在所有非 daemon 线程结束后就退出。如果应用依赖
@Scheduled任务或后台虚拟线程"撑着 JVM 不退出",升级后可能出现应用启动后立刻退出 的现象。解法:spring.main.keep-alive=true(Boot 3.2 引入,4.1 保留;SpringApplication内部会起一个名为keep-alive的非 daemon 线程保活,见core/spring-boot/.../SpringApplication.java:1726); - JDK 版本决定体验 :
spring.threads.virtual.enabled=true在 JDK 17 上静默不生效(Threading枚举的硬编码判定);JDK 21~23 需要警惕synchronizedpinning;JDK 24+(推荐 25)体验最佳; - 上下文传播默认关闭 :
@Async的 MDC/Trace 透传需要显式开启spring.task.execution.propagate-context=true(4.1 新增),开启后会有少量任务包裹开销------ContextPropagatingTaskDecorator的官方 javadoc 明确提示 "not recommended for applications that run lots of very small tasks",任务很小很碎的应用谨慎使用; - 连接池 :虚拟线程解决了"线程不够用",但没解决"连接不够用"------数据库连接仍然是稀缺资源。高并发下连接池打满的问题,配合 4.1 的
spring.datasource.connection-fetch=lazy(惰性连接)效果最佳,系列第十四篇细讲。
九、总结
用一张图回顾全文:
java
spring.threads.virtual.enabled=true(JDK 21+)
└─ Threading.VIRTUAL.isActive() ← core/spring-boot/.../thread/Threading.java
└─ @ConditionalOnThreading(Threading.VIRTUAL) ← 4.x 唯一入口(@ConditionalOnVirtualThreads 已移除)
├─ Tomcat:TomcatWebServerConfiguration
│ └─ TomcatVirtualThreadsWebServerFactoryCustomizer
│ └─ protocolHandler.setExecutor(Tomcat 11 VirtualThreadExecutor("tomcat-handler-"))
│ └─ Thread.ofVirtual().name(prefix, 0).unstarted(task).start() (每请求一虚拟线程)
├─ 执行器:TaskExecutionAutoConfiguration → TaskExecutorConfiguration
│ └─ applicationTaskExecutor(同名 Bean 二选一)
│ ├─ VIRTUAL → SimpleAsyncTaskExecutorBuilder.virtualThreads(true)
│ │ └─ SimpleAsyncTaskExecutor.setVirtualThreads → VirtualThreadDelegate(MR-JAR)
│ │ └─ Thread.ofVirtual():每任务一虚拟线程(@Async / MVC 异步 / GraphQL)
│ └─ PLATFORM → ThreadPoolTaskExecutor(@Lazy,池化配置仍生效)
├─ 调度器:DefaultTaskSchedulerConfiguration(@since 4.1.0)
│ └─ SimpleAsyncTaskScheduler(虚拟线程版,忽略 pool 配置)
├─ Reactor:ReactorEnvironmentPostProcessor
│ └─ reactor.schedulers.defaultBoundedElasticOnVirtualThreads=true
└─ 4.1 新增:spring.task.execution.propagate-context=true
└─ ContextPropagatingTaskDecorator
└─ ContextSnapshotFactory.captureAll().wrap() → @Async 跨线程恢复 MDC / Trace / Baggage
四个核心设计思想:
- 一次判定、处处消费 :
Threading枚举 +@ConditionalOnThreading把"是否虚拟线程模式"收敛成唯一判定源,四条装配链路全部复用,避免判定逻辑散落各处; - 同名 Bean + 条件分支 :
applicationTaskExecutor用同一个 Bean 名承载两种实现,靠互斥条件二选一------调用方无感,这是 Spring 条件装配的典型范式; - 能下沉 core 就下沉 core :
SimpleAsyncTaskExecutor、ContextPropagatingTaskDecorator在 Spring Framework 7 都搬进了spring-core,虚拟线程基础设施成为所有框架的公共底座; - 分而治之 :pinning 交给 JVM(JEP 491)解决,ThreadLocal 跨线程丢失交给
propagate-context一行配置解决,连接池打满交给connection-fetch=lazy解决(系列第十四篇)------Boot 4.1 的虚拟线程是"框架 + JVM + 生态"三方合力的结果。