一、承接前两篇:086 留下的一个问题
086 讲循环依赖时提到过一句话:"如果 ServiceB 需要被 @Transactional 代理,提前暴露给别人的引用必须是最终的代理对象。"这句话背后藏着一整套机制没有展开——代理到底是怎么生成的?谁决定要不要代理?多个切面同时生效时执行顺序是什么?这就是本篇要讲的 Spring AOP:它不是一个孤立的话题,而是 IoC 容器(081-086 系列)之上叠加的另一层能力,理解了它,才能真正看懂事务管理(089-092 会讲)、日志切面、权限校验这些"看起来是魔法"的功能到底是怎么实现的。
二、四个术语:先精确定义,再谈原理
AOP 的术语经常被混着用,导致概念模糊。先用一个具体场景把四个核心术语钉死:
@Aspect
@Component
public class LogAspect {
// ↓↓↓ 这是"切点"(Pointcut):一个表达式,描述"哪些方法要被拦截"
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceMethods() {}
// ↓↓↓ 这是"通知"(Advice):在切点匹配的方法执行前后,要插入的具体逻辑
@Before("serviceMethods()")
public void logBefore(JoinPoint jp) {
// ↓↓↓ jp 就是"连接点"(Join Point):程序执行中一个具体的、可被拦截的点
System.out.println("调用方法:" + jp.getSignature().getName());
}
}
- 连接点(Join Point):程序执行过程中一个具体的、理论上可以被拦截的点。Spring AOP 里连接点只能是方法调用(AspectJ 全量版还支持字段访问、构造器调用等,但 Spring AOP 阉割成了方法级别)。
- 切点(Pointcut):一个"筛选规则",用表达式描述"众多连接点里,哪些是我关心的"。
execution(* com.example.service.*.*(..))就是一个切点表达式,语义是"匹配service包下所有类的所有方法"。 - 通知(Advice):真正要执行的逻辑,回答"匹配到的连接点上,要插入什么代码"。
@Before/@After/@Around等注解标注的方法体就是通知。 - 织入(Weaving):把通知"插入"到目标方法调用链条里的过程。这是最容易被忽略但最关键的一环——第七节详细展开。
一句话理清关系:切点负责"选点",通知负责"插什么",织入负责"怎么插进去",连接点是被选中的那个具体位置。

三、Spring AOP 与 AspectJ:一个常见的混淆点
很多资料把 Spring AOP 和 AspectJ 混着讲,因为 Spring AOP 的切点表达式语法直接借用了 AspectJ 的语法(execution(..)、within(..) 这些写法源自 AspectJ)。但两者的织入方式完全不同:
| 维度 | AspectJ(完整版) | Spring AOP |
|---|---|---|
| 织入时机 | 编译期或类加载期(Compile-time / Load-time Weaving) | 运行期,容器启动时动态生成代理 |
| 织入方式 | 直接修改目标类的字节码 | 生成一个新的代理类,包裹原始对象 |
| 支持的连接点 | 方法调用、字段访问、构造器、静态初始化块等 | 仅方法调用(且仅限 public 方法,且仅限 Spring 容器管理的 Bean) |
| 性能 | 无额外调用开销(字节码已经改好) | 有一层代理转发的开销(通常可忽略) |
| 依赖容器 | 不依赖任何 IoC 容器,纯字节码层面工作 | 依赖 Spring 容器生成和管理代理对象 |
Spring AOP 之所以只做"运行期代理"这一种织入方式,是工程权衡的结果:编译期织入需要改造编译流程(引入 AspectJ 编译器),类加载期织入需要特殊的 JVM agent,都增加了部署复杂度。Spring 选择了"牺牲一部分能力(不能拦截字段访问、不能拦截私有方法),换取零编译期改动、纯 Java 运行期方案"这条更工程化的路径——这也是为什么面试里经常被问"Spring AOP 能拦截 private 方法吗",答案是不能,因为代理是"包一层壳",壳外面的调用者才会经过代理,private 方法根本没有被外部调用的可能,天然绕开了代理。

