@Async 异步任务的五个坑

前言

后台系统里,耗时任务(批量导入、导出、生成文件)几乎都会被甩到后台异步执行:前端拿一个任务 ID,轮询进度,任务在别的线程慢慢跑。在 Spring 里实现这件事,通常只需要一个注解------@Async

看起来简单到不需要动脑,但从"加了没生效"到"把整个系统压垮",中间每一步都埋着坑。本文按踩坑的严重程度递进,梳理 @Async 最常见的五个坑:

  1. self-invocation:@Async 根本没生效
  2. 默认线程池不是"池"
  3. void 方法的异常被静默吞掉
  4. 上下文不跨线程传播
  5. 队列与拒绝策略:任务丢失或主线程被拖垮

每个坑都配了可运行的 demo 和真实运行日志 (Spring Boot 2.5 + JDK 17)。全文只讲 @Async 本身,不涉及具体业务。这是"异步批量任务"系列的一篇,可以配合前两篇《线程池与分治合并》《当 @Transactional 不再可靠》一起看。


坑一:self-invocation ------ @Async 根本没生效

现象

方法上明明标了 @Async,但日志显示它还是在调用者的线程里跑,一点没异步。

原理

@Async@Transactional 一样,是靠 AOP 代理 实现的。Spring 在容器里放的其实是你这个类的一个代理对象,异步的逻辑(把方法丢到线程池)加在代理上。只有通过代理对象调用,增强才会触发。

而同一个类内部的 this.asyncTask() 调用,走的是原始对象自己,绕过了代理,增强自然不会生效。

复制代码
外部调用(走代理,生效):
  其他 Bean ──> [代理对象] ──异步逻辑──> 目标方法

内部调用(this.,失效):
  目标类的方法A ──this.──> 目标方法B   (根本没经过代理)

代码复现

java 复制代码
@Service
public class Pitfall1SelfInvocationService {

    public void outerCallInner() {
        log.info("outerCallInner 执行线程 = {}", Thread.currentThread().getName());
        // this.asyncTask() ------ 绕过代理,不会异步
        asyncTask();
    }

    @Async("bizExecutor")
    public void asyncTask() {
        log.info("asyncTask 执行线程 = {}(若是 main 说明 @Async 没生效)",
                Thread.currentThread().getName());
    }
}

运行日志

复制代码
======== 坑一:self-invocation(内部调用 @Async 失效) ========
主线程 = main
outerCallInner 执行线程 = main
asyncTask 执行线程 = main(若是 main 说明 @Async 没生效)
---- 对比:外部直接调用 asyncTask(走代理,生效)----
asyncTask 执行线程 = biz-async-1(若是 main 说明 @Async 没生效)

看得很清楚:从 outerCallInner 内部调用时,asyncTaskmain 执行(没生效);而从外部直接调用同一个方法时,它在 biz-async-1 线程执行(生效)。

解法

  • 拆到另一个 Bean:把异步方法放到独立的 Service,通过注入调用(最推荐,最清晰)。
  • 注入自身代理@Autowired private XxxService self;,用 self.asyncTask() 调用。
  • AopContext.currentProxy() :需要开启 @EnableAspectJAutoProxy(exposeProxy = true),可读性差,不推荐。

另外两个附带条件也别忘了:@Async 方法必须是 public ;启动类或配置类要加 @EnableAsync,否则整个异步机制根本不会开启。

这个坑和上一篇《当 @Transactional 不再可靠》里的事务失效是同一个 AOP 代理机制 的两个受害者。凡是"靠代理增强"的注解(@Async@Transactional@Cacheable),都怕 self-invocation。


坑二:默认线程池不是"池"

现象

不配线程池直接用 @Async,高并发下线程数不断上涨,线程名一路递增,严重时直接 OOM。

原理

先纠正一个常见误解。@Async 解析执行器的顺序是:

  1. 先找容器里类型为 TaskExecutor、或名为 taskExecutor 的唯一 Bean;
  2. 找不到 ,才 fallback 到 SimpleAsyncTaskExecutor

SimpleAsyncTaskExecutor 的行为是------每来一个任务就 new 一个新线程,不复用、无队列、无上限。它名字里有 "Executor",但根本不是一个线程"池"。任务量一大,线程数就失控。

