本节目标:把
ThreadPoolTaskExecutor的参数、队列策略与扩容行为讲到可预测,说清@Async默认执行器的解析链与风险,并给出用TaskDecorator传递 MDC / 安全上下文的可运行写法。
适用版本:Spring Boot 4.1.x(Java 21)
7.1 讲了「什么时候执行」,7.2 讲了「换一种线程执行会怎样」。本节回到平台线程:当不能或不想全量切虚拟线程时,池化执行器的参数、队列、拒绝策略与上下文传递才是生产上真正会出事的地方。
沿用订单场景。上一节的 OrderNotificationListener 需要一条专用的通知线程池——不能和主业务抢 applicationTaskExecutor,还要把请求的 traceId 与登录用户带过去。
7.3.1 ThreadPoolTaskExecutor 的核心参数
org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor 是对 JDK ThreadPoolExecutor 的包装,它本身不实现线程池,只负责组装参数并暴露监控方法。核心 setter(本机 spring-context-7.0.9.jar javap 核实):
| 参数 | setter | 含义 | Boot 默认值 |
|---|---|---|---|
| 核心线程数 | setCorePoolSize(int) | 常驻线程数 | 8(spring.task.execution.pool.core-size) |
| 最大线程数 | setMaxPoolSize(int) | 队列满后可扩容到的上限 | 无界(Integer.MAX_VALUE) |
| 队列容量 | setQueueCapacity(int) | 等待队列长度 | 无界(Integer.MAX_VALUE) |
| 空闲存活 | setKeepAliveSeconds(int) | 超出核心数的线程空闲回收时间 | 60s |
| 核心线程超时 | setAllowCoreThreadTimeOut(boolean) | 核心线程也允许超时回收 | true |
| 预热 | setPrestartAllCoreThreads(boolean) | 启动时预建全部核心线程 | — |
| 严格提前停机 | setStrictEarlyShutdown(boolean) | 上下文关闭时是否提前中止 | — |
| 装饰器 | setTaskDecorator(TaskDecorator) | 包装每个任务 | — |
监控方法也在这里(生产排障常用):getPoolSize()、getQueueSize()、getActiveCount(),以及拿到底层实例的 getThreadPoolExecutor()。
注意 Boot 默认值是「核心 8、队列无界、最大无界」。队列无界意味着最大线程数永远用不上——任务先堆进队列,maxPoolSize 形同虚设。这是最常见的配置误解,下一小节从源码分支上解释原因。
7.3.2 队列策略与扩容行为
ThreadPoolTaskExecutor.createQueue(int) 的分支被反编译出来是这样的(本机 javap -c 读到 LinkedBlockingQueue 与 SynchronousQueue 两个 new):
protected BlockingQueue<Runnable> createQueue(int queueCapacity) {
if (queueCapacity > 0) {
return new LinkedBlockingQueue<>(queueCapacity); // 有界队列
}
return new SynchronousQueue<>(); // 不存储元素
}
于是扩容行为分两种:
| 队列容量 | 队列类型 | 扩容顺序 | 后果 |
|---|---|---|---|
> 0 | LinkedBlockingQueue | 核心 → 队列 → 扩容到 max | 有界,行为可控,推荐 |
= 0 | SynchronousQueue | 核心 → 直接扩容到 max | 每个任务都要立即有线程接手,否则触发拒绝策略 |
| 无界(Boot 默认) | LinkedBlockingQueue(MAX) | 核心 → 队列(几乎永不扩容) | maxPoolSize 失效,任务可能无限堆积 |
JDK ThreadPoolExecutor 的提交顺序是固定的:先看核心线程是否已满,未满就新建;核心满则尝试入队;入队失败才扩容到 maxPoolSize;再失败才走拒绝策略。所以队列越「能装」,线程越不会扩容。想要「高峰扩容」的语义,必须给队列一个有限容量(例如 100~1000,按任务耗时与可接受延迟定),让队列先满、再触发扩容。
拒绝策略由 RejectedExecutionHandler 决定,ThreadPoolTaskExecutor 通过 ExecutorConfigurationSupport 设置。默认是 JDK 的 AbortPolicy(抛 RejectedExecutionException),这个异常会冒泡到调用方——异步任务被拒时不能静默吞掉。
7.3.3 @Async 默认执行器的解析链与风险
@Async 的执行器不是写死的,而是一段解析链。AsyncExecutionInterceptor 继承 AsyncExecutionAspectSupport(本机 spring-aop-7.0.9.jar javap 核实),关键方法有两个:
determineAsyncExecutor(Method):先看getExecutorQualifier(method)(即@Async("xxx")里的限定符),有则findQualifiedExecutor(beanFactory, qualifier)按名字取 bean;没有则getDefaultExecutor(beanFactory)。getDefaultExecutor(BeanFactory):找容器里唯一的TaskExecutorbean;找不到就回退到常量DEFAULT_TASK_EXECUTOR_BEAN_NAME(本机 javap 核实其值为"taskExecutor")。
在 Spring Boot 里,TaskExecutionAutoConfiguration 会注册名为 applicationTaskExecutor 的 bean(APPLICATION_TASK_EXECUTOR_BEAN_NAME,javap 核实其值为 applicationTaskExecutor),并通过 AsyncConfigurer 把它提供给 @Async。所以开箱即用时,@Async 跑在这个执行器上。
由此带来三个风险:
- 共享一个池。 所有
@Async方法共用一个执行器,一个慢任务(如调下游超时)会把池占满,拖垮其他异步任务。 - 无界队列掩盖过载。 默认队列无界,任务只增不减,表现为内存上涨而非快速失败。
- 异常被吞。
@Async的返回类型若不是Future,方法内抛出的异常不会传播给调用方,只会交给AsyncUncaughtExceptionHandler(默认是SimpleAsyncUncaughtExceptionHandler,javap 核实存在)。生产上要自定义处理器并记日志,否则异步任务失败会悄无声息。
自定义默认执行器有两条路:实现 AsyncConfigurer.getAsyncExecutor()(javap 核实方法签名),或直接声明一个 TaskExecutor bean。前者更集中:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(8);
exec.setMaxPoolSize(32);
exec.setQueueCapacity(200);
exec.setThreadNamePrefix("app-async-");
exec.initialize();
return exec;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("async failed: {}", method.getName(), ex);
}
}
7.3.4 SimpleAsyncTaskExecutor 为什么不适合生产
org.springframework.core.task.SimpleAsyncTaskExecutor 位于 spring-core-7.0.9.jar(本机 javap 核实,它继承 CustomizableThreadCreator,实现 AsyncTaskExecutor、AutoCloseable)。名字里没有「Pool」,因为它不池化:每个任务调用 newThread(...) 新建一条线程,执行完即结束。
它在 Spring Boot 里的角色很特殊:默认执行器是池化的 ThreadPoolTaskExecutor,但开启虚拟线程后默认执行器会切换成配置了虚拟线程的 SimpleAsyncTaskExecutor——因为虚拟线程不需要池化。
在平台线程模式下用它的问题:线程创建与销毁成本高、线程数无硬上限。它确实提供了限流开关(javap 核实):setConcurrencyLimit(int)、setRejectTasksWhenLimitReached(boolean)、setTaskTerminationTimeout(long)、setCancelRemainingTasksOnClose(boolean),以及 setVirtualThreads(boolean)。但即便如此,平台线程场景下仍应优先用 ThreadPoolTaskExecutor:复用线程、参数语义清晰、可监控。SimpleAsyncTaskExecutor 的合理位置只有两个——虚拟线程执行器,或轻量脚本里的一次性异步。
7.3.5 TaskDecorator 传递上下文
org.springframework.core.task.TaskDecorator 只有一个方法(本机 javap 核实):
public interface TaskDecorator {
Runnable decorate(Runnable runnable);
}
执行器在提交前对每个任务调用 decorate,返回一个新的 Runnable。这是唯一的官方扩展点,用来解决「ThreadLocal 不跨线程」的经典问题:请求线程里的 MDC traceId、SecurityContextHolder 里的登录用户,在异步线程里默认都是空的。
MDC 的传递可以直接手写:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> context = MDC.getCopyOfContextMap(); // 提交线程的 MDC
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
if (context != null) {
MDC.setContextMap(context);
}
try {
runnable.run();
} finally {
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
};
}
}
关键是 finally 里恢复现场:线程会被复用,不清理就会把上一个任务的上下文留给下一个。安全上下文同理,Spring Security 提供了现成的包装类(本机 spring-security-core-7.1.1.jar javap 核实):DelegatingSecurityContextRunnable、DelegatingSecurityContextCallable、DelegatingSecurityContextExecutor、DelegatingSecurityContextExecutorService。注意本机 7.1.1 里没有 DelegatingSecurityContextTaskDecorator 这个类——别按印象写,跨线程安全上下文要么用上面的 DelegatingSecurityContext*,要么用下一小节的通用机制。
把装饰器装上执行器:
@Bean("notificationExecutor")
ThreadPoolTaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(16);
exec.setQueueCapacity(500);
exec.setThreadNamePrefix("notify-");
exec.setTaskDecorator(new MdcTaskDecorator());
exec.initialize();
return exec;
}
随后 @Async("notificationExecutor") 即可让通知任务带上请求的 traceId。
7.3.6 ContextPropagatingTaskDecorator 与多装饰器组合
手写装饰器要为每种上下文各写一份。Spring 提供了一个通用实现:org.springframework.core.task.support.ContextPropagatingTaskDecorator(本机 spring-core-7.0.9.jar javap 核实,实现 TaskDecorator,构造器接受 io.micrometer.context.ContextSnapshotFactory)。它基于 Micrometer Context Propagation,把当前线程的 ThreadLocal 值捕获成快照,在任务线程里恢复。
它能覆盖 SecurityContext 是因为 Spring Security 注册了对应的 ThreadLocalAccessor(本机 javap 核实存在 org.springframework.security.core.context.SecurityContextHolderThreadLocalAccessor)。也就是说,只要某类上下文实现了 ThreadLocalAccessor,ContextPropagatingTaskDecorator 就能自动带上。
4.x 的两点增强(官方 Release Notes 核实):
- 4.0 支持多个
TaskDecoratorbean。 容器里有多个时,会自动合成一个CompositeTaskDecorator(本机 javap 核实其构造器接受Collection<? extends TaskDecorator>),各装饰器按@Order/Ordered的顺序调用。 - 4.1 为
@Async提供上下文传播开关。 属性spring.task.execution.propagate-context(本机配置元数据核实,默认false);开启后自动配置会注册ContextPropagatingTaskDecorator——对应 bean 方法是TaskExecutorConfigurations$TaskExecutorContextPropagationConfiguration.contextPropagatingTaskDecorator()(本机 javap 核实)。
spring:
task:
execution:
propagate-context: true # 4.1:让 @Async 自动带上 ThreadLocal 上下文
这条属性省掉了手写 ContextPropagatingTaskDecorator 的样板代码。若还需要 MDC 等未被自动覆盖的上下文,再补一个自定义 TaskDecorator bean,4.0 的多装饰器合成会把它和自动配置的装饰器串起来。
7.3.7 线程池隔离的判断依据
要不要给不同业务配不同池,判断标准是「故障会不会互相传染」,而不是「看起来更整齐」:
| 信号 | 是否隔离 |
|---|---|
| 任务类型不同(快查询 vs 慢外部调用) | 隔离 |
| 一个任务会阻塞很久(下游超时可能几十秒) | 隔离,并给独立队列 |
| 任务有不同优先级或 SLA | 隔离 |
| 任务都很轻、耗时相近、量大 | 共享即可 |
| 只是「代码上分属两个模块」 | 不必隔离 |
隔离的收益是「慢任务打满自己的池,不拖累别人」;成本是每个池都占一批常驻线程与队列内存。典型做法:给外部调用、消息发送、批处理各配一个 ThreadPoolTaskExecutor,用 @Async("池名") 绑定;主业务默认走 applicationTaskExecutor。
7.3.8 验证与排障
观察池状态。 定时打印 getPoolSize() / getActiveCount() / getQueueSize(),或把它们接到 Micrometer(ExecutorServiceMetrics 可绑定 ExecutorService)后看监控。队列持续增长而 activeCount 不变,说明下游变慢、扩容没生效。
验证上下文传递。 在请求线程打印 MDC.get("traceId"),在 @Async("notificationExecutor") 方法里再打印一次;装了 MdcTaskDecorator 后两次应一致,去掉装饰器则异步线程里为 null。
验证拒绝策略。 把 corePoolSize=1、maxPoolSize=1、queueCapacity=1,连续提交多个任务,观察是否抛出 RejectedExecutionException——这能确认拒绝策略真的在生效,而不是被无界队列悄悄吞掉。
踩坑自查。 只设了 maxPoolSize 却没设 queueCapacity,等于没设 maxPoolSize;@Async 方法定义在同一个类里自调用,不经过代理,异步不生效(与 3.3 节「代理失效的边界」同源);@Async 返回 void 时异常被吞,务必配 AsyncUncaughtExceptionHandler。
7.3.9 把普通 Executor 接进 Spring
若要用一个非 Spring 的 Executor(例如 Executors.newVirtualThreadPerTaskExecutor())充当 @Async 执行器,用 TaskExecutorAdapter 包一层即可。本机 javap 核实:org.springframework.core.task.support.TaskExecutorAdapter 的构造器接受 java.util.concurrent.Executor,且仍提供 setTaskDecorator(TaskDecorator):
@Bean("notificationExecutor")
AsyncTaskExecutor notificationExecutor() {
var adapter = new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
adapter.setTaskDecorator(new MdcTaskDecorator());
return adapter;
}
这样既拿到虚拟线程,又保留 TaskDecorator 传上下文的能力,比隐式依赖 SimpleAsyncTaskExecutor 更显式。与 7.2 的 VirtualThreadTaskExecutor 相比,前者是「把已有 Executor 适配进来」,后者是「Spring 自建的虚拟线程执行器」,两者都实现 AsyncTaskExecutor,都能被 @Async 指定。
小结
ThreadPoolTaskExecutor的队列策略由createQueue决定:容量大于 0 用LinkedBlockingQueue,否则用SynchronousQueue;Boot 默认队列无界,会让maxPoolSize失效。@Async的执行器解析链是「限定符 → 唯一TaskExecutor→taskExecutor」,Boot 默认落到applicationTaskExecutor;共享池、无界队列、异常被吞是三大风险。SimpleAsyncTaskExecutor不池化,平台线程场景不宜上生产;它真正的用武之地是虚拟线程执行器。TaskDecorator是传递 MDC / 安全上下文的唯一官方扩展点;4.x 支持多装饰器合成,4.1 的spring.task.execution.propagate-context可让@Async自动传播上下文。- 线程池隔离的依据是「故障是否互相传染」,不是模块划分。
阅读导航:上一节:7.2 虚拟线程下的 Spring · 下一节:8.1 过滤器链构建过程 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。