Chapter 03

原理(下):AOP 代理的生成与失效

上一章把脊梁立了起来:AOP 代理在 bean 生命周期最后一步(postProcessAfterInitialization)由一个 BPP 生成。这一章顺着这个挂载点往下挖——代理具体怎么造出来、为什么在某些写法下会静默失效。

本章你将建立的 schema

  • AOP 自动代理创建器就是 02 章那个"最后一步的 BPP",它把 bean 换成代理
  • 两种造代理的办法:JDK 动态代理(要接口)vs CGLIB(造子类),各自的选择条件和限制
  • 自调用失效的根因——内部 this.method() 走原始引用,不过代理
  • @Configuration 自己也被 CGLIB 代理,这解释了 @Bean 方法互调为何返回同一单例
  • Spring AOP 与 AspectJ 的分界:什么时候代理式不够用,必须上字节码织入

3.1自动代理创建器:02 章那个"最后一步的 BPP"

Spring 不会凭空给 bean 加代理。干这件事的是一个具体的组件——AnnotationAwareAspectJAutoProxyCreator,它本身就是一个 BeanPostProcessor。它在每个 bean 走到生命周期第 ③d 步(postProcessAfterInitialization)时被回调,检查这个 bean 是否命中了某个切面的切点;命中就返回一个代理,没命中就原样返回。把图 2.2 的第 ③d 步放大,就是这张图:

初始化后的 原始 bean 自动代理创建器 一个 BeanPostProcessor postProcessAfterInitialization 命中切点 未命中 代理对象 包住原始 bean 原始 bean 原样返回
图 3.1自动代理创建器就是一个 BPP,在生命周期最后一步决定 bean 出口。注意:返回值会替换容器里的 bean——所以容器和别的 bean 拿到的,从此是代理,不再是原始对象。"AOP 怎么接进来的"全部秘密就在这个返回值。

3.2代理怎么造:JDK 动态代理 vs CGLIB

JDK 动态代理给"有接口的目标"造一个实现同接口的代理;CGLIB 给"没接口的目标"造一个继承它的子类。

为什么需要两种

代理必须和目标"长得一样",调用方才能无感替换。Java 提供两条造"长得一样的对象"的路:实现同一个接口,或继承同一个类。目标有接口就走前者(JDK 自带),没接口就只能走后者(CGLIB 生成子类)。

JDK 动态代理 条件:目标实现了接口 接口 PaymentGateway $Proxy(代理) implements 接口 目标 Target implements 接口 代理持有目标 经 InvocationHandler 转发 CGLIB 条件:无接口 / 强制 proxyTargetClass 目标 Target(类) 无需接口 CGLIB 子类(代理) extends 目标,覆盖方法 继承 final 方法 / 类无法覆盖 → 失效
图 3.2两种造代理的路:JDK 靠"实现同接口",CGLIB 靠"继承目标"。注意:CGLIB 靠覆盖方法实现增强,所以 final 方法、final 类它代理不了——这是后面一个失败模式的根因。

底层机制(比文档深一层):JDK 动态代理在运行期用 java.lang.reflect.Proxy 生成一个实现了目标接口的类,方法调用统一进入 InvocationHandler.invoke,由它决定"先跑通知、再转发给目标"。CGLIB 则用字节码生成一个目标类的子类,覆盖每个非 final 方法插入增强。选择规则:Spring Framework 默认"有接口走 JDK、没接口走 CGLIB";但 Spring Boot 自 2.0 起默认强制 CGLIB(proxyTargetClass=true),因为按接口代理时,注入点若写成实现类而非接口会注入失败,统一用 CGLIB 更稳。

表 3.1 · JDK 动态代理 vs CGLIB
维度JDK 动态代理CGLIB
前提目标必须实现接口无需接口
原理生成实现同接口的代理类生成目标的子类
限制只能代理接口里声明的方法无法代理 final 类 / 方法、private 方法
默认Spring Framework 有接口时默认Spring Boot 2.0+ 默认强制

3.3自调用失效:最经典的"事务没生效"

