《Spring Boot 高级》7.3 并发编程与线程池模型

讲透 ThreadPoolTaskExecutor 的核心参数与队列策略(createQueue 的真实分支)、@Async 默认执行器的解析链与踩坑点,说明 SimpleAsyncTaskExecutor 为何不宜上生产,并用 TaskDecorator 与 ContextPropagatingTaskDecorator 解决 MDC 与安全上下文的跨线程丢失。

本节目标:把 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<>();                     // 不存储元素
}

于是扩容行为分两种:

队列容量队列类型扩容顺序后果
> 0LinkedBlockingQueue核心 → 队列 → 扩容到 max有界,行为可控,推荐
= 0SynchronousQueue核心 → 直接扩容到 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):找容器里唯一的 TaskExecutor bean;找不到就回退到常量 DEFAULT_TASK_EXECUTOR_BEAN_NAME(本机 javap 核实其值为 "taskExecutor")。

在 Spring Boot 里,TaskExecutionAutoConfiguration 会注册名为 applicationTaskExecutor 的 bean(APPLICATION_TASK_EXECUTOR_BEAN_NAME,javap 核实其值为 applicationTaskExecutor),并通过 AsyncConfigurer 把它提供给 @Async。所以开箱即用时,@Async 跑在这个执行器上。

由此带来三个风险:

  1. 共享一个池。 所有 @Async 方法共用一个执行器,一个慢任务(如调下游超时)会把池占满,拖垮其他异步任务。
  2. 无界队列掩盖过载。 默认队列无界,任务只增不减,表现为内存上涨而非快速失败。
  3. 异常被吞。 @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 支持多个 TaskDecorator bean。 容器里有多个时,会自动合成一个 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 过滤器链构建过程 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计