Spring @Async 代理失效与方法内部自调用的判定方法

使用 @Async 把耗时任务丢到线程池执行,是 Spring 项目里最常见的异步改造手段。但很多人第一次接入时会遇到一个诡异现象:方法明明加了 @Async,日志里的线程名却还是 http-nio-8080-exec-1,接口照样被阻塞,异步等于没加。更麻烦的是,这个问题在单元测试里往往测不出来,上线压测时才暴露。 根本原因几乎都指向同一件事:@Async

使用 @Async 把耗时任务丢到线程池执行,是 Spring 项目里最常见的异步改造手段。但很多人第一次接入时会遇到一个诡异现象:方法明明加了 @Async,日志里的线程名却还是 http-nio-8080-exec-1,接口照样被阻塞,异步等于没加。更麻烦的是,这个问题在单元测试里往往测不出来,上线压测时才暴露。

根本原因几乎都指向同一件事:@Async 是借助 Spring AOP 代理实现的,只有调用穿过代理对象时注解才会生效。同一个类内部的方法互相调用走的是 this 引用,绕过了代理,注解就被静默忽略。这篇文章把判定方法讲透:怎么快速确认是不是自调用导致的失效、怎么用代码和日志做出可复现的判定、几种修复方案的取舍,以及在 Code Review 和面试场景下如何几句话说到点上。

先搞清楚 @Async 的生效链路

Spring 处理 @Async 的组件是 AsyncAnnotationBeanPostProcessor,它给匹配的 Bean 套上一层代理(JDK 动态代理或 CGLIB),代理内部持有一个 AsyncAnnotationAdvisor,拦截器是 AnnotationAsyncExecutionInterceptor。调用链可以简化成:

调用方 → 代理对象 → AsyncExecutionInterceptor.invoke()
        → 命中 @Async → 提交到 Executor → 立即返回 null/Future
        → 未命中 → 反射调用目标方法(同步执行)

由此可以推出三个必要条件,缺一不可:

  • 调用必须发生在代理对象上,而不是原始对象(this)。
  • 被调用的方法必须是 public,且代理能拦截到(CGLIB 无法代理 private、final、static 方法)。
  • Bean 必须真的被代理了,即 @EnableAsync 生效、注解所在类本身是 Spring Bean。

方法内部自调用破坏的正是第一条。下面所有判定手段,本质都是在验证这三条中哪一条没满足。

判定方法一:看线程名,最快的初筛

写一行日志,打印当前线程名,5 秒钟就能定性。

@Service
public class ReportService {

    private static final Logger log = LoggerFactory.getLogger(ReportService.class);

    public void generate() {
        log.info("generate 入口线程: {}", Thread.currentThread().getName());
        buildReport();          // 自调用,this.buildReport()
    }

    @Async
    public void buildReport() {
        log.info("buildReport 执行线程: {}", Thread.currentThread().getName());
    }
}

如果两行日志的线程名相同(都是 http-nio-8080-exec-*),说明 buildReport 同步执行了,@Async 没生效。如果第二行是 task-1、async-executor-2 这类线程池线程名,则生效正常。

注意一个容易误判的点:如果全局只有一个线程的线程池(比如 corePoolSize=1 的自定义 Executor),且调用方本身就在这个池里,线程名可能看起来”像”但结论相反。用线程名初筛后,仍建议用后面的断点法或反射法做交叉验证。

还有一个前置检查:@EnableAsync 是否加在配置类上。忘了加这个注解,全项目所有 @Async 都是摆设,线程名一样能看出来,但此时不是自调用问题,不要误诊。可以用下面的代码确认异步基础设施是否装配:

@SpringBootTest
class AsyncInfraTest {

    @Autowired
    private ApplicationContext ctx;

    @Test
    void asyncAdvisorShouldBePresent() {
        String[] names = ctx.getBeanNamesForType(AsyncAnnotationBeanPostProcessor.class);
        System.out.println("AsyncAnnotationBeanPostProcessor 数量: " + names.length);
        // 输出 0 说明 @EnableAsync 没生效
    }
}

判定方法二:断点看调用栈里的 this 类型

在 buildReport() 方法体第一行打断点,观察 Debug 面板中的 this:

  • this 的类型是 ReportService(或者说 this.getClass() 打印出 class com.example.ReportService),说明是原始对象,走了自调用。
  • this 的类型是 ReportService$$EnhancerBySpringCGLIB$$3f2a1b 或 com.sun.proxy.$Proxy87,说明调用穿过了代理,注解有机会生效。

更严谨的做法是在代码里直接断言当前对象是不是代理:

@Async
public void buildReport() {
    Class<?> clazz = this.getClass();
    boolean proxied = AopUtils.isAopProxy(this);
    log.info("this.class = {}, isAopProxy = {}", clazz.getName(), proxied);
    if (clazz.getName().contains("$$") || clazz.getName().contains("$Proxy")) {
        log.info("当前执行对象是代理,可能是代理内部的 target 回调");
    }
}