增强只在"从外部经过代理引用"的调用上触发;对象内部 this.method() 走原始引用,绕过代理,通知不触发。

01 章表 1.1 的加粗结论,在这里兑现。代理增强的前提是"调用先经过代理对象"。一个 bean 的方法 A 里直接调用本类的方法 B(this.methodB()),这个调用走的是原始对象的引用(this 永远指向原始目标,不是代理),根本没经过代理那一层,于是 B 上的 @Transactional / @Async / @Cacheable 通知统统不触发。

外部调用者 代理 Proxy 从这里进,通知才触发 目标 Target · OrderService methodA()(外部入口) methodB() @Transactional ① 通知触发✓ ② this.methodB() ② 同类内部 this 调用:走原始引用、不过代理 → 通知不触发 ✗
图 3.3同一对象,两条调用路径命运不同。注意:① 从外部进,必经代理那道墙,通知触发;② 在 methodA 内部调 this.methodB(),调用从没离开过目标对象、碰不到代理墙,@Transactional 形同虚设。
自调用失效的典型现场Java
@Service
public class OrderService {

    public void placeOrder(Order o) {
        // 从外部被调用:placeOrder 经过代理 —— 但它自己没有 @Transactional
        this.saveWithTx(o);   // ← this 调用:直达原始对象,绕过代理
    }

    @Transactional
    public void saveWithTx(Order o) {
        // 期望这里开启事务;可经 this 调进来时,事务通知不触发,事务不存在
    }
}

三种修法(都在"让调用重新经过代理"上做文章):把 saveWithTx 拆到另一个 bean 去调用;让 bean 自己注入自己(注入的是代理),用 self.saveWithTx(o);或用 AopContext.currentProxy() 取当前代理再调。本质都是绕开 this。

同一根因的一串失败模式

"自调用绕过代理"不是孤例,它是一整族失败模式的共同根因。把它们摆在一起,根因一眼看穿:

表 3.2 · 代理机制带来的失败模式族
失败模式症状根因(回到机制)
@Transactional 自调用事务不回滚、不生效this 调用绕过代理(本节)
@Async 自调用同步执行在当前线程,没异步同上,通知在代理上
final 方法上加注解注解静默无效CGLIB 无法覆盖 final 方法(3.2)
在 @PostConstruct 里调本类事务方法事务不生效代理在初始化后才生成,此刻 this 是裸对象(2.2)
单例里注入的 prototype 不刷新每次拿到同一个注入只在单例创建时发生一次(1.5)

这张表把前三章串成一条因果链——表里每个"根因"都指回某一节的机制。这正是把 Spring 当状态机看的回报:失败模式不用背,从机制能推出来。

3.4@Configuration 自己也是代理:full vs lite

@Configuration 类被 CGLIB 增强,使其 @Bean 方法之间互相调用时仍返回容器里的同一个单例。

一个常被忽略的事实:@Configuration 类自己也会被 CGLIB 代理。原因是 @Bean 方法之间会互相调用——如果不拦截,一个 @Bean 方法里调用另一个 @Bean 方法,就是一次普通 Java 方法调用,会再造一个新对象,破坏单例。

full 模式才保证单例Java
@Configuration                  // full 模式:类被 CGLIB 增强
public class AppConfig {
    @Bean public PaymentGateway gateway() { return new StripeGateway(repo()); }
    @Bean public InventoryRepository repo() { return new MySqlInventoryRepository(); }
    // gateway() 里调用 repo():被代理拦截,返回容器里那个单例 repo,不是新对象
}
表 3.3 · full 模式 vs lite 模式
场景@Bean 方法互调的结果是否保证单例
@Configuration(full,类被 CGLIB 增强)被代理拦截,返回同一单例是
@Component 里放 @Bean 方法(lite,不增强)普通方法调用,造出新对象否 —— 静默重复创建
@Configuration(proxyBeanMethods=false)不增强(省启动开销),不拦截互调否(前提是不依赖互调)
想一想

把上面的 @Configuration 换成 @Component,其它不动,容器里会有几个 InventoryRepository?这种错误启动时会报错吗?

先答,再展开

