
Spring AOP 和 AspectJ 的关系是我见过的模板化回答最多但失真率也最高的技术点之一。网上很多结论都会提到“Spring AOP 是 AspectJ 的子集”可这句话本身没有错问题在于听完之后不少同学以为只要在项目里加上spring-boot-starter-aop就相当于把 AspectJ 引了进来甚至觉得 AspectJ 的 load-time weaving 在 Spring Boot 里也能随手开启。这种误解在排查“切面怎么不生效”的时候尤其致命因为你看到的往往是方法耗时没记录、异常没捕获、事务没回滚这样的静默失败日志里什么都没报根本不会把你引向真正的原因——你正在用的是运行时动态代理而 AspectJ 做的是字节码织入。这里说的 Spring AOP严格意义上指的是 Spring 容器内的基于代理的切面框架AspectJ 则是一个独立的、更古老的 AOP 语言和字节码织入工具链。Spring Framework 在设计上大量参考甚至复用了 AspectJ 的注解与切点表达式但从运行时机制看两者走的是完全不同的路线。这篇文章我想结合自己做方法级监控、字段脱敏、事务增强等实际场景时的排障经历把它们的边界、取舍和混合作法讲清楚。无论你是刚接触 AOP 的新手还是已经写过不少自定义注解的老手读完应该都能对“这两个东西到底谁是谁的谁”有一个清晰的判断。1. 先对齐 AOP 的基本概念这两个框架到底在实现什么1.1 连接点、切点、通知、切面四个词的准确含义想弄懂 Spring AOP 和 AspectJ 的差异第一步不是看代码而是先把 AOP 里那套名词真正建立起来。很多争议其实是对名词的理解不一致造成的。连接点Join Point程序执行过程中的一个具体位置。方法调用、方法执行、构造器调用、字段访问、异常抛出都可以是连接点。切点Pointcut从很多连接点里做筛选的条件。它负责回答“哪些位置需要被增强”。通知Advice真正要执行的增强逻辑。比如前置通知在方法调用前做事后置通知在方法结束后做事环绕通知把整个方法包在中间。切面Aspect把“切点通知”打包以后形成的模块。切面不只包含逻辑还包含这段逻辑该挂在哪些位置。织入Weave把切面逻辑连接到目标对象、形成增强后代码的过程。这是理解两个框架所有差别的核心关键词。你可能会觉得这些词太抽象我用一个商场场景来类比。连接点可以理解成商场里每一个出入口切点就是“哪些出入口需要安放安检设备”的规则通知是安检设备真正执行的动作切面则是把安检规则和执行动作整体打包成的一套方案。那“织入”是什么呢织入决定了这套安检设备究竟是怎么装到商场里的——是在商场建设阶段就把安检门焊在建筑结构里还是在商场营业之后在门口临时架设一台即插即用的闸机。这两种方式就是 AspectJ 和 Spring AOP 最根本的抽象区别。1.2 织入不是简单的代码替换它关乎运行时可见性很多人提到织入下意识会想到“在字节码里插入一段代码”。这个理解没错但不完整。织入的级别有四种源码级织入、编译期织入、加载期织入、运行期代理。源码级织入直接改写 Java 源文件再编译现在基本没人用了。编译期织入AspectJ 编译器在把.java编译成.class时直接把切面代码写进目标字节码。产物本身就带着增强逻辑。加载期织入类文件已经生成但在 JVM 加载字节码的瞬间由 agent 在运行时修改字节码。运行期代理目标对象照常编译、加载但 Spring 会在容器启动时创建代理对象通过代理去包装目标对象的方法调用。Spring AOP 属于最后一种AspectJ 支持中间两种。别看这只是一句简单的分类它直接决定了后面所有行为差异编译期做好的手术对目标类来说是不可感知的因为在类被 JVM 加载之前增强就已经存在而运行期代理则无论怎么包装都必须通过一个代理对象去“截获”调用。一旦有人绕过了代理对象直接使用原始对象增强就会失效。这也是后面要讲的“自调用”问题的根源。2. 织入时机Spring AOP 与 AspectJ 最本质的分界线2.1 Spring AOP 的代理方案是运行时“套壳”Spring AOP 的实现基础是 IoC 容器和代理模式。Spring 启动时扫描到某个 Bean 符合切点规则会创建一个代理对象把这个代理对象放进容器。外部依赖注入拿到的以及调用方通过容器取到的都是代理对象。当代理对象的方法被调用代理会走一遍通知链再调用真正的目标方法。这个“套壳”过程有两个硬性约束。第一Spring AOP 只能作用于 Spring 管理的 Bean。如果你用new直接创建了一个对象它不会经过容器自然就没有代理这回事。第二代理能拦截的连接点类型非常有限通常只有公开方法级别的调用。字段访问、构造器调用、私有方法调用Spring AOP 都无法拦截。为什么因为它的代理机制本质是在方法入口处做转发而字段读写和构造器执行根本不会经过这个“门卫”。你可能会有疑问既然只能拦截方法为什么事务、日志、耗时统计这些场景用 Spring AOP 已经非常顺手因为它们正好都发生在“方法执行”这个阶段。业务系统中需要增强的核心位置大部分落在 Service 层方法、Controller 层方法、注解标记的方法上这些都是 Spring AOP 能覆盖的边界。所以 Spring AOP 虽然范围窄但它覆盖了业务开发里最频繁的需求场景。2.2 AspectJ 的字节码织入是编译期或加载期“做手术”AspectJ 的运行方式完全不同。它不会去创建代理对象而是在编译期间或者类加载期间直接修改字节码把切面逻辑写进目标类的方法体内。这个操作的目标非常宽方法执行、方法调用、字段读写、构造器调用、异常处理器、循环执行点等都在它的可触及范围里。最常见的使用方式是 AspectJ 编译器ajc。把切面代码和业务代码一起交给 ajc 编译业务代码编译出的 Class 文件里就已经包含增强逻辑。类加载期织入则通过 java agent 在 JVM 加载类的时机做增强不修改磁盘里的 Class 文件而是在内存中完成修改后交给 JVM。由于 AspectJ 直接操作字节码它没有“必须经过容器”的限制。普通 Java 对象、第三方依赖 JAR 里的类、字段访问、构造器执行全部都能被增强。这个能力边界一旦扩开能做的东西就远超出 Spring AOP比如实现一个“任何对象字段被修改时自动审计”的切面甚至在框架源码里嵌入监控。这种场景如果硬用 Spring AOP只能间接地在 setter 方法上做环绕增强然后再期待调用方都会走该 setter完全不是同一种匹配粒度。2.3 两张机制的路由差异对照维度Spring AOPAspectJ织入时机运行时容器启动阶段编译期或加载期代理类型JDK 动态代理 / CGLIB字节码修改目标对象要求必须是 Spring Bean任何普通 Java 类都可以可拦截的连接点方法执行方法、字段、构造器、异常等是否依赖 Spring强依赖 Spring IoC 容器与 Spring 完全解耦性能特征每次调用走反射和代理链转发静态增强无代理链路开销启动期行为启动时就完成代理创建编译期生效或者类加载时临时增强这张表看起来简单但深入想一层你会理解为什么很多人用 Spring AOP 会出现“切面偶尔生效、偶尔不生效”的情况。所有不生效的案例几乎都能还原成两类一类是目标对象没有经过 Spring 容器另一类是方法调用发生在目标类内部的非代理路径上。这两个坑让 Spring AOP 在团队协作时特别依赖纪律定了用代理就要保证调用链始终经过代理对象。3. 为什么 Spring AOP 要“借用” AspectJ 的注解和切点语法3.1 Aspect 是 AspectJ 项目里的注解很多初学者的第一个困惑是我写 Spring AOP 的时候明明用的是Aspect、Before、Around这些 API 一看就是 AspectJ 的。为什么实际运行时又说是 Spring 代理这其实是因为 Spring 在做 AOP 设计时没有选择自行创造一套全新的注解和切点表达式而是直接复用了 AspectJ 的现有成果。Spring 文档里有一个很关键的表述Spring AOP 的切入点解释器复用了 AspectJ 的 Pointcut 解析器这也是为了抹平和 AspectJ 之间的语法成本。也就是说你写的execution(public * com.example.service..*.*(..))这类切点表达式从语法层面看确实来自 AspectJ。但把它解析出来并决定“哪些 Bean 应该被创建代理”的仍然是 Spring 自己的代理机制。这解释了一个常见工程现象同一个Aspect注解的切面类放在没有引入 AspectJ weaver 的 Spring Boot 项目里也能用因为 Spring Boot 默认引入的是aspectjweaver的可选依赖但实际处理切面的是spring-aop中的代理基础设施。如果你只依赖spring-context然后再引入aspectjweaver也无法用 Spring AOP 的EnableAspectJAutoProxy去驱动 AspectJ 的编译期织入因为EnableAspectJAutoProxy激活的仍然是代理机制。3.2 切点表达式的种类与 Spring 可用范围差异AspectJ 切点表达式有非常丰富的指示符以 execution、within、target、this、args、annotation、within、target、args 为最常用。Spring AOP 支持其中一部分但执行语义还会缩水。以call()连接点为例。在 AspectJ 里call(void OrderService.pay(Order))可以匹配“任何地方调用该方法的位置”它强调调用方的上下文。而 Spring AOP 只支持execution()匹配的是“该方法被执行的位置”。这两者听起来像文字游戏但实际语义差别巨大如果你的切面只想在“某个特定调用方调用该方法”时触发AspectJ 的call()能做到Spring 的execution()做不到因为代理只能在目标方法执行入口做拦截无法感知调用者是谁。再比如cflow()它表示“在某个控制流内部发生的执行点”。这种能力常被用在复杂框架里做递归控制比如避免通知链在自身执行时被重复触发。Spring AOP 对这类指示符的支持非常有限即便表达式能解析底层代理也无法完成该语义。所以你在项目里看到别人写Around(annotation(log))能跑但写Pointcut(cflow(execution(* com.example..*(..))))就要慎重。3.3 能力边界不是越小越好而是够用就好Spring 之所以做这种“半支持”核心原因是它不想承担 AspectJ 编译器带来的工程复杂度。代理模式最大的工程优势是纯 Java 实现能无缝嵌进 IoC 容器不需要额外编译插件不需要 Java agent部署时不用改启动参数。对绝大多数业务团队来说这种低运维成本比极致的切点能力更重要。要知道 AspectJ 在真实项目里落地并不舒服。编译期织入需要调整构建脚本如果切面依赖 Spring 容器里的其他 Bean还要处理 AspectJ 与 Spring 的上下文交互加载期织入则要引入-javaagent参数在多环境部署时很容易被忽略。Spring 拿走 AspectJ 的注解和表达式保留自己的代理运行时代价本质上是一种实用主义的选择。4. 实操把一个 Spring Boot 项目的代理切面完整跑起来4.1 最小依赖与配置先展示最常规的 Spring Boot 项目里使用 Spring AOP 的方式。引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency这个 starter 会传递引入spring-aop、aspectjweaver。启动类上不需要主动添加EnableAspectJAutoProxy因为 Spring Boot 的自动配置会开启它。但如果你用的是传统 Spring 项目必须在配置类上加EnableAspectJAutoProxy。定义一个简单的切面Slf4j Aspect Component public class LogAspect { Around(execution(public * com.example.service.*.*(..))) public Object logMethod(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); try { return pjp.proceed(); } finally { log.info(method{}, cost{}ms, pjp.getSignature().getName(), System.currentTimeMillis() - start); } } }这段代码在运行时的效果是Spring 容器扫描com.example.service包下的接口实现类如果目标类实现了接口且 CGLIB 未被强制开启早期版本会走 JDK 动态代理Spring Boot 2.x 之后即使目标类有接口默认也走 CGLIB。这个默认行为的变更恰恰是很多人从旧项目升级 Spring Boot 后突然发现切面生效位置变了不少的原因。4.2 JDK 动态代理与 CGLIB 的内部机制JDK 动态代理是基于接口的。它要求目标类必须实现接口然后在运行时创建一个实现同样接口的新类所有方法调用都会转发到 InvocationHandler 的invoke方法。你可以把它理解成官方生成的“影子实现类”但这个类内部没有真正的业务逻辑只是转发给目标对象。CGLIB 则是子类化代理。它直接继承目标类在子类中重写非 final 的公开方法。当调用子类方法时方法体内会先走法 MethodInterceptor再调用 super 方法。这个方式的优势是不要求目标类实现接口语法限制更少但缺点也明显目标类不能被 final 修饰目标方法不能被 final 修饰。Spring Boot 2.x 默认走 CGLIB对很多开发者的直觉是一个纠正以前大家默认 JDK 动态代理更“正规”但 CGLIB 在多数业务场景里明显更省心日常写 Service 类时很少有人会刻意做接口抽象。如果你确实有特殊需求想强制 JDK 动态代理可以通过配置属性调整但这通常没有必要除非你依赖java.lang.reflect.Proxy的某些特性。4.3 用日志观察代理对象与原始对象的差异在真实项目里判断当前 Bean 是不是代理最直接的方法是打印类名和父类信息Component RequiredArgsConstructor public class DemoRunner implements ApplicationRunner { private final OrderService orderService; Override public void run(String... args) { System.out.println(orderService.getClass().getName()); System.out.println(orderService.getClass().getSuperclass()); } }如果 Spring AOP 生效你会看到类名里出现$$EnhancerBySpringCGLIB$$父类正是自己的实现类。这个细节可以帮助你快速确认代理有没有创建成功。但请注意打印出代理对象不代表所有调用都生效后面马上说为什么。5. 最容易翻车的那些位置来自真实项目的长时间排查5.1 自调用代理失效的最大元凶这是我见过最普遍的翻车现场也非常适合用来展示 Spring AOP 和 AspectJ 的差异。假设你有一个 OrderService它包含两个方法Service public class OrderService { public void createOrder() { this.updateStock(); } Loggable public void updateStock() { // ... } }当一个切面对updateStock做了增强外部调用方通过容器拿到的是 OrderService 的代理对象。这个代理对象的createOrder方法执行时内部通过this.updateStock()调用目标对象的方法而不是代理对象。updateStock上即使标了Loggable切面也不会触发。这是因为this指向的始终是目标对象本身而不是代理。Java 编译器把 this 指向的引用硬编码为当前实例代理机制无法在这个路径上做手脚。这就是为什么网上经常有人建议“在类内自己注入自己”或者“从AopContext.currentProxy()获取代理”。但这些方案都有额外成本且会让代码可读性变差。如果你把这个场景换成 AspectJ问题就不存在了。因为 AspectJ 是字节码级增强增强逻辑已经被织入到updateStock方法内部即使createOrder直接调用updateStock字节码里的增强逻辑照样会执行。所以当你遇到“代理切面偶尔生效”的问题时先不要急着怀疑配置先检查有没有绕过代理的调用链路。5.2 自调用问题的正确解法在处理自调用时比较干净的处理方式往往是“把切面逻辑移到更外层的方法上”或者“在方法内部把调用改为通过 Spring 上下文中获取代理”。前者从代码结构上规避问题后者在代码里引入对代理的显式依赖属于成本稍高的方案。还有一种是使用ExposeInvocationInterceptor通过AopContext.currentProxy()获取当前代理对象((OrderService) AopContext.currentProxy()).updateStock();但这要求配置中添加EnableAspectJAutoProxy(exposeProxy true)。启动时开启该参数后代理对象会放到 ThreadLocal 中任何位置都能拿到。这个方案确实有效但从架构角度看它让业务代码开始依赖 AOP 细节不是特别优雅。如果你的团队代码结构能保证外部调用始终经过 Service 入口我更推荐直接调整调用结构而不是到处去捞代理。5.3 final、私有方法、静态方法Spring AOP 只能望洋兴叹CGLIB 无法继承 final 类无法重写 private 方法。所以当你对这类方法定义切点时Spring AOP 通常不会报错但运行后就是没有任何效果。更隐蔽的是Spring AOP 会对方法可见性做筛选private 方法、protected 方法伴随的代理行为都不稳定。如果你在 IDE 里看到切点表达式匹配了某个 private 方法先放弃用 Spring AOP 处理它的想法。而 AspectJ 几乎没有这种限制。它直接改字节码对 final 类、private 方法、构造器、静态初始化块都可以织入。这也是为什么像 Spring 之外的开源基础设施项目如果要实现对类的“无孔不入”的监控往往会选择使用字节码技术或 AspectJ而不是 Spring AOP。6. 不同项目里的选型方向与可行混搭方案6.1 什么场景坚持用 Spring AOP 就够了结合我维护过的项目以下场景不需要升级到 AspectJ方法级日志、耗时监控、参数打印、返回值包装。基于自定义注解做权限校验和操作审计。Service / Controller 层事务边界控制。缓存切面、重试切面、幂等切面、分布式锁切面。对 Spring MVC 控制器做统一的异常拦截和包装。这些需求全部落在方法执行的边界上Spring AOP 的代理机制完全覆盖。且 Spring AOP 的开发成本最低团队里 Java 工程师不需要额外学习 AspectJ 的编译期流程。只要维护好“调用必须经过容器”的契约就足以支撑一个中大型业务系统的横切需求。6.2 什么场景必须考虑 AspectJ如果你遇到下面任何一个需求建议直接考虑 AspectJ或者至少考虑字节码方案需要对某个第三方 JAR 内部的类做增强而该公司没有把它注册成 Spring Bean 的入口。需要拦截字段读写例如对象敏感字段被任何地方访问时都必须触发审计。需要拦截构造器的执行过程。需要控制流级别的切点匹配比如排除某个框架内部递归调用带来的干扰。需要对一个没有实现任何接口、被定义为 final 的类做方法增强。这类需求已经超出 Spring AOP 的能力边界继续死磕代理配置只会浪费时间。比如我遇到过的一个订单脱敏项目需求是“所有包含敏感字段的实体在被渲染输出之前自动脱敏”。如果用 Spring AOP你只能保证通过某个统一方法去访问时需要触发却没法拦截对象被直接输出到日志、被直接放进响应体的场景。用 AspectJ 的字段 get 切点才能真正做到在 JVM 内部层面拦截。6.3 Spring 与 AspectJ 的混搭路线在 Spring 项目里完整使用 AspectJ大致有三种路线。第一种是编译期织入。引入 AspectJ Maven 插件plugin groupIdorg.codehaus.mojo/groupId artifactIdaspectj-maven-plugin/artifactId version1.14.0/version configuration complianceLevel17/complianceLevel source17/source target17/target showWeaveInfotrue/showWeaveInfo /configuration executions execution goals goalcompile/goal /goals /execution /executions /plugin这种方案集成最彻底但要注意把 Spring 的AspectJProxyFactory和真正的 ajc 区分开不要混用。编译期织入会直接修改你业务代码编译后的字节码对 IDE 调试会有影响因为某些本地变量在 IDE 里可能显示不全。第二种是加载期织入。通过 JVM 参数-javaagent:/path/to/aspectjweaver.jar启用。这种方式不需要改编译流程但需要处理 agent 和 Spring 容器初始化的顺序问题。一些 AOP 增强逻辑如果严重依赖 Spring Bean必须在 ApplicationContext 刷新完成后再执行增强否则拿不到依赖。实际项目里用起来比想象中繁琐。第三种是把 AspectJ 只用在非 Spring 领域。例如在一个纯普通 Java 工具库或者独立的任务调度程序里使用 AspectJ。这种情况下不涉及 Spring 容器直接把 AspectJ 的能力边界发挥到最大既绕开了 Spring AOP 的约束也不用操心代理失效的问题。6.4 最后分享一点个人体会我在实际项目里最常见的状态是Spring AOP 解决掉 95% 的横切需求AspectJ 只在遇到真正需要“字段级织入”“构造器织入”时作为最后手段用一下。很多团队并不是真的需要 AspectJ而是被网上那些“全能 AOP 框架”的标签吸引。其实工具没有高下之分只有匹配不匹配。在一个标准 Spring Boot 应用里方法级代理已经覆盖了事务、权限、日志、缓存、幂等这些高频需求一旦某天你碰到第三方类不能被代理、final 类无法增强这种硬需求再开始研究 AspectJ 仍然来得及。另外无论你选择哪条路线我都建议先在测试环境里打印织入结果或代理信息并写一个最简单的场景验证切面到底走没走通。这种“实证习惯”能帮你省下大量排查配置的时间。AOP 机制本身就是隐式的它不像业务方法调用链那样直观越隐式的东西越需要测试去兜底。