这里有个细节要讲清楚:即使调用穿过了代理,真正执行方法体时 this 仍然是目标对象,因为拦截器最终是反射调用 target 的方法。所以单纯看方法体里的 this 类型只能作为辅助证据,真正可靠的判断是看调用栈里有没有代理类的栈帧。Debug 时向上翻栈,看到 CglibAopProxy$DynamicAdvisedInterceptor.intercept 或 JdkDynamicAopProxy.invoke,就说明经过了代理。

反过来,若栈顶直接是业务类、中间只有 ReportService.generate,没有拦截器栈帧,则确定是自调用。

判定方法三:用 AopUtils 与 AopContext 做代码级判定

想在运行期自动告警,可以写一段判定逻辑。Spring 提供了 AopUtils 和 AopContext:

@Component
public class ProxyChecker {

    /** 判断该方法调用是否经过代理:需要先开启 exposeProxy */
    public static boolean isThroughProxy() {
        try {
            Object current = AopContext.currentProxy();
            return current != null;
        } catch (IllegalStateException e) {
            // exposeProxy=false 时会抛 IllegalStateException
            return false;
        }
    }
}

AopContext.currentProxy() 依赖 @EnableAspectJAutoProxy(exposeProxy = true) 或 @EnableAsync(proxyTargetClass = true) 之外的显式配置,默认是关闭的,关闭时抛出的异常信息是:

java.lang.IllegalStateException: Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available.

看到这个异常就说明当前线程的调用没有绑定代理,典型场景就是自调用。开启方式:

@Configuration
@EnableAsync
@EnableAspectJAutoProxy(exposeProxy = true)
public class AsyncConfig {
}

开启后配合 AopContext.currentProxy() 还能顺手解决自调用问题(见后文方案三)。

另一条更通用的判定路径,是用 ApplicationContext 拿到代理再和原始对象比对:

@Service
public class SelfInvokeDemo implements ApplicationContextAware {

    private ApplicationContext ctx;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.ctx = ctx;
    }

    public void outer() {
        Object proxy = ctx.getBean(SelfInvokeDemo.class);
        System.out.println("bean 是代理: " + AopUtils.isAopProxy(proxy));
        System.out.println("bean 类型: " + proxy.getClass().getName());
        inner();                       // 自调用
        ((SelfInvokeDemo) proxy).inner(); // 经代理
    }

    @Async
    public void inner() {
        System.out.println("inner 线程: " + Thread.currentThread().getName());
    }
}

输出会非常直观:两次 inner() 的线程名一主一池,前者证明自调用失效,后者证明代理生效。这个方法适合写进单元测试作为回归用例。

常见报错与”看起来像失效”的伪命题

下面几种情况经常被误判成自调用,实际原因不同,排查时要区分:

现象 / 报错 真实原因 处理办法
线程名不变,无异常 典型自调用,或 @EnableAsync 缺失 先查配置类,再用断点法看栈帧
@Async 方法返回值 void 却想拿结果 返回 void 无法接收结果,不是失效 改为 Future<T> 或 CompletableFuture<T>
java.lang.IllegalArgumentException: object is not an instance of declaring class 反射调用 target 时类型不匹配,常见于 JDK 代理 + 接口实现类内部强转 统一用接口类型接收,或切到 CGLIB(proxyTargetClass = true)
No qualifying bean of type 'java.util.concurrent.Executor' 自定义了多个 Executor 但 @Async 未指定 value 给 @Async("myExecutor") 指定名称,或将某个 Executor 标为 @Primary
异步方法里 @Transactional 不生效 两个注解叠加时的代理顺序问题,与自调用同源 拆到独立 Bean,避免同类叠加

关于 private 方法:给它加 @Async 不会报错,也不会生效,因为 CGLIB 无法覆写 private 方法,Spring 在创建代理时直接跳过。同理 final 方法和 final 类也不行。启动日志里通常没有任何提示,这是最隐蔽的一类”失效”。

还有 static 方法,静态方法不属于任何实例,代理机制完全无从插手,注解永远无效。

几种修复方案的取舍

方案一:拆分到独立 Bean(首选)

@Service
public class ReportService {
    private final ReportAsyncService asyncService;
    public ReportService(ReportAsyncService asyncService) {
        this.asyncService = asyncService;
    }
    public void generate() {
        asyncService.buildReport(); // 跨 Bean 调用,必经代理
    }
}

@Service
public class ReportAsyncService {
    @Async("reportExecutor")
    public void buildReport() {
        // 耗时逻辑
    }
}

结构最清晰,事务边界、线程池隔离都好控制,缺点是类数量增加。团队规范里建议直接把”异步方法必须独立成类”写进 Checkstyle 或 ArchUnit 规则。

