Spring @Async 坑点 + 改造方案
原生 @Async 默认存在的问题
- 默认线程池是
SimpleAsyncTaskExecutor,不是真正线程池! 它不会复用线程,每来一个任务新建一条线程,高并发下线程疯狂创建,直接 OOM。
很多开发不知道,以为 @Async 自带线程池,线上直接踩大坑。
- 没有统一异常捕获
@Async方法如果返回void,任务抛出异常直接静默消失,没有告警,业务完全无感知。 返回 Future 的,异常藏在 Future 对象,不 get 就看不到。 - 没有监控、没有线程命名 默认线程名字杂乱,无法埋点监控队列、活跃线程、拒绝计数。
- 无法做业务隔离 所有 @Async 共用一套线程,慢任务占满全部线程,其他异步全部饿死。
- 事务、上下文丢失 ThreadLocal(用户上下文、traceId 链路追踪)在异步线程丢失,日志看不到链路。
✅生产改造方案:让 @Async 使用我们上面写好的全局线程池
实现AsyncConfigurer,替换 @Async 默认执行器,复用我们写好带afterExecute异常兜底的ThreadPoolExecutor。
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import javax.annotation.Resource;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {
// 注入我们上面定义好的全局线程池
@Resource(name = "globalCommonThreadPool")
private ThreadPoolExecutor globalCommonThreadPool;
/**
* 将@Async默认执行器替换为我们自定义线程池
*/
@Override
public Executor getAsyncExecutor() {
// Spring包装一层,适配@Async接口
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setThreadPoolExecutor(globalCommonThreadPool);
// 一定要初始化
taskExecutor.initialize();
return taskExecutor;
}
/**
* 针对返回void的@Async异步方法,全局未捕获异常处理器
* 补充afterExecute之外的一层兜底
*/
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
// @Async void方法抛出异常,会走到这里
log.error("@Async方法执行异常,method:{}",method.getName(),ex);
// 告警alertService.sendAlert("@Async异步任务异常",ex);
};
}
}
⚠️注意:
AsyncUncaughtExceptionHandler只对返回 void 的 @Async 生效 ; 如果 @Async 返回Future<?>,异常依旧封装在 Future,需要afterExecute去解析捕获,两者配合才完整。
📌链路追踪问题(金融系统必处理)
业务系统用 skywalking /sleuth,TraceId放在 ThreadLocal。 @Async 新开线程,主线程 TraceId 丢失,日志串不起来。
解决两种方案:
-
包装线程工厂,提交任务的时候把主线程 ThreadLocal 复制到异步线程。
-
使用
TaskDecorator装饰器。//链路复制装饰器示例
public class TraceTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 拿到主线程上下文
Map<String,String> context = MDC.getCopyOfContextMap();
return ()->{
try{
if(context != null){
MDC.setContextMap(context);
}
runnable.run();
}finally {
MDC.clear();
}
};
}
}
配置到ThreadPoolTaskExecutor,执行任务前复制 MDC 链路信息,日志 traceId 不中断。
📌业务隔离:给 @Async 指定不同线程池
不同业务不能全部塞同一个线程池,防止饥饿。 我们定义多个线程池 Bean,使用@Async("beanName")指定使用哪个线程池。
//使用订单专用线程池
@Async("orderAsyncThreadPool")
public void asyncSaveOrder(){
}
//使用报表专用线程池
@Async("reportAsyncThreadPool")
public void exportReport(){
}
每个独立线程池,全部复用同一套
BizThreadFactory+afterExecute异常兜底逻辑。
❗生产编码规范(银行项目代码评审重点)
- 禁止不指定 Executor 直接裸用
@Async,避免使用默认SimpleAsyncTaskExecutor。 void返回的 @Async,不要指望业务内部 try‑catch,依靠两层兜底:AsyncUncaughtExceptionHandler+afterExecute。- 异步方法尽量不要依赖 ThreadLocal,如必须,一定要做上下文复制。
- 异步任务做好超时控制,内部调用 RPC/DB 必须设置超时,防止任务卡死占住线程。
- 重要业务异步任务,不能只靠内存线程池,服务重启任务直接丢失,要结合 MQ 持久化。
🎯面试高频题
Q1:@Async 底层默认线程池是什么?有什么坑?
默认
SimpleAsyncTaskExecutor,不做线程复用,每次任务新建线程,高并发下线程暴涨 OOM。生产必须自定义替换为 ThreadPoolExecutor。
Q2:@Async 返回 void 和返回 Future,异常表现有什么不同?
- 返回
void:异常会交给AsyncUncaughtExceptionHandler; - 返回
Future:异常封装在 Future 对象,不会触发上面处理器,需要调用 get () 抛出,必须靠线程池 afterExecute 钩子解析 Future 捕获异常。
Q3:@Async 线程丢失 TraceId 怎么处理?
通过 TaskDecorator,提交任务时复制 MDC 上下文到异步线程。
Q4:@Async 和直接注入线程池 execute/submit,业务怎么选型?
- 简单异步,不需要拿到返回值,业务代码希望简洁,可以用
@Async,但是必须替换自定义线程池。 - 需要获取返回值、自定义超时、需要手动管控任务提交时机,推荐直接注入线程池,调用 execute/submit。
- 核心金融业务,优先直接注入线程池,更可控,代码可读性更强。
🗣️面试口述总结版本
项目中使用 @Async,首先要规避默认 SimpleAsyncTaskExecutor 的坑,这个类不会复用线程,高并发会不断新建线程。我们会实现 AsyncConfigurer 把默认执行器替换为我们自定义的 ThreadPoolExecutor。 针对 void 返回的异步方法,配置 AsyncUncaughtExceptionHandler 做异常兜底;同时线程池重写 afterExecute,处理返回 Future 场景的异常。 另外异步线程会丢失 MDC 链路信息,通过 TaskDecorator 复制上下文保证日志链路完整。不同业务使用不同线程池 bean 做隔离,用 @Async ("beanName") 指定。 对于核心金融业务,为了更强可控性,我们更多直接注入线程池调用 execute/submit,而不是 @Async 注解。