SPRING · @ASYNC · THREADPOOLTASKEXECUTOR · THREADPOOLEXECUTOR

说一声就走,活交给后台的线程去干

下单后要发短信,短信网关要 800 ms。不加 @Async,处理请求的线程得一直等着;加了 @Async,它把活交给线程池就回去了,用户 50 ms 就拿到结果。下面两条按同一个比例画。

不加 @Async用户等 850 ms
http-nio-8080-exec-1
下单
发短信 800 ms(请求线程干等)
加了 @Async用户等 50 ms
http-nio-8080-exec-1
下单
task-1
发短信 800 ms(线程池里的线程干)
前台http-nio-8080-exec-1

Tomcat 处理请求的线程。它越早回去,能接的请求越多。

调用 @Async 方法的那一方
派单员AsyncExecutionInterceptor

替身上的一个拦截器:把方法调用包成任务,丢给线程池,马上返回。

@EnableAsync 打开后才有
后台班组ThreadPoolTaskExecutor

一组反复使用的线程,人数有上下限。Spring 包了一层 JDK 的 ThreadPoolExecutor。

Boot 里默认叫 task-1、task-2……
任务队列BlockingQueue

线程都忙时,任务先在这里排队。它有多长,决定了很多事。

Boot 默认不限长

@Async 背后发生了什么

还是替身那一套(第 10 篇):@EnableAsync 让 Spring 给带 @Async 的 Bean 做替身,替身的拦截器链最前面是派单员。

1调用方调 smsService.send(…)拿到的是替身,调用先到替身
2派单员挑一个线程池@Async("smsPool") 写了名字就用它,没写就用默认的
3把“继续往下调真身”包成一个任务,提交提交这一下在调用方的线程里发生
4马上返回void 返回 null;CompletableFuture 返回一个还没完成的 future
5线程池里的线程执行任务,调到真身的 send真身的代码跑在 task-1 上,不在请求线程上
AsyncExecutionInterceptor.invoke简化版
public Object invoke(MethodInvocation invocation) {
    AsyncTaskExecutor executor = determineAsyncExecutor(method);   // ② 挑线程池
    Callable<Object> task = () -> {
        try {
            Object result = invocation.proceed();                   // ⑤ 在池子的线程里调真身
            if (result instanceof Future) {
                return ((Future<?>) result).get();
            }
        } catch (Throwable ex) {
            handleError(ex, method, args);   // void 方法:交给 AsyncUncaughtExceptionHandler,只记日志
        }
        return null;
    };
    return doSubmit(task, executor, method.getReturnType());   // ③④ 提交,马上返回
}
返回类型调用方拿到什么方法里抛异常
void什么都没有交给 AsyncUncaughtExceptionHandler,默认只打一条 ERROR 日志,调用方完全不知道
CompletableFuture<T>一个 future,可以 thenApply、join、和别的组合装在 future 里,调用方 join() 或 exceptionally 时拿到
Future<T>一个 future,只能 get() 阻塞等装在 future 里,get() 时抛出
String 等普通类型null:方法根本没执行完,拿不到值同 void

@Async 到底用哪个线程池

派单员按下面的顺序找,找到就停。最后那一步最危险。

1@Async("smsPool") 写了名字用名叫 smsPool 的那个 Executor Bean
2实现了 AsyncConfigurer用它的 getAsyncExecutor() 返回的
3容器里正好只有一个 TaskExecutor用它
4有一个名叫 taskExecutor 的 Executor用它
BSpring Boot:自动配的 applicationTaskExecutor你没声明 Executor 时,Boot 配一个线程池,别名就是 taskExecutor,所以第 4 步能找到它
5都找不到:SimpleAsyncTaskExecutor不是线程池:每个任务新建一个线程,用完就扔,数量不设上限
Boot 默认的那个池子不配就是这些值
spring:
  task:
    execution:
      thread-name-prefix: task-
      pool:
        core-size: 8
        max-size: 2147483647          # 不限
        queue-capacity: 2147483647    # 不限:所以 max-size 永远用不上
        keep-alive: 60s
        allow-core-thread-timeout: true
自己配一个专用的按业务分开,互不拖累
@Bean("smsPool")
ThreadPoolTaskExecutor smsPool() {
    ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
    e.setCorePoolSize(4);
    e.setMaxPoolSize(8);
    e.setQueueCapacity(200);          // 一定要有上限
    e.setThreadNamePrefix("sms-");
    e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    e.setWaitForTasksToCompleteOnShutdown(true);
    return e;
}

@Async("smsPool")
public void send(String phone, String text) { … }

线程池收到一个任务,按这四步走

这是 JDK ThreadPoolExecutor 的规则,Spring 的 ThreadPoolTaskExecutor 原样照用。最反直觉的是第 2 步:先排队,队列满了才加人。

① 线程数 < 核心数?是:新建一个线程,直接跑它哪怕别的线程闲着,也先把核心数补满
② 队列没满?是:放进队列排队核心线程忙完了会来取
③ 线程数 < 最大数?是:新建临时线程,直接跑它它插队:先跑这个新任务,不是队头那个
④ 都不行交给拒绝策略抛异常、调用方自己跑、悄悄丢掉、丢掉最老的

线程池模拟器