方案二:自注入代理

@Service
public class ReportService {

    @Lazy
    @Autowired
    private ReportService self;

    public void generate() {
        self.buildReport();
    }

    @Async
    public void buildReport() { }
}

@Lazy 是为了打破循环依赖,Spring Boot 2.6 之后默认禁止循环引用(spring.main.allow-circular-references=false),不加 @Lazy 启动会报:

The dependencies of some of the beans in the application context form a cycle:
┌─────┐
|  reportService (field private com.example.ReportService com.example.ReportService.self)
└─────┘

方案三:AopContext.currentProxy()

public void generate() {
    ((ReportService) AopContext.currentProxy()).buildReport();
}

要求 exposeProxy = true,否则运行时就会撞上前面那个 IllegalStateException。优点是零结构改动,缺点是代码里混入 AOP 细节,且调用方如果在非代理上下文(比如异步线程、定时任务回调)再次进入会拿不到代理。

方案四:用编程式异步替代注解

@Service
public class ReportService {

    private final ThreadPoolTaskExecutor executor;

    public ReportService(ThreadPoolTaskExecutor executor) {
        this.executor = executor;
    }

    public void generate() {
        executor.execute(this::buildReport); // 显式提交,不依赖代理
    }

    public void buildReport() { }
}

完全绕开 AOP,自调用天然没问题,适合对线程池、异常处理、上下文传递要求精细的场景。代价是丢失了注解的声明式便利,异常处理要自己写 AsyncUncaughtExceptionHandler 的等价逻辑。

面试怎么答:把判定讲成三步

被问到”@Async 在什么情况下会失效”时,建议按”原理 → 判定 → 修复”作答,比背结论强得多:

  1. 原理:@Async 由 AsyncAnnotationBeanPostProcessor 生成代理,AnnotationAsyncExecutionInterceptor 拦截后把 Runnable/Callable 提交给 Executor,只有穿过代理才触发。
  2. 判定:先打印线程名初筛;再用断点看调用栈有没有 DynamicAdvisedInterceptor.intercept;或用 AopContext.currentProxy() 与 AopUtils.isAopProxy() 写断言。同时排除四个前置条件:@EnableAsync 是否开启、方法是否 public、类是否是 Spring Bean、Bean 是否被代理。
  3. 修复:优先拆 Bean;其次自注入加 @Lazy;再次 AopContext;对控制力要求高就用编程式提交。并补充说明 private、static、final 三类永远无法被代理。

能主动补充”@Transactional 与 @Async 叠加时的代理顺序问题”和”异步方法内 ThreadLocal 不传递”这两点,通常是加分项。

小结

把判定固化成习惯:任何 @Async 方法上线前,至少留一条带线程名的 info 日志或一条单元测试断言,把”是否真的异步”变成可观测事实,而不是靠猜。团队层面用 ArchUnit 规则禁止同类内部调用带异步注解的方法,能从根上消掉这类问题。

FAQ

Q1:@Async 方法加了注解,但日志线程名是 pool-1-thread-1,算生效吗?

算。pool-1-thread-* 是 ThreadPoolExecutor 默认线程工厂的命名,说明任务确实进了线程池。若你自定义了线程工厂前缀(比如 report-),日志里应看到 report-1,否则说明 @Async("xxx") 指定的 Executor 名字和实际 Bean 名不一致,Spring 会回退到默认 Executor 并打印告警。

Q2:为什么 @Async 方法返回 void 时异常完全看不到?

因为异常发生在线程池线程里,不会传播到调用方。Spring 默认的 SimpleAsyncUncaughtExceptionHandler 只打 error 日志。自定义方式:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("异步方法 {} 执行失败", method.getName(), ex);
    }
}

Q3:单测里 @Async 总是同步执行,测不出问题怎么办?

两种做法:一是只测逻辑不测异步,在测试配置里排除异步后处理器;二是用 @SpringBootTest 加载完整上下文,并配合 Awaitility 等异步结果。若一定要验证代理是否生效,用前面 ctx.getBean() 取代理对象再调用,比直接 new 对象可靠。

Q4:@Async 指定的线程池满了会怎样?

取决于拒绝策略。ThreadPoolTaskExecutor 默认是 AbortPolicy,会抛 RejectedExecutionException,而因为提交动作发生在拦截器里,异常会直接抛给调用方(前提是还没进入异步)。生产建议显式配置 CallerRunsPolicy 或带容量的队列并做监控,避免无限堆积导致 OOM。

Q5:Kotlin 的 suspend 函数加 @Async 有效吗?

无效且会误导。挂起函数并不是普通方法,CGLIB 代理拦截后拿到的返回值语义不同,实际不会挂起。异步场景应改用 CoroutineScope 或 @Async 包裹普通方法,不要混用。