最近在帮一个团队升级项目到 Spring Boot 4.0 的预览版本时,遇到了一件挺有意思的事:服务能正常启动,日志里也没有任何异常,但原本跑得好好的 AOP 切面突然全部失效,事务回滚也不生效了,连@Aspect注解都报了找不到类的错误。顺着这个故障往下查,最后落到的问题原点反而是“Spring AOP 实现原理:动态代理与字节码增强”这两个最基础的机制。这篇文章就从这次排查经历切入,把 Spring AOP 的底层实现原理完整拆开讲一遍:为什么需要动态代理,JDK 代理和 CGLIB 字节码增强各自是怎么工作的,Spring 容器又是如何把代理对象“偷梁换柱”塞进业务代码里的。
1. 从热搜词说起:Spring Boot 4.0 找不到 AOP 是怎么回事
1.1 版本升级之后切面“哑火”的现场还原
先说这个让不少人困惑的现象。在 Spring Boot 3.x 时代,只要引入spring-boot-starter-web,AOP 相关的基础依赖基本都会跟着进来,@Aspect、@Before、@Around这些注解开箱即用。到了 Spring Boot 4.0 的预览版本里,依赖结构做了更细粒度的拆分,AOP 不再默认随 Web Starter 传递,这才出现了“找不到 AOP”的情况。实际报错可能是这样:
java.lang.NoClassDefFoundError: org/aspectj/lang/annotation/Aspect也可能是切面静默失效,完全没有报错。为什么会有两种表现?取决于项目里是否通过其他方式间接引入了 AspectJ 注解包。只要aspectjweaver缺失,切面类的注解解析就会失败,Spring 自然也就扫描不到任何切面定义。
1.2 一条命令定位缺失的依赖
遇到这种问题,第一件事永远是看依赖树,而不是改业务代码。用 Maven 的话,两条命令就能定位:
mvn dependency:tree -Dincludes=org.springframework:spring-aop mvn dependency:tree -Dincludes=org.aspectj:aspectjweaverGradle 项目则用:
./gradlew dependencyInsight --dependency org.springframework:spring-aop ./gradlew dependencyInsight --dependency org.aspectj:aspectjweaver正常情况下应该能看到spring-aop和aspectjweaver这两个关键依赖。如果发现它们不在依赖树里,或者版本被某个冲突规则排除了,那 AOP 失效的原因就直接浮出水面。修复方式也很简单,显式加上spring-boot-starter-aop依赖即可。
1.3 从故障回到原理:AOP 不是一个魔法
这次升级故障最值得思考的地方在于,很多同学平时用 AOP 用得很熟练,但一旦脱离 Spring Boot 的自动配置,连 AOP 依赖了哪几个 jar 包都说不清楚。这说明我们对 AOP 的理解还停留在“加注解、写切面”的 API 层,没有真正落到实现原理层。AOP 不是什么运行时魔法,它本质上就是一句话:在 Bean 创建过程中,通过动态代理生成一个代理对象,让代理对象在调用目标方法前后执行额外的横切逻辑。而动态代理与字节码增强,正是两条最核心的技术路径。
2. AOP到底在解决什么问题:从横切逻辑到代理模式
2.1 一段没有切面的痛苦代码
在没有 AOP 的世界里,给业务方法加上日志和事务控制,代码会长成这样:
public Order createOrder(OrderDTO dto) { long start = System.currentTimeMillis(); try { // 开启事务 TransactionStatus status = transactionManager.begin(); Order order = doCreate(dto); // 提交事务 transactionManager.commit(status); log.info("createOrder cost {} ms", System.currentTimeMillis() - start); return order; } catch (Exception e) { // 回滚事务 transactionManager.rollback(status); log.error("createOrder failed", e); throw e; } finally { log.info("createOrder end"); } }如果只有createOrder一个方法还好说,问题是updateOrder、cancelOrder、queryOrder每一个方法都要重复这么一套逻辑。这就是典型的横切逻辑:日志、事务、权限校验这些功能不关心业务方法的具体逻辑,但又散落在每个业务方法里,导致代码严重重复,维护成本直线上升。
2.2 从继承模板到代理模式的演进
有人会想到用继承来解决:写一个抽象基类,把日志和事务的逻辑放到模板方法里,子类只需要实现业务步骤。但继承的问题是硬编码的父子关系,而且容易破坏业务类的继承结构。装饰器模式也可以做,但每写一个业务类都要手工写一个装饰器类,依然繁琐。真正让横切逻辑和业务逻辑解耦的,是代理模式。
代理模式的核心思想是:客户端不直接持有目标对象,而是持有一个代理对象。代理对象内部包装了目标对象,在调用目标方法的前后插入额外逻辑。放到上面的例子里,OrderServiceImpl不需要再写任何日志和事务代码,代理对象负责统一处理。这样业务类保持干净,公共逻辑集中管理。
2.3 静态代理与动态代理的分水岭
静态代理的问题是代理类需要手动编写,一个业务类对应一个代理类,项目会急剧膨胀。动态代理则不一样:它在运行时动态生成代理类,不用手工写代理类文件。AOP 正是站在动态代理的肩膀上。Spring AOP 中两种最常见的实现路径,就是 JDK 动态代理和 CGLIB 字节码增强,两者对应着不同的适用场景,这也是面试中追问最深的点。
3. JDK动态代理与CGLIB字节码增强:两条技术路线的底层拆解
3.1 JDK动态代理:接口优先的官方方案
JDK 动态代理是 Java 原生支持的代理方式,使用起来非常简洁。先定义一个业务接口和实现类:
public interface OrderService { void createOrder(OrderDTO dto); } public class OrderServiceImpl implements OrderService { @Override public void createOrder(OrderDTO dto) { // 核心业务逻辑 } }然后实现InvocationHandler接口,把增强逻辑写在invoke方法里:
public class TimeLogInvocationHandler implements InvocationHandler { private final Object target; public TimeLogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start = System.currentTimeMillis(); Object result = method.invoke(target, args); System.out.println(method.getName() + " cost " + (System.currentTimeMillis() - start) + " ms"); return result; } }最后通过Proxy.newProxyInstance创建代理对象:
OrderService proxy = (OrderService) Proxy.newProxyInstance( OrderServiceImpl.class.getClassLoader(), new Class<?>[] {OrderService.class}, new TimeLogInvocationHandler(new OrderServiceImpl()) ); proxy.createOrder(dto);这里有一个关键问题:为什么 JDK 动态代理必须要求目标类实现接口?原因在代理类的生成机制上。JDK 在运行时生成的代理类,统一继承了java.lang.reflect.Proxy这个父类,而 Java 是单继承的,代理类已经占用了唯一的继承位,不能再继承目标类。它只能通过实现接口的方式,对外暴露与目标类一致的业务方法。所以当调用方通过接口引用调用方法时,实际走的是代理类的invoke方法。
我遇到过不少同学在这个地方栽跟头:目标类没有实现接口,强行用 JDK 动态代理,结果抛ClassCastException或者创建代理失败。这不是代码写错了,而是 JDK 动态代理在机制上就不支持无接口的类。
3.2 CGLIB字节码增强:没有接口也能代理的硬核方案
CGLIB 解决的是 JDK 动态代理解决不了的问题:目标类没有接口时怎么办。Spring 已经把 CGLIB 重定位到自己的包路径下,也就是org.springframework.cglib,所以工程里不需要显式引入 CGLIB 的独立依赖。
CGLIB 的使用方式和 JDK 动态代理非常像,核心入口是Enhancer:
Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OrderServiceImpl.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> { long start = System.currentTimeMillis(); Object result = proxy.invokeSuper(obj, args); System.out.println(method.getName() + " cost " + (System.currentTimeMillis() - start) + " ms"); return result; }); OrderServiceImpl proxy = (OrderServiceImpl) enhancer.create();这段代码里最核心的一行是enhancer.setSuperclass(OrderServiceImpl.class)。CGLIB 会在运行时通过 ASM 字节码框架动态生成一个OrderServiceImpl的子类,这个子类覆写了目标类中所有可继承的方法。客户端调用proxy.createOrder(dto)时,实际调用的是子类覆写后的方法,子类方法内部会回调MethodInterceptor,并在拦截器里调用proxy.invokeSuper(obj, args)去执行原始方法。这就是“字节码增强”最直观的体现:不是修改原始类,而是生成一个新的子类字节码,在子类方法中插入增强逻辑。
3.3 字节码增强的边界:final类、final方法为什么不行
理解了“CGLIB 靠生成子类来增强”这一点,很多限制就顺理成章了。
final修饰的类无法被继承,所以 CGLIB 无法代理 final 类。final修饰的方法无法被子类覆写,所以无法对 final 方法做增强。private方法对子类不可见,覆写无从谈起。static方法属于类而不是实例,与代理对象的实例调用无关。
这些限制不是 Spring 刻意为之,而是 Java 继承机制和字节码生成框架本身就有的边界。面试时问到 CGLIB 的局限性,从“继承”两个字回答,基本就站住了。
3.4 两条技术路线的完整对比
| 对比维度 | JDK动态代理 | CGLIB字节码增强 |
|---|---|---|
| 代理对象形态 | 生成实现接口的 Proxy 子类 | 生成目标类的子类 |
| 目标类要求 | 必须实现至少一个接口 | 不能被 final 修饰 |
| 底层技术 | Proxy + InvocationHandler | ASM 字节码生成 |
| 方法调用方式 | 反射调用 | FastClass 索引直调,性能更好 |
| 典型应用场景 | 按接口编程的 Spring AOP | 无接口的类或Spring Boot默认场景 |
有一个细节值得单独说明:Spring Boot 2.x 之后spring.aop.proxy-target-class默认值是true,意味着即使目标类实现了接口,Spring 也会优先使用 CGLIB 生成代理。所以现代 Spring Boot 项目里,我们大多数时候接触到的代理对象都是 CGLIB 代理,而面试题里却还在反复问 JDK 和 CGLIB 的区别,这说明两者的核心差异依然值得掌握。
4. Spring AOP的完整组装链路:从@Aspect到代理对象
4.1 切面的定义与Pointcut表达式
先看一段最常见的切面代码:
@Aspect @Component public class LogAspect { @Around("execution(* com.example.service.*.*(..))") public Object logAround(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); try { return pjp.proceed(); } finally { System.out.println(pjp.getSignature().getName() + " cost " + (System.currentTimeMillis() - start) + " ms"); } } }这里的@Aspect声明这是一个切面类,@Around声明环绕通知,execution(* com.example.service.*.*(..))是切点表达式,用来匹配哪些方法需要被增强。Spring AOP 本身并不做 AspectJ 的编译期织入,它只是借用了 AspectJ 的注解和切点表达式语法,实际运行时增强仍然是通过动态代理完成的。理解这一点,对后面看源码会有很大帮助。
4.2 @EnableAspectJAutoProxy 开动了什么
当我们在配置类上加了@EnableAspectJAutoProxy,Spring 会注册一个核心的BeanPostProcessor:AnnotationAwareAspectJAutoProxyCreator。这个类是整个 AOP 的枢纽。Spring 容器在创建每一个 Bean 时,都会执行所有BeanPostProcessor的postProcessAfterInitialization方法,而AnnotationAwareAspectJAutoProxyCreator正是在这个阶段介入,完成代理对象的创建。
如果项目里使用了@SpringBootApplication,其实这个注解也被自动配置包含了,因为在自动配置类AopAutoConfiguration里已经默认开启了相关处理。
4.3 从BeanDefinition到代理对象:wrapIfNecessary 的决策过程
把 AOP 代理的创建过程简化成四步,非常清晰:
- 拿到当前 Bean 的类型。
- 找到容器中所有的 Advisor(通知器),并对每个 Advisor 的切点表达式做匹配。
- 如果至少有一个 Advisor 匹配成功,说明当前 Bean 需要增强。
- 根据配置和 Bean 的特征,选择 JDK 动态代理或 CGLIB,生成代理对象,返回给容器。
核心方法在AbstractAutoProxyCreator的wrapIfNecessary里。这个方法会先检查当前 Bean 是否已经配了增强,然后遍历getAdvicesAndAdvisorsForBean拿到的候选通知器列表。匹配成功后,调用createProxy创建代理。创建代理时,具体走 JDK 还是 CGLIB,取决于proxyTargetClass的配置:true走 CGLIB,false时再看目标类是否实现接口。
4.4 Spring三级缓存与AOP:代理为什么要提前生成
这里要重点说一下 Spring 三级缓存和 AOP 的关系。很多同学对三级缓存的印象停留在“解决循环依赖”上,但实际上它和 AOP 也有很深的纠缠。
正常情况下,Bean 的完整创建流程是:实例化 -> 属性填充 -> 初始化 -> 生成代理。但如果 Bean A 和 Bean B 发生了循环依赖,比如 A 需要注入 B,B 又需要注入 A,那么在 A 实例化之后、还没完成初始化的时候,Spring 就会把 A 提前暴露到一个 ObjectFactory 工厂里。如果 A 需要被 AOP 增强,这个getEarlyBeanReference阶段就会提前创建 A 的代理对象,并让 B 拿到这个代理对象。
为什么要这么绕?因为如果等到初始化完成后再生成代理,B 拿到的就是 A 的原始对象,A 上的事务、日志等切面逻辑全部失效。Spring 在AbstractAutoProxyCreator中专门有getEarlyBeanReference方法处理这种情况,而且会通过earlyProxyReferences集合记录哪些 Bean 已经被提前代理,防止后续初始化流程中再次包裹同样的对象。
所以,当有人问“Spring 三级缓存和 AOP 有什么关系”时,回答的核心就是:在循环依赖场景下,AOP 代理必须在 Bean 提前暴露时就生成,否则代理会晚于依赖注入,导致切面失效。
4.5 proxyTargetClass 的配置影响
Spring Boot 配置文件里只要能写进来这一行:
spring.aop.proxy-target-class=true代理策略就是 CGLIB。如果显式设置为false,并且目标类实现了接口,Spring 会改用 JDK 动态代理。不过现代 Spring Boot 默认已经是 CGLIB 了,这个配置在实际项目中需要调整的场景不多,但在老 Spring 项目中还是比较常见的。
5. 真实项目里AOP的实战场景与代码示例
5.1 接口耗时与日志切面:最常见的入门场景
几乎所有项目都需要的接口日志和性能监控,用 AOP 实现是最自然的。下面这个切面可以直接抄进工程里:
@Aspect @Component public class AccessLogAspect { private static final Logger log = LoggerFactory.getLogger(AccessLogAspect.class); @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) " + "|| @annotation(org.springframework.web.bind.annotation.GetMapping) " + "|| @annotation(org.springframework.web.bind.annotation.PostMapping)") public Object logController(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); String method = pjp.getSignature().toShortString(); long cost = System.currentTimeMillis() - start; log.info("{} 执行耗时 {} ms", method, cost); if (cost > 1000) { log.warn("{} 执行超时,耗时 {} ms", method, cost); } return result; } }这段切面的实际价值在于把耗时和超时告警逻辑从每个 Controller 里彻底去掉。配上@Pointcut抽取公共切点后,维护起来也更方便。
5.2 基于自定义注解实现幂等与防重复提交
AOP 和其他机制结合,最典型的例子是自定义注解 + 切面实现防重复提交。先定义一个注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoRepeatSubmit { long timeout() default 3000; }然后在需要防止重复提交的接口上直接加注解:
@NoRepeatSubmit(timeout = 5000) @PostMapping("/order") public Result createOrder(@RequestBody OrderDTO dto) { // 业务逻辑 return Result.success(); }切面里通过 Redis 的 SET NX 实现分布式锁语义:
@Aspect @Component public class NoRepeatSubmitAspect { @Autowired private StringRedisTemplate redisTemplate; @Around("@annotation(noRepeatSubmit)") public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable { String method = pjp.getSignature().toLongString(); String key = "repeat:submit:" + method + ":" + JSON.toJSONString(pjp.getArgs()); Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", noRepeatSubmit.timeout(), TimeUnit.MILLISECONDS); if (Boolean.FALSE.equals(success)) { throw new RuntimeException("请勿重复提交"); } return pjp.proceed(); } }这里就体现了 AOP 无侵入的优势:业务方法完全不知道自己在被防重复提交保护,所有逻辑都在切面里集中处理。
5.3 声明式事务:AOP最成功的商业实践
@Transactional是所有 Spring 开发者最先接触的 AOP 应用,而且它背后就是一套完整的事务增强链路。Spring 内部通过ProxyTransactionManagementConfiguration注册了事务相关的 Advisor,这个 Advisor 的切点会匹配带有@Transactional注解的方法。匹配成功后,容器创建代理对象,在方法调用前后执行事务开启、提交、回滚等逻辑。
理解了这一点,面试中关于事务的很多问题都能迎刃而解:为什么同类之间用this调用@Transactional方法事务不生效?因为this指向的是原始对象而不是代理对象,包裹事务逻辑的代理完全没被经过。为什么@Transactional加在private方法上不生效?因为private方法无法被 CGLIB 覆写。这些问题的根源都在动态代理的底层机制上。
5.4 动态数据源切换与读写分离
多数据源场景里,AOP 也是核心角色。我们通常会定义一个@DataSource注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataSource { String value() default "master"; }然后在业务方法上标注走哪个数据源:
@DataSource("slave") public List<User> listUsers() { // 查询逻辑 }切面里把注解中的值写入当前线程的 ThreadLocal,动态数据源通过AbstractRoutingDataSource的determineCurrentLookupKey方法读取 ThreadLocal 中的 key,实现读写分离。整个过程中业务代码同样没有出现任何数据源切换逻辑,全部由 AOP 接管。
5.5 什么时候不该用AOP
AOP 不是万能的,过度使用反而会增加排查难度。我自己总结了几条使用边界:
- 方法内部调用(尤其是
this调用)达不到增强效果,要让别人调外部 Bean。 - 高频的短小方法如果有大量方法级切面,会有额外的动态代理调用开销。
- 切面表达式写的过宽,容易误拦截到不该拦截的方法,导致日志里出现大量无关的“噪音”。
- 团队如果对 AOP 不熟,建议把切面逻辑收敛在少数几个切面类里,不要散落得到处都是。
6. 高频踩坑与面试追问:从自调用失效到代理选择
6.1 同一个类里的自调用为什么让切面失效
这是 AOP 面试出场率最高的坑。看这段代码:
@Service public class UserService { @Transactional public void createUser(User user) { // 插入用户 } public void createUserWithLog(User user) { this.createUser(user); // 自调用 } }外部调用createUserWithLog时,Spring 注入给调用方的是一个代理对象。执行到this.createUser(user)这一行时,this指向的是目标类原始对象,而不是代理对象,所以事务增强完全不会执行。这就是自调用导致切面失效的根本原因。
6.2 三种可靠的自调用修复方案
方案一,注入自身。把代理对象注入进来,通过代理对象调用方法:
@Service public class UserService { @Autowired private UserService self; public void createUserWithLog(User user) { self.createUser(user); } }方案二,使用 AopContext。前提是开启 exposeProxy:
@EnableAspectJAutoProxy(exposeProxy = true)调用时通过AopContext.currentProxy()获取当前代理对象:
((UserService) AopContext.currentProxy()).createUser(user);方案三,把被调用的方法拆到另一个 Bean 里。这是最彻底也最符合 Spring 设计风格的做法,把createUser从UserService中拆到UserCreationService,由UserService调用userCreationService.createUser(user),代理链自然生效。
6.3 final、private、static 方法为什么不能被增强
这里再把这些边界条件汇总一下,其实原理在前面的 CGLIB 部分已经论证过了:
final方法不能覆写,所以增强代码没有插入点。private方法对子类不可见,CGLIB 子类无法覆写。static方法是类级别的,和实例代理完全无关。
所以 Spring 文档里也明确建议:@Transactional不要写在private方法上,不是 Spring 做不到,而是 Java 继承机制决定了看不到这个方法的代理入口。
6.4 面试官视角的高频追问清单
结合最近社群里的讨论,整理一份面试高频问题清单,每个问题都可以用本文前面讲过的机制来组织答案。
Spring 如何选择 JDK 动态代理和 CGLIB?核心看
proxyTargetClass:Spring Boot 默认 true,强制 CGLIB;设为 false 时优先 JDK,JDK 不可用时再降级 CGLIB。为什么 JDK 动态代理必须要求接口?因为生成的代理类已经继承了
java.lang.reflect.Proxy,单继承限制下只能通过接口暴露方法。Spring Boot 4.0 找不到 AOP 类,第一步做什么?查依赖树,重点看
spring-aop和aspectjweaver。@Transactional同类自调用为什么失效?this是原始对象,不是代理对象,事务增强走不到。Spring 三级缓存和 AOP 有什么关系?循环依赖时需要在早曝光阶段提前生成代理对象,否则其他 Bean 拿到的是原始对象。
动态代理对象的类型是什么?JDK 代理是
com.sun.proxy.$ProxyN,CGLIB 代理是目标类的子类,类名包含$$SpringCGLIB$$。
6.5 一点实测经验
在调试 AOP 相关问题时,我个人的习惯是先在 IDEA 里对调用点打断点,然后查看对象的实际类型。如果看到UserService$$SpringCGLIB$$0这种类名,说明代理链路是正常的;如果看到的是原生的UserService,那就说明 Spring 容器里根本没有生成代理对象,问题大概率出在依赖或配置上。
另外一个体会是,很多代理失效问题其实是团队编码习惯问题,比如内部自调用、把@Transactional写到private方法上。与其在出问题时手忙脚乱地查,不如在代码规范里加一条:所有需要 AOP 增强的方法都必须从外部 Bean 调用,方法不能用final和private修饰。这几条规则配合动态代理原理落地之后,团队里关于 AOP 的故障一下少了很多。