四、切点表达式:不止 execution 一种写法
execution(..) 是最常用但不是唯一的切点表达式:
// execution:按方法签名匹配,最常用、最精确
@Pointcut("execution(public * com.example.service.OrderService.*(..))")
// within:按类或包匹配,粒度比 execution 粗
@Pointcter("within(com.example.service..*)") // 匹配 service 包及其子包下所有类的所有方法
// @annotation:按方法上标注的注解匹配,事务、权限校验的切点常用这种写法
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
// @within:按类上标注的注解匹配
@Pointcut("@within(com.example.annotation.Auditable)")
// args:按方法参数类型匹配
@Pointcut("args(String, ..)") // 匹配第一个参数是 String 的方法
// 组合切点:用 && || ! 组合多个切点表达式
@Pointcut("serviceMethods() && @annotation(org.springframework.transaction.annotation.Transactional)")
@annotation(...) 这种写法值得单独强调,因为它是事务管理的核心机制:@Transactional 本身只是一个普通注解,不带任何行为,真正让它"生效"的是 Spring 内置了一个基于 @annotation(Transactional) 的切点,凡是被这个注解标注的方法,都会匹配到事务相关的 Advisor(089-092 会详细展开事务的实现)。理解了这一点,就能理解为什么"注解本身不做事,是切点+通知在背后配合"这个说法。
五、通知类型全景:五种通知的执行时机
@Aspect
@Component
public class DemoAspect {
@Pointcut("execution(* com.example.service.OrderService.placeOrder(..))")
public void targetMethod() {}
@Before("targetMethod()")
public void before(JoinPoint jp) {
System.out.println("① 方法执行前");
}
@Around("targetMethod()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("②a 环绕通知:调用前");
Object result = pjp.proceed(); // 手动触发下一环(可能是目标方法,也可能是下一个环绕通知)
System.out.println("②b 环绕通知:调用后,拿到返回值 " + result);
return result; // 环绕通知可以修改返回值,甚至完全不调用 proceed() 而直接短路
}
@AfterReturning(pointcut = "targetMethod()", returning = "result")
public void afterReturning(Object result) {
System.out.println("③ 方法正常返回后,拿到返回值 " + result);
}
@AfterThrowing(pointcut = "targetMethod()", throwing = "ex")
public void afterThrowing(Exception ex) {
System.out.println("④ 方法抛出异常后:" + ex.getMessage());
}
@After("targetMethod()")
public void after() {
System.out.println("⑤ 方法结束后(无论成功还是抛异常,都会执行,类似 finally)");
}
}
五种通知里,**@Around**是能力最强、也最容易出错的一种:它接管了整个方法调用的控制权,必须显式调用 pjp.proceed() 才会真正执行目标方法(或下一层通知)——忘了调用 proceed(),目标方法根本不会被执行;调用了两次,目标方法会被执行两次。其他四种通知(@Before/@After/@AfterReturning/@AfterThrowing)本质上都是 @Around 的"语法糖简化版",Spring 内部最终都会统一转换成基于拦截器链(MethodInterceptor)的形式来执行,@Around 只是把这条链路直接暴露给了开发者。

六、代理创建的源码链路:谁在什么时候决定要不要代理
回到 086 提过的类:AnnotationAwareAspectJAutoProxyCreator。它本质上是一个 BeanPostProcessor,标准情况下(没有循环依赖)它的介入时机是在 Bean 初始化完成之后:
// AbstractAutoProxyCreator#postProcessAfterInitialization(简化后的核心逻辑)
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean != null) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
// 如果这个 Bean 没有因为循环依赖被提前代理过,走这里正常包装
return wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean;
}
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
// 第一步:找出所有能匹配这个 Bean 的 Advisor(切点+通知的组合体)
Object[ ] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
if (specificInterceptors != DO_NOT_PROXY) {
// 第二步:有匹配的 Advisor,才需要创建代理;否则原样返回,不额外开销
Object proxy = createProxy(bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
return proxy;
}
return bean; // 没有任何切点匹配这个 Bean,直接返回原始对象,不生成代理
}
关键点:wrapIfNecessary 名字里的"if necessary"不是修饰词,是真实的判断逻辑——没有任何 Advisor 匹配的 Bean,容器根本不会为它生成代理,这也是为什么"给所有 Bean 都套用 AOP"在性能上不是什么大问题:真正被代理的只有切点命中的那部分。
第一步 getAdvicesAndAdvisorsForBean 的匹配过程,就是把容器里所有注册的 Advisor(切点+通知的绑定体)拿出来,逐个用其切点表达式去匹配当前 Bean 的类和方法,凡是切点表达式命中的,就收集进结果列表——这就是"切点如何在运行期真正生效"的落地实现,对应第四节讲的各种 execution/@annotation 表达式,最终都会被编译成一个可以用来做匹配判断的 Pointcut 对象。

