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 步放大,就是这张图:
3.2代理怎么造:JDK 动态代理 vs CGLIB
JDK 动态代理给"有接口的目标"造一个实现同接口的代理;CGLIB 给"没接口的目标"造一个继承它的子类。
代理必须和目标"长得一样",调用方才能无感替换。Java 提供两条造"长得一样的对象"的路:实现同一个接口,或继承同一个类。目标有接口就走前者(JDK 自带),没接口就只能走后者(CGLIB 生成子类)。
final 方法、final 类它代理不了——这是后面一个失败模式的根因。底层机制(比文档深一层):JDK 动态代理在运行期用 java.lang.reflect.Proxy 生成一个实现了目标接口的类,方法调用统一进入 InvocationHandler.invoke,由它决定"先跑通知、再转发给目标"。CGLIB 则用字节码生成一个目标类的子类,覆盖每个非 final 方法插入增强。选择规则:Spring Framework 默认"有接口走 JDK、没接口走 CGLIB";但 Spring Boot 自 2.0 起默认强制 CGLIB(proxyTargetClass=true),因为按接口代理时,注入点若写成实现类而非接口会注入失败,统一用 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 通知统统不触发。
methodA 内部调 this.methodB(),调用从没离开过目标对象、碰不到代理墙,@Transactional 形同虚设。@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。
同一根因的一串失败模式
"自调用绕过代理"不是孤例,它是一整族失败模式的共同根因。把它们摆在一起,根因一眼看穿:
| 失败模式 | 症状 | 根因(回到机制) |
|---|---|---|
@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 方法调用,会再造一个新对象,破坏单例。
@Configuration // full 模式:类被 CGLIB 增强
public class AppConfig {
@Bean public PaymentGateway gateway() { return new StripeGateway(repo()); }
@Bean public InventoryRepository repo() { return new MySqlInventoryRepository(); }
// gateway() 里调用 repo():被代理拦截,返回容器里那个单例 repo,不是新对象
}
| 场景 | @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 走另一条路——编译期或类加载期直接改字节码织入,因此没有这些限制。这是本章的判别点。
| 维度 | Spring AOP(代理式) | AspectJ(字节码织入) |
|---|---|---|
| 织入时机 | 运行期生成代理 | 编译期 / 类加载期改字节码 |
| 连接点 | 只有方法执行 | 方法、字段、构造器、静态块 |
| 作用对象 | 只有 Spring 容器里的 bean | 任意对象(含 new 出来的) |
| 自调用 | 失效(绕过代理) | 生效(直接织入字节码) |
| 接入成本 | 零额外工具,框架内置 | 需织入器 / 编译期插件 |
判别准则:默认用 Spring AOP——日志、事务、缓存这类"作用在 bean 的公开方法上"的横切,它完全够用且零成本。只有当需求落在它的盲区——要拦字段读写或构造器、要增强非 bean 的普通对象、要让自调用也生效——才升级到 AspectJ,接受它的额外工具链成本。能用代理解决就别上字节码织入。
§本章 self-check
先合上教程作答。第 3、5 题是把前几章机制连起来推结论,正是面试追问的样子。
- 负责生成 AOP 代理的组件是什么类型?它在 bean 生命周期的哪一步工作?
- JDK 动态代理和 CGLIB 各自的前提条件是什么?CGLIB 代理不了什么?
- 用"调用是否经过代理"这一条,解释为什么
this.methodB()上的@Transactional不生效。 - 为什么
@Configuration类自己要被 CGLIB 增强?不增强(换成@Component)会静默发生什么? - 什么样的需求会逼你从 Spring AOP 切换到 AspectJ?举两个 Spring AOP 的盲区。
答案(先做完再展开)
- 它是一个 BeanPostProcessor(
AnnotationAwareAspectJAutoProxyCreator),在初始化最后一步postProcessAfterInitialization工作。 - JDK 要求目标实现接口、生成实现同接口的代理;CGLIB 无需接口、生成目标子类。CGLIB 代理不了
final类 /final方法 /private方法(无法覆盖)。 - 增强逻辑在代理对象上,只有"经过代理引用"的调用才触发。
this指向原始目标对象,this.methodB()不经过代理,所以事务通知不触发。 @Bean方法会互相调用,CGLIB 增强拦截这些互调、返回容器里的同一单例。换成@Component(lite)后互调变成普通 new,静默造出重复对象、破坏单例,且不报错。- 需要拦截方法之外的连接点(字段、构造器)、需要增强非 bean 对象、需要自调用也生效。盲区例如:字段访问拦截、自调用增强。
一个"循环依赖 + AOP"同时出现的现场
A、B 互为字段依赖,且 A 被一个切面增强。结合 2.4 的三级缓存和本章的"代理在初始化末尾才生成",推断:B 在填充阶段拿到的 A,是原始对象还是代理?容器靠什么保证 B 拿到的 A 和最终容器里的 A 是同一个?
提示(卡住再展开)
B 在填充时从三级缓存取出 A 的工厂,工厂此刻就提前生成 A 的代理(而不是等到 A 自己初始化末尾),把这个代理作为早期引用给 B。于是 B 拿到的就是代理;A 后续初始化末尾会复用这个已生成的代理,不再重复包装。三级缓存那个"工厂延迟决定要不要代理"的设计,就是为这个一致性服务的——你刚好把 02 和 03 接上了。