代码复现

为了准确还原 SimpleAsyncTaskExecutor 的行为,demo 里显式配了一个:

java 复制代码
// 反例:还原 @Async 默认执行器的行为
@Bean("simpleExecutor")
public Executor simpleExecutor() {
    SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("simple-async-");
    executor.setDaemon(true);
    return executor;
}

// 正解:自定义线程池
@Bean("bizExecutor")
public Executor bizExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(1);
    executor.setMaxPoolSize(2);
    executor.setQueueCapacity(2);
    executor.setKeepAliveSeconds(60);
    executor.setThreadNamePrefix("biz-async-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

运行日志

各提交 6 个任务:

复制代码
---- SimpleAsyncTaskExecutor:观察线程名不断递增,不复用 ----
[SimpleAsyncTaskExecutor] 任务 5 执行线程 = simple-async-5
[SimpleAsyncTaskExecutor] 任务 3 执行线程 = simple-async-3
[SimpleAsyncTaskExecutor] 任务 2 执行线程 = simple-async-2
[SimpleAsyncTaskExecutor] 任务 6 执行线程 = simple-async-6
[SimpleAsyncTaskExecutor] 任务 1 执行线程 = simple-async-1
[SimpleAsyncTaskExecutor] 任务 4 执行线程 = simple-async-4
---- 自定义池:观察线程名只在固定几个之间复用 ----
[自定义池] 任务 1 执行线程 = biz-async-1
[自定义池] 任务 4 执行线程 = biz-async-2
[自定义池] 任务 2 执行线程 = biz-async-1
[自定义池] 任务 3 执行线程 = biz-async-2
[自定义池] 任务 6 执行线程 = biz-async-2

对比非常直观:SimpleAsyncTaskExecutor 开了 simple-async-1simple-async-6 六个不同的线程 ,一个任务一个线程;自定义池始终只在 biz-async-1 / biz-async-2 之间复用。6 个任务尚且如此,成千上万个任务时,前者就是一场线程灾难。

解法:自定义 ThreadPoolTaskExecutor

七个核心参数逐个说清:

参数 作用
corePoolSize 核心线程数,常驻不销毁
maxPoolSize 最大线程数,队列满后才会扩容到这个上限
queueCapacity 任务队列容量,核心线程忙不过来时任务先在这里排队
keepAliveSeconds 超过核心数的线程空闲多久后回收
threadNamePrefix 线程名前缀,排查问题时极其重要
rejectedExecutionHandler 拒绝策略(见坑五)
allowCoreThreadTimeOut 是否允许核心线程也被回收

线程数怎么定?这里直接接上《线程池与分治合并》那篇的结论:IO 密集型任务的线程数可以远超 CPU 核数 ,经验值是 CPU 核数 × (1 + 等待时间/计算时间);CPU 密集型才用"核数 + 1"。


坑三:void 方法的异常被静默吞掉

现象

异步任务里抛了异常,调用方毫不知情,任务看起来"成功"了,但数据其实不对。这是最危险的一个坑------它不报错,只是悄悄地错

原理

@Async 方法返回 void 时,方法内部抛出的异常会交给 AsyncUncaughtExceptionHandler 处理,默认实现(SimpleAsyncUncaughtExceptionHandler)只打一行错误日志,不会向上抛。调用方那条线程早就往下走了,根本感知不到。

只有返回 Future / CompletableFuture 时,异常才会被封进 future,调用方 get() 时才能拿到。

代码复现

java 复制代码
// 反例:void 直接抛,异常被吞
@Async("bizExecutor")
public void voidTaskThrow() {
    throw new RuntimeException("任务内部出错了(但这个异常会被静默吞掉)");
}

// 推荐:void 内部自己 try-catch,把失败状态落地
@Async("bizExecutor")
public void voidTaskSelfHandle(TaskState state) {
    try {
        throw new RuntimeException("任务内部出错了");
    } catch (Exception e) {
        state.markError(e.getMessage());   // 回写任务状态表
    }
}

// 可选:返回 CompletableFuture,异常随 future 传播
@Async("bizExecutor")
public CompletableFuture<String> futureTask() {
    CompletableFuture<String> future = new CompletableFuture<>();
    future.completeExceptionally(new RuntimeException("任务内部出错了(通过 future 暴露)"));
    return future;
}

运行日志

复制代码
---- 反例:void 直接抛,主线程感知不到 ----
voidTaskThrow 开始,线程 = biz-async-1
ERROR o.s.a.i.SimpleAsyncUncaughtExceptionHandler - Unexpected exception occurred
       invoking async method: ...voidTaskThrow()
主线程继续往下走,完全不知道上面的任务失败了
---- 推荐:void 内部 try-catch 回写状态 ----
voidTaskSelfHandle 开始,线程 = biz-async-1
捕获异常并回写任务状态:任务内部出错了
主线程查询任务状态:state=ERROR, message=任务内部出错了
---- 可选:返回 CompletableFuture,异常可感知 ----
futureTask 开始,线程 = biz-async-1
主线程通过 future.get() 感知到异常:任务内部出错了(通过 future 暴露)

反例里,异常只是被框架打了条日志,主线程"完全不知道上面的任务失败了";而 try-catch 回写方案里,主线程随后查到了 state=ERROR;future 方案里,主线程通过 get() 拿到了异常。

解法与原则

  • 推荐 :异步方法内部自己 try-catch,把成功/失败状态落地到任务状态表 。这也是真实项目里的标准做法------异步批量任务通常都有一张任务表,跑完把 taskState 置为 FinishError,前端轮询这张表拿结果。
  • 想统一兜底,可以配一个自定义 AsyncUncaughtExceptionHandler(实现 AsyncConfigurer)。
  • 需要拿返回值或让调用方感知异常,返回 CompletableFuture

原则:异步任务必须有自己的"结果落地"渠道(状态表 / 回调 / future),绝不能指望异常像同步方法那样自然冒泡上来。


坑四:上下文不跨线程传播

现象

异步方法里取不到当前登录用户、拿不到请求参数、@Transactional 不回滚------一到异步线程,这些"本来好好的"东西全没了。

原理

这些上下文的底层都是 ThreadLocal

上下文 载体
事务(连接、回滚标记) TransactionSynchronizationManager(ThreadLocal)
请求信息 RequestContextHolder(ThreadLocal)
登录态 SecurityContextHolder(ThreadLocal)
自定义上下文 你自己的 ThreadLocal

@Async 一换线程,新线程的 ThreadLocal 是空的,自然什么都取不到。

代码复现

java 复制代码
public static final ThreadLocal<String> USER_HOLDER = new ThreadLocal<>();

// 反例:裸 ThreadLocal,异步线程里取到 null
@Async("bizExecutor")
public void rawThreadLocalTask() {
    log.info("[裸 ThreadLocal] 取到的用户 = {}", USER_HOLDER.get());
}

// 正解:MDC 上下文经 TaskDecorator 复制过来
@Async("bizExecutor")
public void mdcTask() {
    log.info("[MDC + TaskDecorator] 取到的 traceId = {}", MDC.get("traceId"));
}

复制上下文靠 TaskDecorator,在任务提交时抓取当前线程的上下文,在异步线程执行前放进去:

java 复制代码
executor.setTaskDecorator(runnable -> {
    Map<String, String> context = MDC.getCopyOfContextMap();  // 提交线程的上下文
    return () -> {
        if (context != null) MDC.setContextMap(context);      // 放进异步线程
        try {
            runnable.run();
        } finally {
            MDC.clear();                                        // 用完清理,防串味
        }
    };
});

运行日志

复制代码
======== 坑四:上下文不跨线程传播 ========
主线程 = main 设置了 USER_HOLDER=张三, traceId=trace-abc-123
[裸 ThreadLocal] 异步线程 = biz-async-1,取到的用户 = null(null 说明上下文丢了)
[MDC + TaskDecorator] 异步线程 = biz-async-1,取到的 traceId = trace-abc-123(非 null 说明复制成功)

同一个异步线程里,裸 ThreadLocal 取到 null,而经过 TaskDecorator 复制的 MDC 取到了正确的 traceId

解法

  • 参数显式传递(最推荐):把需要的上下文(用户 ID、traceId 等)当方法参数传进去,简单可靠,不依赖任何魔法。
  • TaskDecorator:适合 traceId、MDC 这类横切上下文,一次配置全局生效。
  • InheritableThreadLocal :只在线程创建时 继承一次,而线程池的线程是复用的,创建时机不确定,所以在线程池场景不可靠,别用。

关于事务:@Async + @Transactional 组合时,异步方法里的声明式事务是一个独立的新事务,且如果再叠加 self-invocation、循环内 catch 异常等场景,声明式事务往往直接失效。这正是上一篇《当 @Transactional 不再可靠》里用编程式事务手动管理事务边界的原因。


坑五:队列与拒绝策略 ------ 任务丢失或主线程被拖垮

现象

两种诡异现象:任务突然"消失"没执行;或者一提交异步任务,主线程自己反而卡住了,异步接口比同步还慢。

原理

线程池的任务处理流程是:核心线程忙 → 进队列 → 队列满则扩容到最大线程 → 还满就触发拒绝策略

不同拒绝策略行为完全不同:

拒绝策略 行为 后果
AbortPolicy(默认) 直接抛 RejectedExecutionException 任务丢失 + 异常
CallerRunsPolicy 由提交任务的线程亲自执行 异步退化为同步,主线程被拖慢
DiscardPolicy 静默丢弃新任务 任务悄无声息丢失
DiscardOldestPolicy 丢弃队列中最老的任务 老任务丢失

代码复现

demo 的池配置是 核心1 / 最大2 / 队列2,即最多容纳 2(执行) + 2(排队) = 4 个任务,用的是 CallerRunsPolicy。一次提交 6 个慢任务,第 5 个开始就会触发拒绝。

运行日志

复制代码
======== 坑五:队列满触发拒绝策略(CallerRunsPolicy) ========
线程池:核心1/最大2/队列2,最多容纳 4 个任务。提交 6 个,观察后面的任务被 main 执行
提交任务 1(提交线程 = main)
提交任务 2(提交线程 = main)
提交任务 3(提交线程 = main)
提交任务 4(提交线程 = main)
提交任务 5(提交线程 = main)
任务 4 执行线程 = biz-async-2
任务 1 执行线程 = biz-async-1
任务 5 执行线程 = main          <-- 注意!第 5 个任务被 main 亲自执行了
提交任务 6(提交线程 = main)    <-- main 被任务5阻塞,直到它跑完才提交任务6
任务 3 执行线程 = biz-async-1
任务 2 执行线程 = biz-async-2
任务 6 执行线程 = biz-async-1

关键在任务 5 :它的执行线程是 main ,不是 biz-async。因为提交第 5 个时队列已满、线程用尽,CallerRunsPolicy 让提交任务的 main 线程亲自执行了它------异步彻底退化成了同步。更要命的是,main 被任务 5 阻塞了 500ms,连"提交任务 6"这个动作都被卡住了。在真实的 Web 场景里,这意味着接口线程被业务任务拖住,吞吐直接崩塌。

解法

  • 有界队列 + 合理拒绝策略 :队列不要用无界(Integer.MAX_VALUE),否则任务无限堆积迟早 OOM,maxPoolSize 也永远用不上。
  • 拒绝策略按语义选
    • 任务不能丢(如支付、下单)→ CallerRunsPolicy 或落库补偿,绝不能 Discard。
    • 任务可容忍丢失(如非关键日志)→ Discard 系列。
  • 关键任务落库补偿:提交前先落一条"待处理"记录,被拒绝时靠定时任务重新捞起来跑。

五个坑串成一条链

这五个坑不是孤立的,而是异步任务从"能用"到"扛得住"必须闯过的五道关卡。任何一关没过,任务就会掉进对应的失败结局------而且是从上到下层层递进的:连异步都没生效(坑一),谈后面四关都没意义。

用一句话概括每一关要问自己的问题:

复制代码
坑一 能异步吗?   →  过了 self-invocation,@Async 才真正生效
坑二 用对池了吗? →  弃用默认 SimpleAsyncTaskExecutor,配自定义线程池
坑三 知道成败吗? →  异常必须落地,别被静默吞掉
坑四 上下文在吗? →  ThreadLocal 类上下文要显式传递或复制
坑五 扛得住吗?   →  有界队列 + 合理拒绝策略,别丢任务、别拖垮主线程

一句话记忆:

现象 根因 解法
self-invocation 没异步 内部调用绕过代理 拆 Bean / 注入自身代理
默认线程池 线程暴涨 SimpleAsyncTaskExecutor 不复用 自定义 ThreadPoolTaskExecutor
异常被吞 悄悄错 void 异常不上抛 try-catch 回写状态 / 返回 future
上下文丢失 取不到用户/事务 ThreadLocal 不跨线程 参数传递 / TaskDecorator
拒绝策略 任务丢/主线程卡 队列满触发拒绝 有界队列 + 选对策略

常见问题

Q:@Async@Transactional 加在同一个方法上,会怎样?

两个都是代理增强,能叠加,但要注意执行顺序和失效场景。异步方法里的事务是在新线程里独立开启的新事务,与调用方不在同一事务。而且一旦这个异步方法又被 self-invocation 调用,两个注解会一起失效。批量循环里想精确控制事务,用编程式事务更稳(见上一篇)。


Q:为什么我的 @Async 方法返回值总是拿到 null?

如果方法签名返回普通对象(不是 Future),@Async 会立刻返回 null,因为真正的执行还没开始。要拿返回值必须声明返回 Future<T>CompletableFuture<T>,然后 get()


Q:@Async 用了自定义线程池,还需要手动 shutdown 吗?

不需要。ThreadPoolTaskExecutor 是 Spring 管理的 Bean,容器关闭时会自动调用它的 destroy() 优雅关闭。这和《线程池与分治合并》里"手写 Executors.newFixedThreadPool 必须自己 shutdown"不同------那是你 new 出来的、脱离容器管理的,不关就是线程泄漏。


Q:一个项目里能配多个线程池吗?怎么指定用哪个?

能,而且强烈建议按业务隔离 。配多个 Executor Bean,用 @Async("bizExecutor") 指定名字即可。不同业务用不同的池,避免一个慢任务把所有异步任务都堵死------这也是"线程池隔离"的基本思想。


总结

@Async 把异步做得像加个注解一样轻松,但轻松的表象下藏着一整条坑。逐个记住:

  • 能异步 :别在同类里 self-invocation,别忘 @EnableAsync 和 public
  • 用对池 :永远自定义 ThreadPoolTaskExecutor,别碰默认的 SimpleAsyncTaskExecutor
  • 知成败:异常在方法内 try-catch 落地,别指望它自然冒泡
  • 带上下文ThreadLocal 类上下文靠参数传递或 TaskDecorator 复制
  • 扛得住:有界队列 + 按语义选拒绝策略,别丢任务也别拖垮主线程

理解每个坑背后的机制------代理、线程池模型、ThreadLocal、拒绝策略------遇到新的异步问题时才能一眼看穿本质。

相关推荐
XWalnut6 小时前
LeetCode刷题 day29
java·算法·leetcode
4154116 小时前
MyBatis-Plus + PostGIS 实战(1.1):Geometry 字段在 Swagger 中的优雅展示
java·mybatis·postgis
极客先躯6 小时前
高级java每日一道面试题-2026年04月05日-实战篇[Docker]-如何排查容器 OOM 的问题?
java·运维·docker·容器·内存溢出·高级面试
带娃的IT创业者6 小时前
监控并非安全:当隐私成为技术的祭品
java·开发语言·安全·数据安全·监控·隐私保护·加密技术
叩码以求索7 小时前
使用next数组加速匹配过程
java·数据结构·算法
余额瞒着我当琳7 小时前
C++ 基础核心概念精讲:引用、内联与 nullptr
java·开发语言·c++
再玩一会儿看代码8 小时前
JUnit 测试框架详解:从实际开发、业务测试到 Java 面试高频问题
java·经验分享·笔记·junit·面试
yxlalm8 小时前
2.java秒杀项目第二课-后端登录功能
java·开发语言
亦暖筑序9 小时前
AgentScope-Java 入门:用 SSE 展示评审任务进度
java·agent·ai编程