七、织入的本质:拦截器链与"洋葱模型"
一个方法可能同时匹配多个切面的多个通知(比如同时有日志切面和权限校验切面都命中了 placeOrder 方法),这些通知的执行顺序遵循"洋葱模型"——外层先进后出:
@Aspect
@Order(1) // 数字越小,优先级越高,越靠外层
public class SecurityAspect {
@Around("execution(* com.example.service.OrderService.placeOrder(..))")
public Object check(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("[权限] 检查前");
Object result = pjp.proceed();
System.out.println("[权限] 检查后");
return result;
}
}
@Aspect
@Order(2) // 越靠内层
public class LogAspect {
@Around("execution(* com.example.service.OrderService.placeOrder(..))")
public Object log(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("[日志] 记录前");
Object result = pjp.proceed();
System.out.println("[日志] 记录后");
return result;
}
}
实际执行顺序是:[权限]检查前 → [日志]记录前 → 目标方法执行 → [日志]记录后 → [权限]检查后——外层通知(@Order 值更小)包裹住内层通知,内层通知包裹住目标方法本身,像剥洋葱一样一层套一层。这套顺序在源码层面是通过 ReflectiveMethodInvocation(或 CGLIB 场景下的 CglibMethodInvocation)维护一个拦截器链的游标(currentInterceptorIndex)来实现的:每次调用 proceed(),游标前进一位,链走完了才真正触达目标方法。织入本身不是"编译时把代码焊在一起",而是运行时通过这样一条拦截器链,把多个通知和目标方法串成一次连续的调用——这正是"织入"这个术语在 Spring AOP 里的真实含义:不是字节码层面的静态织入,而是调用链层面的动态串联。

八、与 086 呼应:循环依赖场景下代理生成的第二条路径
第六节讲的是"标准路径"——postProcessAfterInitialization 里判断要不要代理。但 086 讲过,如果这个 Bean 卷入了循环依赖并被提前暴露,代理会在更早的 getEarlyBeanReference() 里生成:
// AbstractAutoProxyCreator 同时实现了 SmartInstantiationAwareBeanPostProcessor
public Object getEarlyBeanReference(Object bean, String beanName) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
this.earlyProxyReferences.put(cacheKey, bean); // 标记:这个 Bean 已经走过"提前代理"这条路
return wrapIfNecessary(bean, beanName, cacheKey); // 复用同一套判断+生成逻辑
}
现在可以把 086 和本篇串起来看整个图景:wrapIfNecessary 是真正生成代理的核心逻辑,它只会被调用一次——不管是从标准路径(postProcessAfterInitialization)触发,还是从提前暴露路径(getEarlyBeanReference)触发。走了提前暴露路径的 Bean,会往 earlyProxyReferences 里记一笔,等真正走到标准路径时(第六节代码里的 if (this.earlyProxyReferences.remove(cacheKey) != bean) 判断),发现"这个 Bean 已经代理过了",直接跳过,避免代理被生成两次。这就是"三级缓存的工厂函数只调用一次"(086 第三节的源码)和"代理只生成一次"(本篇)这两个看似独立的机制,在源码层面其实共享同一套"只做一次"的设计逻辑。

九、常见误区:自调用为什么会让 AOP 失效
@Service
public class OrderService {
@Transactional
public void outer() {
this.inner(); // 危险:this.inner() 是直接调用原始对象的方法,没有经过代理!
}
@Transactional
public void inner() {
// 期望这里开启一个新事务,但因为是自调用,@Transactional 完全不生效
}
}
这是 Spring AOP 面试里最经典的一个坑:代理拦截的前提是"调用方经过代理对象"。容器注入给别处的是代理对象,别的类调用 orderService.inner() 时确实经过代理,能触发事务逻辑;但在 OrderService 内部,this.inner() 里的 this 是原始对象,不是代理对象,这次调用完全绕开了代理链,inner() 上的 @Transactional 自然不会生效。常见的规避方式:把 inner() 抽到另一个类里(让调用变成真正跨 Bean 的调用);或者注入自身的代理引用(@Autowired private OrderService self; 然后用 self.inner() 调用,但这种写法可读性差,通常不推荐作为首选方案);或者使用 AopContext.currentProxy()(需要开启 exposeProxy=true)拿到当前代理对象再调用。

