SPRING · @ASYNC · THREADPOOLTASKEXECUTOR · THREADPOOLEXECUTOR
说一声就走,活交给后台的线程去干
下单后要发短信,短信网关要 800 ms。不加 @Async,处理请求的线程得一直等着;加了 @Async,它把活交给线程池就回去了,用户 50 ms 就拿到结果。下面两条按同一个比例画。
http-nio-8080-exec-1Tomcat 处理请求的线程。它越早回去,能接的请求越多。
调用 @Async 方法的那一方AsyncExecutionInterceptor替身上的一个拦截器:把方法调用包成任务,丢给线程池,马上返回。
@EnableAsync 打开后才有ThreadPoolTaskExecutor一组反复使用的线程,人数有上下限。Spring 包了一层 JDK 的 ThreadPoolExecutor。
Boot 里默认叫 task-1、task-2……BlockingQueue线程都忙时,任务先在这里排队。它有多长,决定了很多事。
Boot 默认不限长@Async 背后发生了什么
还是替身那一套(第 10 篇):@EnableAsync 让 Spring 给带 @Async 的 Bean 做替身,替身的拦截器链最前面是派单员。
@Async("smsPool") 写了名字就用它,没写就用默认的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 到底用哪个线程池
派单员按下面的顺序找,找到就停。最后那一步最危险。
@Async("smsPool") 写了名字用名叫 smsPool 的那个 Executor BeanAsyncConfigurer用它的 getAsyncExecutor() 返回的taskExecutor 的 Executor用它applicationTaskExecutor你没声明 Executor 时,Boot 配一个线程池,别名就是 taskExecutor,所以第 4 步能找到它SimpleAsyncTaskExecutor不是线程池:每个任务新建一个线程,用完就扔,数量不设上限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 步:先排队,队列满了才加人。
线程池模拟器
| 拒绝策略 | 满了会怎样 | 适合 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException,Spring 包成 TaskRejectedException,调用方收到 | 宁可失败也不能悄悄丢的任务 |
| CallerRunsPolicy | 交回调用方的线程自己跑,调用方被拖慢,自然就提交得慢了 | 不能丢、可以慢的任务,天然限流 |
| DiscardPolicy | 直接丢掉,没有任何提示 | 丢了也无所谓的,比如统计上报 |
| DiscardOldestPolicy | 丢掉队列里最老的,把新的放进去 | 只关心最新状态的,比如刷新缓存 |
换了线程,丢了什么
很多东西都挂在 ThreadLocal 上,跟着线程走。任务一换到 task-1,它们就都不在了。
MDC异步线程打的日志串不起来RequestContextHolder请求可能已经结束了,别在异步里读它@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,总耗时取慢的那个。
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,默认的执行器换成每个任务一个虚拟线程,不再有核心数、队列这些参数
常踩的坑
一半是 @Async 没生效,一半是线程池配得不对。
没写 @EnableAsync;或者同一个类里自己调自己、方法是 private,没经过替身(第 10 篇)。打日志看线程名就知道。
Boot 默认队列不限长,第 2 步永远能排进去,永远走不到第 3 步。给 queue-capacity 设个上限。
容器里有好几个 TaskExecutor 又没有叫 taskExecutor 的,@Async 退回 SimpleAsyncTaskExecutor,每个任务新开一个线程。启动日志里会有 More than one TaskExecutor bean found…。用 @Async("名字") 指定。
void 方法的异常只进日志。实现 AsyncConfigurer.getAsyncUncaughtExceptionHandler 统一处理,或者返回 CompletableFuture 让调用方处理。
线程到了最大数、队列也满了,默认策略就抛这个。调大参数,或者换成 CallerRunsPolicy,让调用方自己跑来限流。
返回类型写成了 String 这种普通类型。改成 CompletableFuture<String>,方法里 return CompletableFuture.completedFuture(x)。
ThreadLocal 没跟过去。配一个 TaskDecorator,把 MDC 和 SecurityContext 带过去,用完清掉。
调用方的事务还没提交,别的线程看不到。改成提交后再异步:@TransactionalEventListener 加 @Async。
所有 @Async 共用一个池子,慢任务把线程全占了。按业务拆成几个池子,各用各的 @Async("…")。
关闭时队列里的任务被丢了。配 await-termination;真正不能丢的,用消息队列。