
1. 先说说“代码冗余地狱”到底长什么样我见过太多人第一次翻开 Spring AOP 源码时第一反应是“这东西到底解决了什么问题”。其实不用急着看源码先回忆一下没接触 AOP 之前我们在真实业务代码里最常干的一件事——给每个方法加日志。打开一个典型的 Service 类你会发现一个非常熟悉的画面public class UserService { public User findUser(Long id) { log.info(开始查询用户, id id); long start System.currentTimeMillis(); try { User user userDao.findById(id); log.info(查询用户成功, 耗时 (System.currentTimeMillis() - start) ms); return user; } catch (Exception e) { log.error(查询用户失败, e); throw e; } } public User updateUser(User user) { log.info(开始更新用户, user user); long start System.currentTimeMillis(); try { User result userDao.update(user); log.info(更新用户成功, 耗时 (System.currentTimeMillis() - start) ms); return result; } catch (Exception e) { log.error(更新用户失败, e); throw e; } } // 其他若干方法... }这段代码有什么问题表面上看只是有点啰嗦实际上它的危害远超想象其一大量横切逻辑重复。日志、权限校验、性能统计、事务管理这类逻辑不属于核心业务却要在一个个业务方法里反复出现。一个项目几十个 Service几百个方法每一处都要贴一段几乎一模一样的“胶水代码”。其二业务代码被淹没。如果你接手的是别人写的类想快速看懂 findByUser 到底做了什么你可能要在一堆日志打印、时间统计、异常处理里翻半天才找到真正的那一行 SQL 调用。代码的“可读性”和“可维护性”就是这么被慢慢拖垮的。其三改动成本极其恶劣。假设今天监控系统要求把所有日志格式从“开始查询用户, id”改成 JSON 格式或者需要加上 traceId 关联链路。怎么办全局替换替换完还得出 bug。更麻烦的是如果领导要求“开发环境打 DEBUG 日志生产环境只打 WARN”你难道再全局改一遍这三个问题在《AOP 实战》里有个统一的专业称呼——横切关注点Cross-Cutting Concerns。所谓横切就是指日志、权限、事务这些逻辑像一把刀一样横着切过了所有业务模块它们的代码天然和业务代码纠缠在一起。当时普通人能想到的解决方案无非几种抽公共方法、用继承、用模板方法。但稍微有点工程经验的人都会发现这些方案治标不治本。真正让代码得到救赎的就是 Spring AOP。所以这篇文章的目标也很明确用一整篇的篇幅把 Spring AOP 的核心原理讲透重点放在“代理模式”这条主线上。理解了代理你就理解了 AOP 的一半理解了 AOP 的概念体系你就能写出不重样、不冗余、还能被同事夸“不愧是老手”的代码。2. AOP 的“修仙世界观”先搞懂切面、连接点、切入点、通知这四大概念在开始写代码之前我建议你先把 AOP 的整套概念体系在脑子里过一遍。因为所有框架层面的功能最终都是对这些概念的具象化实现。你现在理解的越清楚后面看 Spring 源码就越轻松。2.1 切面Aspect——把横切逻辑模块化所谓切面就是把前面提到的日志、事务、权限这类横切逻辑集中封装成一个模块。简单理解以前这些逻辑是一盘散沙东写一块西写一块现在你把它集中收拢到一个类里这个类就是切面。日常生活里类似的操作是“插件化”。比如你买了一个智能插座这个插座本身不带任何电器功能但它可以在电流异常时切断电源、在用电高峰时统计功率。它嵌在你家电路和你家电器之间不干扰电器本身的逻辑却能在某些时刻介入。切面就是这样一个“智能插座”。2.2 连接点JoinPoint和切入点Pointcut——精确到方法的“标靶”连接点很好理解它指程序执行过程中可以插入切面的位置。在 Spring AOP 里连接点就是方法的执行更严格地说只有方法级不像 AspectJ 还能切字段、构造器。但问题来了项目里有成百上千个方法难道切面要每个都插一手当然不是。所以就有了“切入点”这个概念。切入点更像是一个筛选条件它告诉你只有符合这个条件的方法才会被插入切面逻辑。打个比方连接点就像全校所有教室切入点是“三年级二班这样的教室”。只有三年级二班的教室才装监控其他教室不装。切入点的编写在 Spring 里依赖 AspectJ 的表达式最常见的写法是这样Pointcut(execution(* com.example.demo.service.*.*(..))) public void serviceLayer() {}这段表达式的意思是“匹配 com.example.demo.service 包下所有类的所有方法不论返回类型和参数”。类似这种表达式还有很多变体比如匹配注解、匹配参数、匹配返回值等下一篇讲注解实战时再展开细聊。2.3 通知Advice——五种类型的触发时机通知就是“切面里要在目标方法前后干的事”。Spring 定义了五种类型我把它们列成一张表方便对照记忆通知类型触发时机典型场景Before目标方法执行前权限校验、参数校验AfterReturning目标方法正常返回后审计日志、数据整理AfterThrowing目标方法抛出异常后异常记录、告警推送After目标方法结束之后含异常资源释放、清理Around目标方法执行前后都能介入最灵活日志、事务、性能统计、分布式锁、重试Around 为什么最特殊因为它可以完全接管目标方法的执行。你可以决定“要不要执行”“什么时候执行”“执行前后干什么”“返回值要不要改”甚至“吞掉异常”。其他四种通知本质上是 Around 的某种简化形式。2.4 织入Weaving和目标对象Target——切面如何“缝”进去织入是 AOP 术语里最形象的一个词把切面逻辑织入到目标对象生成一个新的代理对象。织入的时机有三种编译期、类加载期、运行期。Spring AOP 采用的是运行期织入也就是在程序跑起来以后、Bean 创建过程中动态完成的——这就是它依赖代理模式的原因。目标对象就是你写的那个业务 Bean比如 UserService。它本身不知道自己会被切面“盯上”。切面做的事是把 UserService 包装成一个代理对象外部拿到的其实是这个代理对象。当调用方法时代理对象会按照通知配置先执行切面逻辑再委托给真正的 UserService 方法体。所以当你从 Spring 容器里取出 UserService 时那个对象往往不再是原始的 UserService而是一个“带皮肤”的代理对象。理解这一点是后面避开无数坑的前提。3. 代理模式的“功法演进”从静态代理到 JDK 动态代理再到 CGLIBAOP 之所以选择代理模式来实现运行期织入原因很简单在不修改原有代码的前提下给目标对象增加行为。这是代理模式的核心价值也是 AOP 的核心价值。但代理模式本身也分好几个层次我把这条演进路线称为“功法三部曲”。只有把这三部曲看明白了你才能理解为什么 Spring AOP 最终选了 JDK 动态代理和 CGLIB 两种实现。3.1 静态代理一板一眼但毫无扩展性静态代理就是手动写一个代理类和被代理类实现同一个接口然后在代理类里包一个目标对象并在方法调用前后插入逻辑。public interface UserService { User findUser(Long id); } public class UserServiceImpl implements UserService { public User findUser(Long id) { // 真正的业务逻辑 return userDao.findById(id); } } public class UserServiceProxy implements UserService { private final UserServiceImpl target; public UserServiceProxy(UserServiceImpl target) { this.target target; } public User findUser(Long id) { log.info(开始查询用户, id id); User user target.findUser(id); log.info(查询用户成功); return user; } }静态代理确实解决了“不修改原有代码加入日志”的问题但它极其僵硬每个需要代理的类都要手动写一个代理类。即便你的业务逻辑一模一样代理类也得跟着复制一遍项目越大维护成本越高代码量甚至比你直接改业务类还恐怖。所以静态代理在实际工程里很少用它的价值更多是帮助我们理解动态代理究竟“好”在哪。3.2 JDK 动态代理基于接口的运行时代理动态代理的核心变化在于代理类是在运行时动态生成的而不是手写死的一个类。JDK 从 1.3 开始提供了 java.lang.reflect.Proxy配合 InvocationHandler可以在运行时为任意接口创建代理对象。看代码感受会更直接public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { log.info(开始执行方法: method.getName()); Object result method.invoke(target, args); log.info(方法执行结束: method.getName()); return result; } }调用方式UserService userService new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( userService.getClass().getClassLoader(), userService.getClass().getInterfaces(), new LogHandler(userService) ); User user proxy.findUser(1L);看到重点没有我们写一个通用的 LogHandler就可以代理任何接口。无论是 UserService、OrderService 还是 ProductService只要它们有接口都能复用同一套代理逻辑。这就是动态代理相对静态代理的本质区别代理逻辑被抽象成了可复用的 Handler代理类的生成交给运行时反射机制完成。JDK 动态代理的局限也明显要求目标对象必须实现至少一个接口。如果你的类没有接口它就不能被直接代理。这个限制在后面的 CGLIB 那里得到了解决。3.3 CGLIB基于继承的字节码增强CGLIBCode Generation Library的思路完全不同它通过生成目标类的子类来实现代理子类重写父类的方法在重写的逻辑里插入切面代码。因为用的是继承所以不需要接口也能代理。技术层面上CGLIB 使用的是字节码操作技术直接生成新的 Class 文件字节码性能上也不差。从 Spring 4.0 开始CGLIB 被重新打包放进了 spring-core 里你不需要单独引入依赖就能使用。但 CGLIB 有一个必须牢记的限制final 类无法被代理final 方法也无法被重写增强。这一点很关键很多自认为对 AOP 很熟的人遇到“为什么某个方法切面没生效”这种问题最后查出来的原因就是方法不小心加了 final。为了更直观我把两种代理机制的差异整理成一张对比表对比维度JDK 动态代理CGLIB实现方式基于接口的运行时代理基于继承的字节码增强前提条件目标对象必须实现接口目标类不能是 final方法不能是 final生成方式Proxy.newProxyInstanceEnhancer 创建子类版本支持JDK 1.3集成在 Spring 4.0 核心包性能特点创建代理时开销小调用走反射创建代理时开销较大调用可通过 MethodInterceptor 直接调用适用范围有接口的 Service无接口的类、接口实现类均可3.4 Spring 到底如何选择代理方式Spring AOP 在创建代理时有一套自己的决策逻辑核心判断依据就是“目标对象有没有接口”。在 Spring Boot 2.x 之前Spring 的默认行为是如果目标对象实现了接口就优先使用 JDK 动态代理如果没有接口才退而使用 CGLIB。这个策略很合理因为 JDK 动态代理是纯 JDK 内置能力不引入额外字节码操作兼容性最好。但请注意Spring Boot 2.x 之后官方把默认代理方式改成了 CGLIB即 proxyTargetClass 默认为 true。背后的原因主要是越来越多项目里的 Spring 配置类、Bean 对象大量采用了类级别的 AOP而不再强调必须要有接口CGLIB 能够覆盖无接口类行为更统一也减少了很多“为什么这个类没被代理”的困惑。这里有个很实际的建议除非有特殊需求否则不要强行把 Spring Boot 的代理方式改回 JDK 动态代理。我曾经在项目里见过一段配置文件为了兼容某个老 SDK 强制指定 proxyTargetClassfalse结果无接口的 Bean 不能被切面拦截排查耗费了整整半天。保持默认基本不会错。4. Spring AOP 的“织入时刻”Bean 创建流程中的代理插曲很多面试者能把代理模式的两种实现背得滚瓜烂熟但问他“Spring 容器里到底什么时候给你生成代理对象”他就答不上来了。这一节我们就来解开这个谜。4.1 从 EnableAspectJAutoProxy 说起如果你的 Spring Boot 项目里有类似这样的配置代码那 AOP 就已经被开启了Configuration EnableAspectJAutoProxy public class AppConfig { }在 Spring Boot 里如果你引入了 spring-boot-starter-aop 依赖其实这个注解已经由自动配置帮你加好了你都不需要手动写。但它的存在很关键它向容器注册了一个特殊的 Bean 后置处理器叫AnnotationAwareAspectJAutoProxyCreator。这个类可以说是 Spring AOP 的总指挥。它本身实现了 BeanPostProcessor 接口这意味着 Spring 在创建每一个 Bean 时都会经过它的手。4.2 Bean 创建流程中的关键节点postProcessAfterInitializationSpring Bean 的创建过程大致是实例化 - 属性填充 - 初始化方法 - 注册到容器。其中在“属性填充”和“初始化”这两个步骤之间、以及初始化之后Spring 都会调用一群 BeanPostProcessor 的钩子方法。AnnotationAwareAspectJAutoProxyCreator 重写的是postProcessAfterInitialization翻译过来就是“Bean 初始化完成之后”。在这个时机它会做三件事检查当前这个 Bean 是否已经是一个代理对象如果是就跳过避免重复代理。拿当前 Bean 的类信息去和所有切面的 Pointcut 匹配看是否命中切入点。如果命中就调用 ProxyFactory 创建代理对象把切面里的通知逻辑封装成 Advisor织入代理链。整个过程用一句话总结切面先注册Bean 再创建Bean 初始化完毕后被总指挥判定是否“切一刀”命中了就把代理对象返回给容器。所以外部调用方从容器里拿到的 Bean可能是原始对象也可能是一个代理对象。容器本身不关心你是否是代理它只关心“Bean 的创建流程走完了没有”。4.3 代理对象的调用链通知到底怎么被执行的假设你调用了代理对象的 findUser 方法内部执行顺序是什么样的Spring 会把匹配到当前方法的所有通知Advice组装成一个拦截器链Interceptor Chain。这些拦截器排好顺序依次执行。以 Around 通知为例它处于拦截器链的最外层调用 moment 进入代理方法后先执行切面前置逻辑然后调用 joinPoint.proceed() 去触发下一个拦截器直到最后才真正调用目标对象的原始方法。原始方法返回后通知再执行后置逻辑。这种设计的好处是多个切面可以叠加且顺序可控。比如一个方法既有日志切面又有事务切面还有权限切面它们会按照 Order 注解指定的优先级依次执行互不干扰。我在第一次理解这个拦截器链时总觉得它很像洋葱每层切面就是一层洋葱皮你从外往内逐层调用穿过所有皮才触达核心业务出来时又一层一层地穿回去。这个比喻虽然简单但对理解 Around 的 proceed 机制非常管用。5. 手写一个最小 AOP 框架彻底摆脱对框架的“黑盒恐惧”有些读者看到这里会觉得概念都懂了但真要让我解释动态代理怎么和 AOP 结合我好像还是说不清楚。没关系这一节我们动手写一个迷你版 AOP把思路彻底跑通。5.1 目标与设计我们要实现的能力标记了某个注解的方法在执行前自动打印“开始执行”执行后打印“执行结束”。不需要引入 Spring只靠 JDK 动态代理完成。先定义一个注解import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface LogAnno { }再定义一个业务接口和实现类public interface Calculator { int add(int a, int b); } public class CalculatorImpl implements Calculator { Override LogAnno public int add(int a, int b) { return a b; } }最后写核心的代理处理器import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class AopProxy { public static T T createProxy(T target) { Class? clazz target.getClass(); return (T) Proxy.newProxyInstance( clazz.getClassLoader(), clazz.getInterfaces(), new InvocationHandler() { Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 判断当前方法是否带 LogAnno 注解 Method targetMethod clazz.getMethod(method.getName(), method.getParameterTypes()); if (targetMethod.isAnnotationPresent(LogAnno.class)) { System.out.println(开始执行 method.getName()); Object result method.invoke(target, args); System.out.println(执行结束 method.getName()); return result; } return method.invoke(target, args); } } ); } }测试类public class Main { public static void main(String[] args) { Calculator calculator new CalculatorImpl(); Calculator proxy AopProxy.createProxy(calculator); System.out.println(计算结果 proxy.add(3, 5)); } }运行输出开始执行add 执行结束add 计算结果8这个迷你框架只有二十多行代码但它完整展现了一个 AOP 实现的核心要素根据方法上的元信息注解判断是否需要拦截拦截时通过 InvocationHandler 在目标方法调用前后插入额外逻辑调用方拿到的是代理对象而不是原始对象。5.2 从手写框架回看 Spring AOP别看这个框架简陋它背后的套路和 Spring AOP 惊人地一致。Spring 里的 Pointcut 表达式本质上就是决定“哪些方法要拦截”。我们的手写框架里用 LogAnno 注解来标记可拦截方法Spring 里也可以用 annotation(com.example.LogAnno) 这样的 expression 实现同样的效果。Spring 里的 Before、After、Around 通知本质上就是 InvocationHandler 里在不同位置插入逻辑。Spring 把这一层抽象得更优雅提供了五种注解来声明执行时机我们则必须在 invoke 方法里手动控制位置。Spring 里的 Advisor、Advice、Pointcut 组装本质上也和我们的判断逻辑一样先做方法匹配再执行增强逻辑。所以当你以后再看 Spring AOP 源码不要被一堆类名吓到。你只需要默念这句心法它不过是一个升级版的 InvocationHandler加上一个更强大的方法匹配器再嵌套了一层层拦截器形成调用链。底层原理和这段二十行代码说的是同一件事。6. 实战中的“心魔”为什么你的切面不生效原理都懂但写出来的切面偶尔就是不生效这是每个经历过 Spring AOP 实战的人都绕不开的坎。这里我把自己踩过的高频坑整理出来每一个都是真实项目里出现过的。6.1 同一个类中的方法调用切面失效这是最经典的问题。看这段代码Service public class UserService { LogAnno public void outerMethod() { // 调用同类内部方法 innerMethod(); } LogAnno public void innerMethod() { // 业务逻辑 } }你调用 outerMethod 的时候innerMethod 上的 LogAnno 不会生效。原因很简单Spring 的切面是通过代理对象生效的。当你从容器里拿 UserService 时它可能是代理对象。你在 outerMethod 内部调用 this.innerMethod()这里的 this 指向的是目标对象本身不是代理对象所以内部方法直接执行了原始逻辑切面根本没机会介入。解决方式有三种注入自身代理把 UserService 的代理对象注入到 UserService 里Spring 支持循环依赖时可通过 ObjectProvider 或 Lazy 处理。使用 AopContext.currentProxy() 获取当前代理对象前提是开启 exposeProxytrue。把内部方法抽到另一个 Bean 里通过 Bean 之间的调用来触发代理。这是第一批 AOP 新手遇到的终极问题也是面试官最爱问的“为什么同类内调方法切面不生效”的标准答案。理解它你就真正理解代理对象的边界在哪。6.2 final 方法或 final 类导致代理失败前面提过CGLIB 基于继承实现所以 final 类不能被代理final 方法不能被重写增强。这在老项目里特别容易中招某个 Service 类历史悠久里面某个方法被你加了 final 修饰结果切面一直不生效查半天才发现是这个原因。我的建议是凡是你希望被 AOP 切面管理的类不要加 final方法也尽量不要用 final。虽然设计模式里常有人建议用 final 来防止继承破坏但和切面增强一起用时你就得先权衡。6.3 切面依赖的 Bean 创建顺序问题有一种诡异的现象切面本身已经写了但切面类里用 Autowired 注入的 Bean 是 null或者切面根本没被加载。这多半是配置类组织方式的问题常见于切面类没有被 Spring 扫描到。排查思路很简单确认切面类在 ComponentScan 的扫描路径内或在 Configuration 类里通过 Bean 方式定义切面时方法要正确返回切面实例。另外如果同时存在多个切面并且有顺序要求记得用 Order 注解指定优先级数字越小优先级越高。6.4 如何判断容器里的对象到底是不是代理遇到问题时先用最笨也最有效的方式验证打印对象的 class 名称。UserService userService applicationContext.getBean(UserService.class); System.out.println(userService.getClass().getName());如果输出里包含 EnhancerBySpringCGLIB 或 jdk.proxy 或 $Proxy说明代理已生效切面配置没问题。如果没有说明代理没生成再从切入点和配置上排查。7. 面试官的“考问”高频 Spring AOP 面试题实战拆解作为榜单热搜词里的常客“ioc 和 aop 的原理面试”“spring 高级面试题”“spring 底层 api 源码解析”被反复搜到。下面我从面试视角把这些高频问题拆开揉碎给你一套可复述的答案框架。7.1 问Spring AOP 的实现原理是什么框架答案很简单Spring AOP 基于动态代理实现。有接口的类使用 JDK 动态代理生成实现同样接口的代理对象无接口的类使用 CGLIB生成目标类的子类。通过在代理对象的方法调用链上织入切面逻辑在不修改业务代码的前提下完成增强。如果想让面试官眼前一亮建议把回答补充到这个深度Spring AOP 的切面解析、匹配、织入全部发生在 Bean 创建流程中核心类是 AnnotationAwareAspectJAutoProxyCreator。它作为 BeanPostProcessor在 Bean 初始化完成后检查是否命中切点命中则创建代理对象并返回给容器。调用代理对象方法时通过拦截器链依次执行通知最终调用目标方法。7.2 问JDK 动态代理和 CGLIB 有什么区别按我在第 3 节的表格思路答但记得补上两个关键点一是 JDK 动态代理要求目标对象有接口CGLIB 无此限制但要求类和方法不能是 final二是 Spring Boot 2.x 后默认代理方式切换成 CGLIB主要是为了统一无接口类的代理行为。7.3 问Spring 三级缓存和 AOP 有什么关系这个问题更刁钻也是最近几年热搜里的“spring 三级缓存原理”被频繁关联到的原因。先简单背景一下Spring 为了解决循环依赖引入了三级缓存一级缓存存成品 Bean二级缓存存早期暴露的半成品 Bean三级缓存存 ObjectFactory用于生成早期代理对象。三级缓存和 AOP 的关联在于当 Bean 发生循环依赖时Bean 可能在“属性填充”阶段就要被注入到另一个 Bean 中但此时它的初始化还没完成AOP 代理也还没有机会创建。于是 Spring 利用三级缓存的 ObjectFactory提前获取一个“半成品”的代理对象引用注入给依赖方。如果后续真的需要代理这个早期引用的 Bean 也会被替换为代理对象。这个机制保证了“提前暴露的引用”最终也能和正常创建的 Bean 一样带切面能力。没有三级缓存里的 ObjectFactory循环依赖和 AOP 组合的场景就会出问题。回答这个问题的诀窍是先答缓存分三级的作用再说 AOP 在循环依赖场景下的处理顺序。7.4 问AOP 的使用场景有哪些这是送分题但答案不要只说“日志、事务、权限”。我建议分成两层答第一层是 Spring 内置场景事务管理Transactional、异步注解Async、缓存Cacheable、定时任务。这些都是 Spring 通过 AOP 实现的。第二层是自定义场景方法耗时的性能监控、审计日志、接口幂等校验、分布式锁、接口防重提交。我做过一个基于 Around 方法实现的分布式锁锁的获取和释放逻辑放在切面里业务代码完全无感效果非常好。只要两层各举两个例子面试官基本能看出你不只是背了概念而是有实战经验。写在最后的个人体会写这篇文章时我脑子里一直在回放自己刚接触 Spring AOP 那个阶段的困惑概念太多源码太重注释太少。直到某天自己动手写了一个二十行的迷你代理框架那种“哇原来是这么回事”的感觉才真正出现。所以这篇文章里我特意保留了大量代码和类比就是希望帮你跳过当年那段时间的迷茫。这一篇作为“上篇”重点是把“为什么 AOP 能解决代码冗余”和“底层代理机制到底怎么运转”这两件事讲透。下一篇我会重点展开 Aspect 切面的完整写法、五种通知的实战用法、以及切入点表达式的高级匹配技巧——也就是直接可以抄进项目的具体配置。到时候你会发现原理通了那些注解配置根本不需要死记硬背。