Spring Boot 4 虚拟线程源码剖析:Tomcat 线程池是怎么被换掉的

本文是 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 实际做了这些事:

  1. 谁读取 spring.threads.virtual.enabled?凭什么判定"虚拟线程模式开启"?
  2. Tomcat 的 ProtocolHandler 是怎么被换成虚拟线程执行器的?Boot 3.x 和 Boot 4.x 的替换方式有什么不同?
  3. @Async 方法跑在什么执行器上?4.1 为什么新增了 spring.task.execution.propagate-context?
  4. 虚拟线程的 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,虚拟线程配置不会生效,且没有任何报错。排查时优先确认这一点。
  • PLATFORMVIRTUAL 的取反 ,而不是单独去读另一个属性。这保证了两者互斥、恰好一个生效------这也是后面所有 @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();
	}
	...
}

注意两个细节:

  1. 类名带 s :是 TomcatVirtualThreadsWebServerFactoryCustomizer(注意 Threads 的复数),网上不少文章写成了不带 s 的名字,4.1.0 中的正式类名以本仓库为准;
  2. 它是一个 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.VirtualThreadExecutorTomcat 11 自带 的虚拟线程执行器(并非 Spring 提供的),构造参数是线程名前缀。它继承了 java.util.concurrent.AbstractExecutorService,内部用计数为 1 的 CountDownLatch 作为停机标志:shutdown() 只是 countDown(),isShutdown()/awaitTermination() 都基于它------注意它并不会等待在途请求收尾 (awaitTermination() 在停机后立即放行),实现非常精简;
  • getOrder() 返回 TomcatWebServerFactoryCustomizer.ORDER + 1 (即 1):保证它在常规定制器之后 执行------先由 TomcatWebServerFactoryCustomizerserver.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-0tomcat-handler-1......每一个都是 JDK 的虚拟线程。

补充 :其实 Tomcat 11 自己也原生支持虚拟线程------AbstractEndpointuseVirtualThreads 开关(server.xml 的 Connector 上可以直接配 useVirtualThreads="true"),createExecutor() 时会据此直接创建 VirtualThreadExecutor(线程名前缀带 endpoint 名称)。Boot 4.1 之所以绕开它、走 setExecutor 手动替换,是为了把开关统一收口到 spring.threads.virtual.enabled 这一个属性上------一次判定(Threading 枚举)、处处消费,这也是全文的设计主线。

链路 1 到此完成 :spring.threads.virtual.enabled=trueThreading.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();
	}

}

三个关键细节:

  1. 两个 Bean 同名 (都是 applicationTaskExecutor),靠 @ConditionalOnThreading 二选一。Threading.PLATFORMVIRTUAL 的取反(第二节说过),所以两个条件恰好一个命中,不会有冲突,也不会有空窗;
  2. 虚拟线程分支的 Bean 类型是 SimpleAsyncTaskExecutor ,而不是 ThreadPoolTaskExecutor------文档里明确写着:开启虚拟线程后,applicationTaskExecutor 是一个"使用虚拟线程的 SimpleAsyncTaskExecutor"。线程池配置(spring.task.execution.pool.*)对它完全无效;
  3. 平台线程分支加了 @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 的一个重大调整是:SimpleAsyncTaskExecutorspring-context 模块搬进了 spring-core 模块 (包名 org.springframework.core.task)------它和 RetryTemplateContextPropagatingTaskDecorator 一样,成了 spring-core 的一部分(系列第九篇聊过的"容错进 core"是同一波迁移)。

在 7.0.8 的 SimpleAsyncTaskExecutor 中,虚拟线程开关的存储和消费是这样的:

java 复制代码
private @Nullable VirtualThreadDelegate virtualThreadDelegate;

public void setVirtualThreads(boolean virtual) {
	this.virtualThreadDelegate = (virtual ? new VirtualThreadDelegate() : null);
}

