过去大半年我一直在帮团队做技术面试,Spring AOP这块几乎是必问题。有意思的是,候选人基本都能说出“AOP是面向切面编程”“有JDK动态代理和CGLIB两种实现”,但再往下追问一句“Spring Boot 2.x 默认用的是哪种?为什么?”很多人就卡住了。等问到“三级缓存跟AOP的代理创建有什么关系”这种深度问题,能答上来的人更是凤毛麟角。
这篇文章我会沿着一条面试导向的线索来写:先讲透动态代理的字节码层原理,再分析Spring的代理选择策略,然后落到AOP核心概念和通知执行机制,最后用我实际面试中遇到的追问清单收尾。内容以Spring Framework和Spring Boot 2.x/3.x为主线,包含了从应用层到源码级的完整知识链条,适合准备面试的Java工程师,也适合那些用AOP写过日志、做过权限控制,但没深究过内部机制的朋友。
1. 面试标准问题:JDK动态代理和CGLIB到底差在哪——从字节码层面拆解
先回答那个最基础也最关键的问题。很多候选人知道“JDK动态代理基于接口,CGLIB基于继承”,但这个答案只说对了第一层。真正拉开差距的是第二层和第三层:代理对象在运行时是怎么被构造出来的?代理逻辑是在哪个环节被插入的?
1.1 JDK动态代理:接口约束下的反射分发机制
JDK动态代理从Java 1.3就开始提供了,核心类是java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler。它的工作逻辑可以拆成三步:
第一步,Proxy.newProxyInstance()接收三个参数:类加载器、接口数组、以及实现了InvocationHandler的调用处理器。JVM在运行时动态生成一个代理类,这个代理类实现了你传入的所有接口,并且继承自Proxy基类。
第二步,代理类中每个接口方法都被改写成一段统一逻辑:先获取当前方法的Method对象,然后调用InvocationHandler.invoke()方法,把代理对象、当前方法、参数列表都传进去。
第三步,你写在invoke()里的增强逻辑在这个时机执行——可以在调用目标方法前做权限校验、在调用后做日志记录、在异常时做兜底处理。
public interface UserService { void createUser(String name); } public class UserServiceImpl implements UserService { @Override public void createUser(String name) { System.out.println("创建用户:" + name); } } public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("[JDK代理] 方法开始:" + method.getName()); Object result = method.invoke(target, args); System.out.println("[JDK代理] 方法结束:" + method.getName()); return result; } } // 使用 UserService userService = new UserServiceImpl(); UserService proxyInstance = (UserService) Proxy.newProxyInstance( userService.getClass().getClassLoader(), userService.getClass().getInterfaces(), new LogInvocationHandler(userService) ); proxyInstance.createUser("张三");这个机制的天然限制就在第二步——代理类必须实现接口,因为Java是单继承,代理类已经继承了Proxy,没法再继承一个具体的业务类。所以如果你的目标类没有实现任何接口,JDK动态代理就直接失效了。
另一个值得注意的细节是:代理对象和目标对象是两个不同的对象,proxyInstance instanceof UserServiceImpl会返回false,但proxyInstance instanceof UserService会返回true。这个差异在实际项目里会引发一些隐蔽问题,后面提到this调用失效的场景时会细说。
1.2 CGLIB:通过生成子类实现代理的字节码增强技术
CGLIB的全称是Code Generation Library,它不走JDK的反射机制,而是直接操作字节码,在运行时生成一个目标类的子类。核心类是Enhancer和MethodInterceptor。Enhancer设置父类,MethodInterceptor提供拦截逻辑,然后把字节码交给ASM框架去生成新的Class对象。
public class CglibProxyFactory { public static Object createProxy(Class<?> targetClass) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(targetClass); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> { System.out.println("[CGLIB] 方法开始:" + method.getName()); Object result = proxy.invokeSuper(obj, args); System.out.println("[CGLIB] 方法结束:" + method.getName()); return result; }); return enhancer.create(); } }这里面有一个关键点经常被忽视:MethodInterceptor回调方法里的method.invoke(obj, args)是错的,必须使用proxy.invokeSuper(obj, args)。原因是obj是生成的子类实例,如果调用method.invoke(obj, args),等于在这个子类实例上重新触发了被拦截的方法,会造成无限递归,最终栈溢出。invokeSuper()则是绕过了子类重写逻辑,直接调用父类的原始方法实现。
这也是CGLIB的一条边界:final类无法被继承,final方法无法被重写,private方法无法被增强。所以CGLIB不是万能选项,碰到final方法,织入逻辑会被静默跳过,而且不会报错——这个坑在面试里经常被包装成场景题来考察。
1.3 两种代理的差异对照
| 对比维度 | JDK动态代理 | CGLIB |
|---|---|---|
| 底层机制 | 运行时生成接口实现类 | 运行时生成目标类的子类 |
| 目标要求 | 必须实现接口 | 不能是final类,方法不能是final |
| 性能特点 | 创建快,调用慢(反射开销) | 创建慢,调用快(直接方法调用) |
| 依赖 | JDK原生支持 | 需要引入CGLIB/Spring内置封装 |
| 适用场景 | 面向接口编程的Spring Bean | 无接口的普通类、Spring Boot默认场景 |
关于性能差异这点多说一句。早年的八股文喜欢说“JDK动态代理反射慢,CGLIB快”,但在JDK 8之后,反射调用的Method.invoke经过JIT优化后性能已经大幅跟上,两种方式的差距远没有到影响业务决策的程度。Spring Boot从2.x开始默认使用CGLIB,更多是出于设计统一性的考虑,而不是纯粹的性能原因。我在面试中会追问这一层,能答出这点的候选人通常对代理机制有更真实的理解。
2. Spring在JDK动态代理和CGLIB之间如何抉择——配置策略与边界场景
理解了两者的底层差异,下一步就是Spring层面的选择逻辑。这部分如果只看结论不看原因,很多边界case都覆盖不住。
2.1 原生Spring Framework的默认策略
先说一个容易记混的版本知识点。在Spring Framework 5.x(对应Spring Boot 2.x)之前,Spring AOP的默认策略是:目标类实现了接口就用JDK动态代理,没有接口就用CGLIB。这个策略体现在DefaultAopProxyFactory的createAopProxy()方法里。
public AopProxy createAopProxy(AdvisedSupport config) { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class<?> targetClass = config.getTargetClass(); // 目标类是接口或者本身就是代理类型,降级为JDK动态代理 if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } }这段源码就是核心。hasNoUserSuppliedProxyInterfaces(config)的意思是:如果没有显式指定代理接口,或者目标类本身没有接口,就走CGLIB分支。翻译成人话就是“能JDK就JDK,JDK不了就CGLIB”。
但在Spring Boot 2.x之后,这个默认值被改了。Spring Boot的AopAutoConfiguration里强制设置了spring.aop.proxy-target-class=true,所以Spring Boot项目默认全部走CGLIB。这个改动的动机很实际:开发者经常给Service类加接口,但Controller和Configuration这类组件基本不实现接口,如果还是走“有接口就JDK”的逻辑,一个应用里会同时存在两种代理机制,排问题的时候心智负担很重。干脆统一用CGLIB。
2.2 transactionManager和proxyTargetClass的配置关系
如果项目里没有用Spring Boot的默认配置,还想强制指定代理方式,可以在@EnableTransactionManagement或XML配置里设置proxyTargetClass属性。
@Configuration @EnableTransactionManagement(proxyTargetClass = true) public class TransactionConfig { }或者用Spring Boot的配置文件:
spring: aop: proxy-target-class: true这里有一条深坑值得展开。很多人以为设置了proxyTargetClass=true就一定走CGLIB,其实不一定。回到上面的源码逻辑:如果目标类是接口类型或者本身已经是一个JDK代理对象,ObjenesisCglibAopProxy是无法使用的。比如你直接对一个接口类型做AOP,Spring还是会降级回JDK动态代理。我在生产环境见过一个项目,Service接口和实现类分属不同的Maven模块,调用方只依赖接口类型,结果声明的@Transactional注解在部分方法上生效、部分不生效,最后排查发现就是因为某些Bean注入的是接口类型,触发了降级路径。
2.3 JDK代理下注入失败,CGLIB代理下却正常——接口暴露导致的问题
实际开发里最容易遇到的一个边界场景是:一个类实现了接口,另一个类注入它的时候,字段类型用的是实现类而不是接口。
在JDK代理模式下,Spring容器里存放的代理对象是Proxy生成的代理实例,它只实现了接口,并没有继承你的实现类。所以在注入时按实现类类型去查找Bean,会直接抛出NoSuchBeanDefinitionException或BeanNotOfRequiredTypeException。这种情况下有两个解法:
- 注入时统一使用接口类型,这也是Spring推荐的做法;
- 改用CGLIB代理,让代理对象继承实现类,注入自然匹配。
这个问题的隐蔽之处在于:不做AOP的时候一切正常,一加AOP、加事务、加自定义切面,应用启动就开始报错。很多新人会误认为是IoC容器出了问题,其实根子是代理机制的类型兼容性。
另外一个高频相关点是@Configuration类中bean方法之间的调用。如果你在@Configuration类里注入了一个被代理的Bean,并且通过方法调用而非依赖注入获取它,JDK代理和CGLIB代理在这个场景下的行为也有差异。CGLIB代理能够保留更多类层次的信息,而JDK代理只能看到接口方法,这会导致某些基于类的方法调用在JDK代理下失效。这也是Spring Boot默认CGLIB的另一个原因——减少这类“为什么我这个方法没生效”的困扰。
3. 一个能跑的AOP样板——从案例入手拆解切面、切点、通知和自定义注解
原理讲完必须落到代码。为了让后续的名词解释不悬空,我建议你亲手把下面这个例子跑一遍。这是我最常推荐给团队新人的AOP练习项目:给指定方法加上操作日志。
3.1 完整可运行的自定义注解AOP实现
先定义一个注解用于标注需要记录操作日志的方法:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String module() default ""; String action() default ""; }然后定义切面:
@Aspect @Component public class OperationLogAspect { @Pointcut("@annotation(operationLog)") public void operationLogPointcut(OperationLog operationLog) { } @Around("operationLogPointcut(operationLog)") public Object recordLog(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start = System.currentTimeMillis(); String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; // 这里落到日志系统或MQ System.out.println("操作成功: module=" + operationLog.module() + ", action=" + operationLog.action() + ", method=" + methodName + ", cost=" + cost + "ms"); return result; } catch (Exception e) { // 异常场景的日志记录 System.out.println("操作失败: module=" + operationLog.module() + ", action=" + operationLog.action() + ", method=" + methodName + ", error=" + e.getMessage()); throw e; } } }在Service方法上打上注解:
@Service public class OrderService { @OperationLog(module = "订单模块", action = "创建订单") public Long createOrder(OrderCreateRequest request) { // 业务逻辑... return 10001L; } }启动Spring Boot项目,调用createOrder()方法,观察控制台输出,你对AOP的执行机制就有了一个具象的认知。
这里注意一点:@Pointcut("@annotation(operationLog)")中的参数名必须和通知方法中的OperationLog operationLog参数名一致,否则Spring无法完成从切点到通知的参数绑定。我在IDE里见过太多人反复调整代码却没有效果,最后发现只是参数名不匹配。另外,注解的Retention策略必须是RUNTIME——如果留默认的CLASS,Spring根本无法在运行时通过反射读取到这个注解,切面会静默失效。
3.2 术语表:把官方的五个概念翻译成人话
跑通代码之后,我们把这些名词逐个对上号。
- 连接点(Join Point):理论上可以被拦截的方法调用点。所有被Spring管理的方法都算潜在连接点,但实际只有被切点表达式命中了的才会真正走代理逻辑。
- 切点(Pointcut):一个筛选条件,用来决定“哪些连接点最终被拦截”。上面的
@annotation(operationLog)就是一个切点表达式,它精确选中标记了@OperationLog注解的方法。 - 通知(Advice):拦截到目标方法之后要做的事,也就是切面类里具体的方法逻辑。Spring定义了
@Before、@After、@AfterReturning、@AfterThrowing、@Around五种类型。 - 切面(Aspect):切点加通知的组合体,用
@Aspect注解标记的类来描述。它是横切逻辑的完整封装。 - 织入(Weaving):把切面代码插入到目标对象方法调用链上的过程。Spring AOP是在运行时通过动态代理完成的,这个概念不需要你手动操作,但要理解它和编译期织入(AspectJ的ajc编译器)是两种完全不同的实现路线。
单独记忆这五个词没有意义,关键是理解它们组合起来的执行顺序:请求进入代理对象,代理对象根据切点表达式判断当前方法是否命中,命中则按通知定义好的顺序执行增强逻辑,最后再决定是放行目标方法还是提前短路。这一整个过程就是织入的运行时表现。
3.3 为什么Spring没有采用AspectJ的编译期织入
面试中经常有人混淆Spring AOP和AspectJ,这里花一小段讲清楚。
AspectJ是一个独立的AOP框架,它通过专门的编译器ajc把切面代码直接编译进目标类的字节码里,或者通过Load-Time Weaving(LTW)在类加载阶段修改字节码。这是真正的编译期和类加载期织入,不需要动态代理。
Spring AOP只有运行时织入这一条路,也就是靠动态代理在运行时生成代理对象。它没有采用AspectJ的编译期织入,原因很实际:
- 动态代理对于普通的业务场景足够了,学习成本和集成成本更低;
- 编译期织入需要引入AspectJ编译器,改变项目的构建方式,Spring希望通过纯Java方式解决80%的横切需求;
- 动态代理模式允许在运行时动态决定代理对象的行为,对Spring容器管理Bean的生命周期更友好。
所以Spring选择了“支持AspectJ注解风格,但底层自己实现代理机制”的折中路线。@Aspect、@Before这些注解直接沿用了AspectJ的规范,但执行引擎是Spring自己的ProxyFactory。
4. 通知的执行顺序不是靠直觉——环绕通知与异常分支的实际表现
“五种通知类型的执行顺序”是另一道送分但容易答错的题。给出规范答案之前,先说明一个问题:很多人表格背得滚瓜烂熟,一到代码里看到控制台打印顺序就跟预期不一致,因为他忽略了@Around内部代码的摆放位置。
4.1 正常情况下的执行顺序
假设目标方法正常返回,不抛异常,五种通知都配置了的话,实际执行顺序是:
@Around开启,执行环绕通知方法中proceed()之前的代码;@Before执行;- 目标方法本身执行;
@AfterReturning执行(仅在目标方法正常返回后);@After执行(无论正常还是异常都会执行);- 回到
@Around,执行proceed()之后的代码。
注意列顺序:@Before在@Around内部代码的前半段之后执行,@AfterReturning和@After都在proceed()返回之后、环绕通知收尾代码之前执行。所以如果你只凭“Before、After、Around”这几个英文单词的直觉来推断顺序,一定是错的。
用代码验证:
@Aspect @Component public class OrderTraceAspect { @Around("@within(org.springframework.stereotype.Service)") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { System.out.println("1. Around before"); Object result = joinPoint.proceed(); System.out.println("6. Around after"); return result; } @Before("@within(org.springframework.stereotype.Service)") public void before() { System.out.println("2. Before"); } @AfterReturning("@within(org.springframework.stereotype.Service)") public void afterReturning() { System.out.println("4. AfterReturning"); } @After("@within(org.springframework.stereotype.Service)") public void after() { System.out.println("5. After"); } }控制台输出将会是:
1. Around before 2. Before 3. 目标方法执行 4. AfterReturning 5. After 6. Around after4.2 异常情况下的执行顺序变化
目标方法抛出异常时,顺序变成:
@Around开启,执行proceed()之前的代码;@Before执行;- 目标方法执行并抛出异常;
@AfterThrowing执行(仅当proceed()没有捕获异常时);@After执行(最终通知,无论成败);- 异常继续向外抛出,
@Around中proceed()之后的代码不会执行,除非你在环绕通知里catch住了异常。
这里有一个关键细节:如果环绕通知在proceed()外面主动catch了异常,那么@AfterThrowing不会触发,因为从AOP框架的角度看,异常并没有从连接点“抛出去”。同理,@AfterReturning也不会触发,因为目标方法的结果是“异常”,不是“正常返回”。
@Around("@within(org.springframework.stereotype.Service)") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { try { return joinPoint.proceed(); } catch (Exception e) { System.out.println("Around 内捕获异常,AfterThrowing 不会触发"); // 可以返回兜底值,或者重新抛出 throw e; } }4.3 多个切面的嵌套顺序与Order控制
当一个方法被多个切面命中时,切面的执行顺序服从洋葱模型:外层切面的@Around先进入,最内层才是目标方法,退出时反向遍历。这个顺序由@Order注解或Ordered接口控制,数字越小越靠外。
@Aspect @Component @Order(1) public class FirstAspect { ... } @Aspect @Component @Order(2) public class SecondAspect { ... }@Order(1)的切面先进入,后退出。这个特性在做数据权限和操作日志叠加时很重要——比如权限切面需要最外层先做拦截,日志切面应该包在权限切面内层,这样才能根据权限校验结果决定要不要记录审计日志。
无独有偶,Spring事务的切面顺序默认是最低优先级(Ordered.LOWEST_PRECEDENCE)。如果你的自定义切面没有设置@Order,它默认的优先级也是最低,此时多个切面和事务切面的相对顺序取决于Bean的初始化先后。这也是网上很多“事务不回滚”问题的潜在原因之一:自定义切面在事务切面外层catch住异常并吞掉,事务管理器根本感知不到异常,自然就不会回滚。遇到这个问题时,先给事务切面或你的切面明确设置执行顺序,再看问题是否消失。
5. 代理创建的时机问题——Bean生命周期中AOP到底在哪个节点介入
面试深度到第三步,一般会开始问Bean生命周期和AOP的关系。这也是热搜词里“spring三级缓存原理”被反复搜索的原因——AOP的代理创建时机和循环依赖的三级缓存机制有着强关联。
5.1 从Bean实例化到代理对象的完整链路
一个普通单例Bean在Spring容器中的生命周期大致是:扫描到BeanDefinition,实例化对象,属性填充,初始化前(BeanPostProcessor前置处理),初始化(InitializingBean/init-method),初始化后(BeanPostProcessor后置处理),放入单例池。
AOP代理的创建就发生在“初始化后”这一步。核心组件是AbstractAutoProxyCreator,它实现了BeanPostProcessor接口。在postProcessAfterInitialization阶段,它会拿到刚完成初始化的原始Bean,调用wrapIfNecessary()方法判断这个Bean是否有匹配的切面,如果有,就生成代理对象替换掉原始Bean。
// AbstractAutoProxyCreator 核心逻辑(简化版) public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean != null) { Object cacheKey = getCacheKey(bean.getClass(), beanName); if (this.earlyProxyReferences.remove(cacheKey) != bean) { return wrapIfNecessary(bean, beanName, cacheKey); } } return bean; }所以代理对象是“替换”而不是“修改”原始Bean。原始Bean在实例化和属性填充阶段用的还是自己,只有到了Bean生命周期末尾才被代理对象顶替,之后注入给其他Bean的都是代理对象。
5.2 三级缓存如何配合AOP处理循环依赖
三级缓存机制是回答AOP代理创建时机时绕不开的细节。Spring解决构造器之外的循环依赖,靠的是三个缓存容器:
- 一级缓存
singletonObjects:存放完全初始化好的成品Bean; - 二级缓存
earlySingletonObjects:存放提前暴露的早期Bean(原始对象或早期代理对象); - 三级缓存
singletonFactories:存放ObjectFactory工厂,用于生成早期引用对象。
当Bean A和Bean B循环依赖时,过程是:
- A开始创建,实例化完成后,A的
ObjectFactory被放入三级缓存; - A执行属性填充,发现需要注入B;
- 创建B,B实例化完成,同样把自己的
ObjectFactory放入三级缓存; - B执行属性填充,发现需要注入A;
- 此时从三级缓存中找到A的
ObjectFactory,调用getEarlyBeanReference()拿到A的早期引用,注入给B; - B完成创建并放入一级缓存;
- A继续执行,完成初始化,触发
postProcessAfterInitialization,生成最终代理对象,放入一级缓存。
问题来了:如果A需要AOP代理,但A在步骤2暴露给B的是原始对象,B拿到的引用里就没有A的增强逻辑了。
5.3 为什么二级缓存可以没有,但三级缓存不能去掉
很多面试官喜欢追问“为什么用三级缓存而不是二级缓存”。答案和AOP的早期代理有关。
在三级缓存方案中,ObjectFactory.getEarlyBeanReference()方法内部会调用SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference()。AbstractAutoProxyCreator对这个方法做了重写:如果当前Bean命中切面,在这里就提前生成一个代理对象。
// AbstractAutoProxyCreator public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey = getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }关键点在于这个分支里记录了一个earlyProxyReferences标记。这样在后面的postProcessAfterInitialization阶段,wrapIfNecessary()发现earlyProxyReferences里已经有这个Bean的记录,就不会再生成一个不同的代理对象,而是直接返回早期创建的代理。
换句话说,三级缓存的意义在于:让AOP代理对象的生成时机可控,做到“如果需要早期引用,就提前生成代理;如果没人提前引用,就等到Bean初始化完成后再生成代理”。如果简化成二级缓存(直接存放早期原始对象),那么即使提前暴露了引用,也无法在暴露时对循环依赖的Bean做AOP增强——二级缓存里只能放下原始对象,等后面再生成代理,已经引用过原始对象的B就拿不到代理了。
这个机制在面试中经常被拿来验证候选人是否真正读过源码。能把这个链路讲清楚,通常意味着候选人已经具备定位实际Spring问题(比如循环依赖下AOP不生效)的能力。
6. 高频面试追问清单——从应用层到源码级的十五个问题
最后分享一份我在面试中实际使用过的追问清单,附带简要的回答方向。这部分不是让你背答案,而是帮你检查自己对Spring AOP的理解还有哪些盲区。
6.1 基础概念与使用场景
Q1:AOP能解决什么问题?有哪些典型使用场景?
AOP适合处理横切关注点,典型场景包括:事务管理、日志记录、权限校验、参数校验、性能监控、数据脱敏、审计日志、多数据源路由等。核心价值是避免这些逻辑散落在每个业务方法里,同时避免业务代码被横切逻辑侵入。
Q2:Spring AOP和AspectJ有什么区别?
Spring AOP是运行时织入,基于动态代理;AspectJ支持编译期织入、加载期织入。Spring AOP只支持方法级别的连接点,AspectJ还支持字段访问、构造器调用等。Spring默认使用AspectJ的注解风格,但执行引擎是Spring自己的。
Q3:Spring AOP能拦截哪些方法?哪些方法无法拦截?
能拦截Spring容器管理的Bean的public方法(也有办法处理protected方法,但有限的)。不能拦截static方法、final方法、private方法;不能拦截通过this调用的内部方法;不能拦截非Spring管理的对象的方法。
6.2 动态代理机制深入
Q4:JDK动态代理为什么必须基于接口?
因为代理类已经继承了Proxy类,Java单继承体系下只能通过实现接口来扩展行为。
Q5:CGLIB代理的类和方法有哪些限制?
final类无法被代理,final方法不会被重写,private方法无法被拦截。如果确实要反编译确认代理类的形态,可以用ClassWriter保存生成的字节码文件后用IDE反编译查看。
Q6:Spring Boot默认用哪种代理?为什么?
从Spring Boot 2.x开始默认使用CGLIB(proxyTargetClass=true),目标是统一应用内的代理模式,减少因JDK代理和CGLIB混杂带来的类型不兼容问题。
Q7:如何强制Spring使用JDK动态代理?
设置spring.aop.proxy-target-class=false或@EnableTransactionManagement(proxyTargetClass = false)。需要注意工具类方法若目标类没有接口则无法生效。
6.3 异常与失效场景
Q8:为什么@Transactional在同类方法调用时会失效?
同类方法this调用不会经过代理对象,而是直接调用原始目标对象的方法,AOP拦截逻辑根本不会执行。
Q9:Spring事务什么时候会回滚?
默认仅对RuntimeException和Error回滚,受检异常默认回滚?不对——受检异常默认不回滚。需要@Transactional(rollbackFor = Exception.class)来自定义。
Q10:切面里吞掉异常会对事务产生什么影响?
如果自定义切面在事务切面之前捕获并吞掉异常,事务管理器感知不到异常,不会触发回滚。解决办法是明确切面顺序,并且在切面中重新抛出异常或使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
6.4 源码级进阶
Q11:Bean生命周期的哪个阶段生成AOP代理?postProcessAfterInitialization阶段,由AbstractAutoProxyCreator.wrapIfNecessary()完成。
Q12:三级缓存和AOP有什么关系?
三级缓存中的ObjectFactory可以在Bean早期暴露引用时就生成AOP代理,并通过earlyProxyReferences标记避免后期重复生成。
Q13:如果完全去掉三级缓存,只留二级缓存,循环依赖和AOP会怎么样?
单纯从循环依赖角度,二级缓存理论上够用,但会失去“按需提前生成AOP代理”的能力,导致循环依赖场景下被提前引用的Bean无法完成AOP增强。
Q14:AOP代理对象注入给自己会产生什么问题?
代理对象和被代理对象是不同的对象,如果Bean内部把this传给外部或保存引用,可能绕过代理逻辑。这也是常见“为什么我加了日志切面但没生效”的排查路径之一。
Q15:为什么Spring的@Scheduled标注的方法不建议this调用?@Scheduled的调度原理同样依赖代理或后置处理器,同类内部调用同样会跳过调度增强,导致定时任务不执行或重复执行。
写在后面
回看这一大篇,核心其实就两句话:Spring AOP的一切行为,都是动态代理机制在Bean生命周期中的具体表现;理解了代理对象的生成时机和作用边界,绝大数“切面不生效”的问题都能自己判断出来。
我给团队的建议一直是不要死记源码行号,而是自己动手写一个最简AOP例子,打印出每一步的执行顺序,在这个过程里建立“代理对象、目标对象、切面逻辑”三者之间的心智模型。模型一旦建立,后面再看Spring事务、异步、缓存的失效场景,基本就是同一套判断逻辑的复用。如果时间有限,优先把本文第4节的通知顺序和第5节的三级缓存链路吃透,这两个点既是面试高频,也是实际排查时最常用的底层知识。