两个:一个是 repo() 作为 @Bean 注册的单例,另一个是 gateway() 内部调 repo() 时普通 Java 调用 new 出来的。不会报错——这是最阴险的地方,单例保证被静默破坏,要到运行时发现"两个仓储状态不一致"才暴露。proxyBeanMethods=false 是有意关掉增强(原生镜像 / 启动优化场景),前提是你确认没有 @Bean 互调依赖。

3.5判别:Spring AOP 还是 AspectJ?

Spring 代理式 AOP 的所有限制(只能方法、只对 bean、自调用失效),都源于"运行期靠代理织入"这一个选择。完整的 AspectJ 走另一条路——编译期或类加载期直接改字节码织入,因此没有这些限制。这是本章的判别点。

表 3.4 · Spring AOP vs AspectJ(备选方案)
维度Spring AOP(代理式)AspectJ(字节码织入)
织入时机运行期生成代理编译期 / 类加载期改字节码
连接点只有方法执行方法、字段、构造器、静态块
作用对象只有 Spring 容器里的 bean任意对象(含 new 出来的)
自调用失效(绕过代理)生效(直接织入字节码)
接入成本零额外工具,框架内置需织入器 / 编译期插件

判别准则:默认用 Spring AOP——日志、事务、缓存这类"作用在 bean 的公开方法上"的横切,它完全够用且零成本。只有当需求落在它的盲区——要拦字段读写或构造器、要增强非 bean 的普通对象、要让自调用也生效——才升级到 AspectJ,接受它的额外工具链成本。能用代理解决就别上字节码织入。

§本章 self-check

先合上教程作答。第 3、5 题是把前几章机制连起来推结论,正是面试追问的样子。

  1. 负责生成 AOP 代理的组件是什么类型?它在 bean 生命周期的哪一步工作?
  2. JDK 动态代理和 CGLIB 各自的前提条件是什么?CGLIB 代理不了什么?
  3. 用"调用是否经过代理"这一条,解释为什么 this.methodB() 上的 @Transactional 不生效。
  4. 为什么 @Configuration 类自己要被 CGLIB 增强?不增强(换成 @Component)会静默发生什么?
  5. 什么样的需求会逼你从 Spring AOP 切换到 AspectJ?举两个 Spring AOP 的盲区。
答案(先做完再展开)
  1. 它是一个 BeanPostProcessor(AnnotationAwareAspectJAutoProxyCreator),在初始化最后一步 postProcessAfterInitialization 工作。
  2. JDK 要求目标实现接口、生成实现同接口的代理;CGLIB 无需接口、生成目标子类。CGLIB 代理不了 final 类 / final 方法 / private 方法(无法覆盖)。
  3. 增强逻辑在代理对象上,只有"经过代理引用"的调用才触发。this 指向原始目标对象,this.methodB() 不经过代理,所以事务通知不触发。
  4. @Bean 方法会互相调用,CGLIB 增强拦截这些互调、返回容器里的同一单例。换成 @Component(lite)后互调变成普通 new,静默造出重复对象、破坏单例,且不报错。
  5. 需要拦截方法之外的连接点(字段、构造器)、需要增强非 bean 对象、需要自调用也生效。盲区例如:字段访问拦截、自调用增强。
进阶挑战 · 刚好够不着

一个"循环依赖 + AOP"同时出现的现场

A、B 互为字段依赖,且 A 被一个切面增强。结合 2.4 的三级缓存和本章的"代理在初始化末尾才生成",推断:B 在填充阶段拿到的 A,是原始对象还是代理?容器靠什么保证 B 拿到的 A 和最终容器里的 A 是同一个?

提示(卡住再展开)

B 在填充时从三级缓存取出 A 的工厂,工厂此刻就提前生成 A 的代理(而不是等到 A 自己初始化末尾),把这个代理作为早期引用给 B。于是 B 拿到的就是代理;A 后续初始化末尾会复用这个已生成的代理,不再重复包装。三级缓存那个"工厂延迟决定要不要代理"的设计,就是为这个一致性服务的——你刚好把 02 和 03 接上了。