而每次执行任务时(executedoExecutenewThread):

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(AsyncConfigurationSelectorProxyAsyncConfiguration)会从容器注入唯一的 AsyncConfigurer Bean(AbstractAsyncConfiguration#setConfigurers,没有则跳过),把它的 getAsyncExecutor() 适配成 defaultExecutorSupplier;若返回 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-tracingio.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 @Async methods and the AsyncTaskExecutor is auto-configured, you have to opt-in for context propagation using the spring.task.execution.propagate-context property.

如果不用自动配置而是自定义执行器,则需要自己注册一个 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),拿返回值替换原任务再执行。这里的两个调用:

  1. factory.captureAll():在提交方线程 上,把 micrometer 上下文注册表(io.micrometer.context.ContextRegistry)里所有登记过的 ThreadLocalAccessor 的当前值快照下来;
  2. 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 的代码吗?SimpleAsyncTaskExecutorBuilderThreadPoolTaskExecutorBuilder 都有 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(载体线程池)调度。正常情况下,虚拟线程执行阻塞操作(sleepLockSupport.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 变化盘点)

  1. 线程池配置全面失效 :虚拟线程模式下,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";
  2. 虚拟线程是 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);
  3. JDK 版本决定体验 :spring.threads.virtual.enabled=true 在 JDK 17 上静默不生效(Threading 枚举的硬编码判定);JDK 21~23 需要警惕 synchronized pinning;JDK 24+(推荐 25)体验最佳;
  4. 上下文传播默认关闭 :@Async 的 MDC/Trace 透传需要显式开启 spring.task.execution.propagate-context=true(4.1 新增),开启后会有少量任务包裹开销------ContextPropagatingTaskDecorator 的官方 javadoc 明确提示 "not recommended for applications that run lots of very small tasks",任务很小很碎的应用谨慎使用;
  5. 连接池 :虚拟线程解决了"线程不够用",但没解决"连接不够用"------数据库连接仍然是稀缺资源。高并发下连接池打满的问题,配合 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

四个核心设计思想:

  1. 一次判定、处处消费 :Threading 枚举 + @ConditionalOnThreading 把"是否虚拟线程模式"收敛成唯一判定源,四条装配链路全部复用,避免判定逻辑散落各处;
  2. 同名 Bean + 条件分支 :applicationTaskExecutor 用同一个 Bean 名承载两种实现,靠互斥条件二选一------调用方无感,这是 Spring 条件装配的典型范式;
  3. 能下沉 core 就下沉 core :SimpleAsyncTaskExecutorContextPropagatingTaskDecorator 在 Spring Framework 7 都搬进了 spring-core,虚拟线程基础设施成为所有框架的公共底座;
  4. 分而治之 :pinning 交给 JVM(JEP 491)解决,ThreadLocal 跨线程丢失交给 propagate-context 一行配置解决,连接池打满交给 connection-fetch=lazy 解决(系列第十四篇)------Boot 4.1 的虚拟线程是"框架 + JVM + 生态"三方合力的结果。

相关推荐
掘金码甲哥1 小时前
WorkBuddy 直连 DeepSeek v4 Flash,一发截图就崩?我们在网关层一招根治
后端
用户239526180101 小时前
别只会画流程图!彻底搞懂 Spring AI Alibaba Graph 的节点、边与 State
后端
掘金者阿豪1 小时前
接口数据传输优化实战:JSON Gzip 与 Protobuf 的深度对比
后端
用户608186527901 小时前
Avalonia UI 样式进阶实战:外置样式 + MVVM 主题切换 + 样式优先级全解析
后端
掘金者阿豪1 小时前
达梦VS金仓:真正做一次Oracle迁移,才知道工具链有多重要
后端
刘立军1 小时前
中心化配置与 I18n:严格禁止 AI 魔法值与硬编码参数
人工智能·后端·架构
Y001112361 小时前
springboot+vue项目实战
vue.js·spring boot·后端
Csvn1 小时前
📊 SQL 入门 Day 25:查询优化实战 —— 从 EXPLAIN 到索引的完整排查流
后端·sql
用户298698530141 小时前
将 Excel 表格转换为图片的三种实用方法
人工智能·后端·excel