十、常见误区补充
- "加了
**@Transactional**就一定有事务" —— 不对,除了自调用会失效,方法必须是public(JDK 代理和 CGLIB 代理都无法拦截非 public 方法产生的对外可见调用),类或方法不能是final(CGLIB 需要继承目标类生成子类代理,final类/方法无法被覆写)。 - "Spring AOP 可以拦截任意 Java 对象的方法" —— 不对,只能拦截 Spring 容器管理的 Bean,普通
new出来的对象不会被代理包裹。 - "@Around 通知里不调用 proceed() 是配置错误" —— 不完全对,这其实是一种合法的"短路"用法,比如权限校验失败时直接返回错误结果而不调用目标方法,属于有意为之的设计。
- "多个 @Aspect 的执行顺序不可控" —— 不对,
@Order注解(或实现Ordered接口)可以精确控制,值越小优先级越高,越靠"洋葱"外层。
十一、总结
Spring AOP 的完整链条,串起来就是:
- 四个术语各司其职:连接点是位置,切点是筛选规则,通知是插入的逻辑,织入是把通知和目标方法串成一条调用链的过程。
- Spring AOP 选择了运行期动态代理而非编译期织入,用可接受的能力阉割(只支持方法级别、只支持 public 方法、只对容器管理的 Bean 生效)换来了零编译期改动的工程简洁性。
- 代理不是无条件生成的:
wrapIfNecessary只在有 Advisor 匹配时才创建代理,且无论从标准路径还是循环依赖的提前暴露路径触发,都只会生成一次。 - 多通知的执行顺序是"洋葱模型",由
@Order控制层级,底层通过拦截器链的游标机制实现。 - 自调用是 AOP 失效的头号陷阱,本质原因是"绕开了代理对象直接调用原始对象"。
延伸阅读
- Spring 源码:
AbstractAutoProxyCreator#wrapIfNecessary/#getEarlyBeanReference、AnnotationAwareAspectJAutoProxyCreator、ReflectiveMethodInvocation - 本系列相关篇目:081(Spring IoC 与 DI)、082(Bean 生命周期)、083(BeanDefinition 与容器刷新)、084(Bean 作用域)、085(依赖注入方式)、086(循环依赖)、088(JDK 动态代理与 CGLIB)、089-092(Spring 事务系列,AOP 的典型工程应用)
面试官视角提问(不计入阅读时间)
Q1:如果一个类的方法签名完全匹配某个切点表达式(比如**execution(* com.example.service.*.*(..))**),但这个对象是业务代码里手写**new**出来的,而不是从 Spring 容器里拿到的,它会被这个切面拦截吗?
不会。AOP 织入的前提是"这个对象必须经过 AnnotationAwareAspectJAutoProxyCreator 这个 BeanPostProcessor 的处理",而这个处理只发生在 Spring 容器创建、管理的 Bean 身上——具体来说是在 postProcessAfterInitialization 里,只有容器 getBean() 拿到的实例才会走到这一步。手写 new 出来的对象完全绕开了 Bean 的生命周期,从始至终没有机会被 wrapIfNecessary 处理,所以哪怕方法签名和切点表达式字符串层面完全匹配,也不会生成代理,AOP 逻辑不会触发。这也是为什么在单测里直接 new OrderService() 而不通过 Spring 容器时,@Transactional 之类的注解会"悄悄"失效,很多人第一次踩这个坑都以为是配置错了,其实根源是对象来源问题。
Q2:**AnnotationAwareAspectJAutoProxyCreator**本身也是一个 Bean,它也需要被容器创建和管理,那它会不会也被其他**BeanPostProcessor**(甚至被它自己)代理,从而引发"鸡生蛋"式的死循环?
不会,Spring 对这类特殊 Bean 做了专门规避。容器在 refresh() 的 registerBeanPostProcessors 阶段,会提前、单独把所有 BeanPostProcessor 类型的 Bean 实例化出来,且这批 Bean 实例化时使用的是一套更简化的流程——它们互相之间,以及对自己,都不会触发完整的后处理器链条(这是 AbstractApplicationContext#registerBeanPostProcessors 里专门处理的顺序问题:先创建实现了 PriorityOrdered 的,再创建实现了 Ordered 的,最后创建普通的,且每一批创建时,之前已经注册的后处理器会立刻生效,但正在创建的这一个不会对自己生效)。这是一种典型的"自举"(bootstrapping)问题的规避方案:让基础设施类先于普通业务 Bean 完成注册,且注册过程本身不依赖它们要提供的能力。
Q3:如果一个**@Around**通知里用**try-catch**捕获了目标方法抛出的异常,但没有重新抛出(也没有调用**pjp.proceed()**之外的任何异常处理),这会对外层的事务切面产生什么影响?
影响很大,而且是一个真实的生产事故常见根因。Spring 声明式事务的回滚逻辑(TransactionInterceptor,本质上也是一个 @Around 风格的拦截器)依赖"能不能捕获到目标方法抛出的异常"来判断要不要回滚——如果某个内层的 @Around 通知把异常吞掉了,不往外抛,那么外层的事务拦截器看到的就是"这次调用正常返回,没有异常",于是会正常提交事务,而不是按预期回滚。这种 bug 极难排查:业务日志里看不到异常(被吞了),事务也没有报错(正常提交了),但数据状态却是错的——因为部分操作其实是在异常发生后才应该被撤销的。这也是为什么"环绕通知里捕获异常后必须重新抛出,除非你非常清楚自己在干什么"是一条几乎不该被打破的纪律。
Q4:如果同一个方法被两个**@Order**值完全相同的切面同时命中,Spring 怎么决定谁在外层谁在内层?
@Order 值相同时,两个切面的相对顺序是未定义行为——Spring 内部会退化到按 Advisor 在容器里的注册顺序(间接受 Bean 名称、扫描顺序等因素影响)来排序,但这个顺序不是设计上保证稳定的契约,不同 Spring 版本、不同的 Bean 扫描方式甚至同一版本换个包名都可能导致顺序变化。工程实践上,永远不要依赖**@Order**相同时的隐式顺序,如果多个切面之间确实存在执行顺序上的依赖关系(比如权限校验必须在日志记录之前),应该显式给它们分配不同的、有明确梯度的 @Order 值(比如 100、200、300,中间留出插入空间),把顺序变成显式声明的契约,而不是依赖注册顺序这种隐式的、随时可能被破坏的假设。
Q5:**@Transactional**到底是怎么"变成"一个 AOP 切面的?它对应的**Advisor**和普通业务里自己写的**@Aspect**有什么本质区别?
@Transactional 的生效路径本质上和自定义 @Aspect 走的是同一套 Advisor 匹配机制,只是 Spring 把它做成了"内置"的:容器里会注册一个 BeanFactoryTransactionAttributeSourceAdvisor,它的切点逻辑是"检查方法或类上是否有 @Transactional 注解"(对应第四节讲的 @annotation(...) 表达式的语义,只是内部实现用的是专门的 TransactionAttributeSourcePointcut 而不是字符串表达式),它的通知逻辑是 TransactionInterceptor——这是一个 MethodInterceptor,本质上就是一个功能固定的 @Around 通知:调用前开启事务,调用后根据是否有异常决定提交还是回滚。区别在于:自定义 @Aspect 是"声明式地描述你想要的横切逻辑",而 @Transactional + 内置 Advisor 是"框架把一段通用、高频复用的横切逻辑封装成了一个可配置的注解"——两者在织入机制层面完全一致,只是暴露给开发者的接口形态不同(一个是自定义切面类,一个是一个注解)。这说明 Spring AOP 不只是给开发者用的"工具",也是 Spring 自身实现许多核心特性(事务、缓存 @Cacheable、异步 @Async)的统一底层机制。
Q6:AOP 的核心价值是"让横切逻辑对业务代码隐身"——业务方法上只有一个注解,看不到实际发生了什么。从架构设计角度,你怎么评价这种"隐式织入"的取舍?如果重新设计,你会更倾向于隐式还是显式?
"隐式织入"是一把双刃剑:好处是业务代码确实干净,@Transactional/@Cacheable 这种一行注解就能复用大量基础设施逻辑,避免每个方法手写 try { begin(); ... commit(); } catch { rollback(); } 式的重复代码;代价是排查问题时的认知负担被转移了——本篇第九节讲的自调用失效就是最典型的例子:代码"看起来"应该开启事务,但实际运行时悄悄没有生效,而且没有任何编译期或运行期的显式报错提示你"这里 AOP 没生效"。如果重新设计,会倾向于保留隐式织入作为默认能力(复用价值太高,不该放弃),但在关键的失效场景上补一层显式的诊断信息——比如启动期扫描出所有"标注了 @Transactional 但从当前调用路径分析可能构成自调用"的方法,输出一条 WARN 级别日志,把原本要等到生产环境数据错乱才会暴露的问题,尽量往前移到启动阶段可观测的地方。核心思路是:隐式带来的复用红利可以保留,但"隐式失效"这件事本身不应该是隐式的,框架应该主动帮你把"AOP 可能没生效"这个风险点显式地摆出来。