预设:
第 0 拍 · 每个任务要跑 6 拍 · 临时线程空闲 8 拍被回收
线程(绿框是核心名额,橙框是临时名额)
队列

    拒绝策略满了会怎样适合
    AbortPolicy(默认)抛 RejectedExecutionException,Spring 包成 TaskRejectedException,调用方收到宁可失败也不能悄悄丢的任务
    CallerRunsPolicy交回调用方的线程自己跑,调用方被拖慢,自然就提交得慢了不能丢、可以慢的任务,天然限流
    DiscardPolicy直接丢掉,没有任何提示丢了也无所谓的,比如统计上报
    DiscardOldestPolicy丢掉队列里最老的,把新的放进去只关心最新状态的,比如刷新缓存

    换了线程,丢了什么

    很多东西都挂在 ThreadLocal 上,跟着线程走。任务一换到 task-1,它们就都不在了。

    丢了当前登录用户SecurityContextHolder异步方法里取到的是空的(第 05 篇)
    丢了日志里的 traceIdMDC异步线程打的日志串不起来
    丢了事务TransactionSynchronizationManager不在调用方的事务里(MyBatis 第 02 篇)
    丢了当前请求RequestContextHolder请求可能已经结束了,别在异步里读它
    TaskDecorator提交时在调用方线程里拍个快照,执行时在池子线程里装回去
    @Bean
    TaskDecorator mdcDecorator() {
        return task -> {
            Map<String, String> mdc = MDC.getCopyOfContextMap();          // 调用方线程
            SecurityContext sc = SecurityContextHolder.getContext();
            return () -> {
                try {
                    if (mdc != null) MDC.setContextMap(mdc);              // 池子线程
                    SecurityContextHolder.setContext(sc);
                    task.run();
                } finally {
                    MDC.clear();                                          // 线程要还回池子,一定清掉
                    SecurityContextHolder.clearContext();
                }
            };
        };
    }
    • Boot 自动配的线程池会用上容器里的 TaskDecorator Bean;自己 new 的 ThreadPoolTaskExecutor 要手动 setTaskDecorator。
    • 只管登录信息的话,Spring Security 有现成的 DelegatingSecurityContextAsyncTaskExecutor。
    • 事务不要传:异步任务想读调用方刚写的数据,调用方的事务很可能还没提交,读不到。用 @TransactionalEventListener 等提交后再异步(第 08 篇)。
    并行调两个服务
    CompletableFuture<User> u = userSvc.load(id);
    CompletableFuture<List<Order>> o = orderSvc.list(id);
    CompletableFuture.allOf(u, o).join();

    两个方法都是 @Async、返回 CompletableFuture,总耗时取慢的那个。

    别用公共池干 IO
    CompletableFuture.supplyAsync(() -> callHttp())

    不传线程池就用 ForkJoinPool.commonPool,大小约等于 CPU 核数减一,全 JVM 共用,一堵全堵。传一个自己的线程池。

    停机时别丢任务
    spring.task.execution.shutdown.await-termination=true
    spring.task.execution.shutdown.await-termination-period=30s

    不配的话,关闭时队列里还没跑的任务会被直接丢掉。

    JDK 8 和 JDK 17:Spring 5 和 Spring 6

    @Async 的机制、线程池的四步规则,两边一样。变化在返回类型和虚拟线程。

    Spring 5 · Boot 2

    JDK 8+
    • 返回值还能用 Spring 自己的 ListenableFuture
    • CompletableFuture 已经可以用(JDK 8 自带)
    • 线程池里只有平台线程,一个线程对应一个系统线程,数量要省着用

    Spring 6 · Boot 3

    JDK 17+
    • ListenableFuture 被标记过时,统一用 CompletableFuture
    • 6.1 起有 ContextPropagatingTaskDecorator,配合 Micrometer 自动把 traceId 带到异步线程
    • Boot 3.2 起,在 JDK 21 上配 spring.threads.virtual.enabled=true,默认的执行器换成每个任务一个虚拟线程,不再有核心数、队列这些参数
    没变的:替身 + 派单员找线程池的顺序先排队后加人四种拒绝策略ThreadLocal 不跟着走

    常踩的坑

    一半是 @Async 没生效,一半是线程池配得不对。

    加了 @Async,还是在请求线程里同步执行

    没写 @EnableAsync;或者同一个类里自己调自己、方法是 private,没经过替身(第 10 篇)。打日志看线程名就知道。

    max-size 调大了也没用,任务越堆越多

    Boot 默认队列不限长,第 2 步永远能排进去,永远走不到第 3 步。给 queue-capacity 设个上限。

    线程数一路涨到几千

    容器里有好几个 TaskExecutor 又没有叫 taskExecutor 的,@Async 退回 SimpleAsyncTaskExecutor,每个任务新开一个线程。启动日志里会有 More than one TaskExecutor bean found…。用 @Async("名字") 指定。

    异步方法里抛的异常,谁也不知道

    void 方法的异常只进日志。实现 AsyncConfigurer.getAsyncUncaughtExceptionHandler 统一处理,或者返回 CompletableFuture 让调用方处理。

    TaskRejectedException

    线程到了最大数、队列也满了,默认策略就抛这个。调大参数,或者换成 CallerRunsPolicy,让调用方自己跑来限流。

    异步方法的返回值总是 null

    返回类型写成了 String 这种普通类型。改成 CompletableFuture<String>,方法里 return CompletableFuture.completedFuture(x)。

    异步线程的日志没有 traceId,拿不到当前用户

    ThreadLocal 没跟过去。配一个 TaskDecorator,把 MDC 和 SecurityContext 带过去,用完清掉。

    异步任务查不到刚插入的订单

    调用方的事务还没提交,别的线程看不到。改成提交后再异步:@TransactionalEventListener 加 @Async。

    短信网关一慢,订单通知也发不出去了

    所有 @Async 共用一个池子,慢任务把线程全占了。按业务拆成几个池子,各用各的 @Async("…")。

    发版重启后,有一批异步任务没执行

    关闭时队列里的任务被丢了。配 await-termination;真正不能丢的,用消息队列。