ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AOP与AspectJ深度对比:动态代理与字节码织入原理及选型指南

Spring AOP与AspectJ深度对比:动态代理与字节码织入原理及选型指南

做后端开发的人,肯定都遇到过这种场景:同一个日志逻辑要重复写几十遍,事务管理横穿所有业务方法,或者想在某个接口上加个性能统计却不得不改一堆老代码。我第一次接触AOP的时候就觉得这玩意儿简直是救星,但紧接着就遇到一个绕不开的问题——Spring AOP和AspectJ,到底选哪个?

说实话,这两个名字经常被摆在一起,很多人默认它们功能相同、可以随意切换。但我在实际项目中踩过几次坑之后才明白,这俩虽然都叫AOP,底层路线却完全不同,适用场景也差异很大。这篇文章就把两个方案的实现机制、功能边界、性能表现和选型依据逐层拆开讲清楚,顺带把我排查过的一些实际问题一并整理出来,希望能帮刚接触AOP的同学少走弯路。

1. AOP要解决的问题,以及两条完全不同的实现路线

1.1 横切关注点:为什么必须把逻辑“切”出来

先回到最初的问题。一个典型业务系统里,日志记录、事务控制、权限校验、异常处理这些逻辑,跟“核心业务”没有直接关系,但几乎每个业务方法都得带上它们。最土的办法是手动在每个方法里重复写一遍,代码量翻倍,改一处要动十几个地方,维护成本高得离谱。这就是所谓的横切关注点——它们横穿在所有业务模块中,跟业务本身是正交的。

AOP(面向切面编程)的核心思路就是把这些横切逻辑抽离成独立的“切面”,再通过某种机制在合适的时机“织入”到业务代码里。这样做的好处很直接:业务代码保持纯净,横切逻辑统一维护。实际工程里最常见的例子就是事务注解@Transactional,你只在方法上标了个注解,增删改查就被事务包住了,背后全是AOP在发力。

但“织入”这个动作,在实现层面可以走完全不同的技术路线。一种是在运行时动态生成一个“代理对象”,把切面逻辑挂到代理上;另一种是在编译阶段直接修改字节码,把切面代码“焊”进原本的类里。前者是Spring AOP的路子,后者是AspectJ的路子。

1.2 同一个目标,两种哲学:Spring AOP和AspectJ的关系

先澄清一个常见误解:Spring AOP和AspectJ并不是互为替代品的关系。Spring AOP是Spring框架内建的面向切面方案,它完全依托于Spring的IoC容器,也就是只有被Spring管理的Bean,才能被Spring AOP代理。而AspectJ是一套独立的、完整的AOP框架,它不依赖任何容器,它通过自己专有的编译器直接操作Java字节码来实现织入。

我见过不少开发者在Spring项目里尝试只引入AspectJ依赖、却没用AspectJ编译器,然后跑来问为什么切面没生效。这种混淆非常普遍,根源在于Spring框架为了接入方便,采用了AspectJ风格的注解(比如@Aspect、@Pointcut、@Around),让人误以为用的就是AspectJ。实际上Spring只是借用了它的注解语法做“样式统一”,底层织入仍然是运行时代理,而不是AspectJ的编译器织入。

这也引出了两条路线最本质的分水岭:Spring AOP是在运行期开启动态代理,AspectJ是在编译期或加载期修改字节码。这个差异决定了它们支持的功能边界、性能表现和能用的场景范围,下面逐个拆开讲。

2. 原理层面深挖:动态代理与字节码织入的本质差异

2.1 Spring AOP的实现机制:JDK动态代理与CGLIB

Spring AOP运行期代理分成两种实现方式。默认情况下,如果目标Bean实现了接口,Spring会用JDK动态代理,也就是利用java.lang.reflect.Proxy为接口生成一个实现类实例。这个代理类持有InvocationHandler调用处理器,所有接口方法的调用都会被拦截,在处理器里先执行切面逻辑,再通过反射调用真实目标方法。

如果目标Bean没有实现接口,Spring会退而使用CGLIB。CGLIB用字节码生成库为原始类动态创建一个子类,在这个子类里重写父类的方法,把切面逻辑直接写进方法体中。父类方法仍然被保留,代理只是“包在外面”的一层。

给个最简单的心智模型:JDK动态代理要求目标必须有接口,它只能在接口方法转发上做手脚;CGLIB不需要接口,它继承目标类,可以对非final实例方法做重写。还有个经常被忽略的细节是,JDK动态代理只拦截接口里定义的方法,如果目标类自己加了点接口外的方法,那个方法就代理不到。而CGLIB可以代理所有非final、非静态、非private的实例方法。

实际配置里,Spring Boot 2.x以上版本默认把spring.aop.proxy-target-class设为true,也就是优先使用CGLIB。但对技术栈比较传统的项目,还是得注意去确认这个标志位,不然会出现“方法没走代理”这种迷惑现象。

2.2 AspectJ的织入时机:编译期织入与加载期织入

AspectJ完全不用代理。它自带一个编译器,在编译阶段就把切面代码直接织入到目标类字节码里。更准确地说,AspectJ提供了好几种织入时机:源码头织入(直接改源代码)、编译期织入(改.class文件)、二进制织入(改打包好的jar里的类)、加载期织入(类加载到JVM时通过自定义agent动态改写字节码)。

日常使用最多的场景,是通过Maven插件在编译阶段完成织入。切面代码最终会被直接“缝合”进目标类的字节码里,运行期拿到的就是一个“被改造过”的普通Java类,没有代理、没有反射转发的中间层。这就意味着它支持切入的连接点种类远多于Spring AOP:方法调用、方法执行、构造器调用、字段读写、静态初始化、甚至是异常抛出时的处理入口,全都拦得住。

编译期织入最典型的一个特点是,它可以切入到Spring容器之外的对象上。比如一个普通的类,压根不归Spring管,Spring AOP拿它一点办法都没有,但AspectJ在编译器阶段就直接把它改了,完全不需要容器参与。

2.3 为什么Spring AOP会有那么多“代理失效”限制

理解代理原理之后,Spring AOP那些经典的失效场景就全都解释得通了。最常见的两个:自调用失效,以及final方法无法被代理。

先看自调用。我在一个类里写了方法A,A内部调用同类的另一个方法B,B上面标了@Transactional或自定义切点。跑起来发现B上的切面完全没生效。原因在代理模式下,外层调用者拿到的是代理对象,但在方法A内部,this关键字指向的是原始目标对象,而不是代理。所以A调B时是“目标调目标”,中间没有代理拦截,切面自然就没了。

final方法失效更简单,CGLIB的代理方式是生成子类、重写父类方法,final方法不允许重写,所以无法织入。JDK动态代理走接口转发,final方法如果不在接口里,根本不会被拦截。

还有一个隐蔽的坑是切点表达式匹配范围过宽。Spring AOP的切点默认匹配的是Bean方法执行点,如果表达式里写了execution(public * com.example.foo.*.*(..)),但那个类本身没被Spring托管,同样不生效。很多刚上手的人把AspectJ的语法套到Spring AOP上,却忘了“容器托管”这个前置条件,排错排到怀疑人生。

3. 实操对比:同一个统计耗时切面,两种方案各怎么写

3.1 用Spring AOP写一个方法耗时切面

做性能优化时我最常用的一个手法就是打一个全局的耗时切面。在Spring Boot项目里,这个过程非常丝滑。

第一步引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

第二步定义切面类,用@Aspect标注并交给Spring容器管理:

@Aspect @Component public class CostTimeAspect { @Pointcut("execution(* com.example.service.*.*(..))") public void serviceLayer() { } @Around("serviceLayer()") public Object aroundService(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost = System.currentTimeMillis() - start; System.out.println(joinPoint.getSignature().getName() + " cost " + cost + " ms"); } } }

这样就完事了。Spring启动时扫描到这个@AspectBean,会自动给com.example.service包下所有Bean方法套上代理。我是从一开始就坚持用@Around而不是@Before加@After的组合,因为耗时统计必须在try-finally里算出时间,否则方法抛异常时记录就被跳过了。

需要注意,execution表达式在Spring AOP里只能匹配方法执行,写call或者get这类AspectJ专有的切入点指示符,编译期不会报错,但运行期根本不生效。浪费过时间的同学应该都懂。

3.2 用AspectJ编译期织入实现同一个切面

AspectJ的用法完全不同,它需要自己的编译器和插件。项目里要加入AspectJ Maven插件,并配置主编译器为aspectj-maven-plugin。

第一步添加插件配置:

<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>aspectj-maven-plugin</artifactId> <version>1.14.0</version> <configuration> <complianceLevel>17</complianceLevel> <source>17</source> <target>17</target> <showWeaveInfo>true</showWeaveInfo> <aspectLibraries> <aspectLibrary> <artifactId>aspectjrt</artifactId> </aspectLibrary> </aspectLibraries> </configuration> <executions> <execution> <goals> <goal>compile</goal> <goal>test-compile</goal> </goals> </execution> </executions> </plugin>

第二步编写切面,不需要@Component,不需要Spring托管,就是一个普通的Java类加AspectJ的标注:

@Aspect public class CostTimeAspect { @Pointcut("execution(* com.example.service.*.*(..))") public void serviceLayer() { } @Around("serviceLayer()") public Object aroundService(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost = System.currentTimeMillis() - start; System.out.println(joinPoint.getSignature().getName() + " cost " + cost + " ms"); } } }

第三步,编译项目。注意关键点:AspectJ插件会和Maven默认的maven-compiler-plugin冲突,必须在插件顺序上做协调,否则就会出现“代码能编译但切面完全没有织入”的情况。我把aspectj-maven-plugin放在maven-compiler-plugin前面,并删除后者的compile执行绑定,改用AspectJ的插件作为主编译器,实测下来才稳。

编译完成后,可以用javap -c反编译目标class看看,方法体里会直接多出耗时的字节码指令——这就是织入的结果。看到那些多出来的字节码指令时,我第一次真正理解了两者在“编译期改造”和“运行期代理”上的差异。

3.3 两种代码风格的实质差异

从代码层面看,两个方案的切面类表面长得很像,都是一个@Aspect注解、一个@Pointcut、一个@Around。但背后逻辑区别很大。

在Spring AOP下,切面类本身必须是一个Spring Bean,它依赖IoC容器的生命周期管理。AspectJ的切面类则更像一个编译期“模板”,不需要被实例化到任何容器里,编译完就完成了使命(运行期还要一个aspectjrt运行时库做支撑)。

另外一个经常被问到的点:既然Spring代码里能用@Aspect注解,是不是把AspectJ的依赖也带进来了?不尽然。Spring用的是它自己定义的注解模型,只是为了兼容才采用了AspectJ风格的注解名。你可以在一个完全没有AspectJ编译器的Spring项目里用@Aspect,切面一样能工作——走的是代理引擎。但如果你想要AspectJ独有的连接点类型,或者想切到不是Spring管理的类上,那就得老老实实加AspectJ编译器了。

4. 性能、功能边界与最终选型依据

4.1 运行时开销:代理模式无法避免的性能损耗

说到性能,就逃不开一个事实:Spring AOP的代理调用链在每次方法调用时都会有额外开销。代理对象收到方法调用,先进入拦截器调用链,做切面逻辑,再反射调用真实目标方法。这个链路比直接调用原始方法慢,量级通常可以忽略,但在超高频调用场景下就会放大。

我做过一个简单但不严谨的基准测试,在一个循环里调用一个空方法几百万次,Spring AOP代理方式比原始直接调用慢大约一个数量级,而AspectJ编译期织入后的调用耗时几乎和原始调用持平。这背后的逻辑很直观:AspectJ的切面代码已经被“焊”进去了,运行期不会有额外的动态分发机制。

但不是所有项目都需要关心这个量级的差异。常规业务系统里一次方法的耗时大头通常是数据库查询、网络调用,AOP那点额外调度开销占比极小,根本不必为了性能去折腾AspectJ的类加载器或编译器配置。

真正需要警惕的反而是一不小心写出的“多层代理嵌套”。如果一个Bean同时被事务代理、安全代理、日志代理三个切面裹住,Spring会生成代理链,调用时要经过多层拦截转发,这时的性能损耗就不是一个小数目了,而且排查链路非常痛苦。

4.2 支持范围与约束条件对比

从功能范围来看,Spring AOP的短板非常明显。它天生只能针对Spring容器内的Bean方法执行点做切入,不能切构造器、不能切字段赋值、不能切静态初始化,也不能切没有进入容器管理的普通类。AspectJ在这些方面基本是“全能型”的,它连构造器都能拦截,这也是做某些对象生命周期审计时的得力工具。

再从集成成本来看,Spring AOP几乎零成本,在Spring Boot项目里加一个starter就行,也不用改构建配置。AspectJ需要引入独立的编译器插件,还得考虑和构建系统的兼容性,一旦项目里同时存在Lombok等注解处理器,谁先谁后、谁生成谁改写字节码这些坑都会接踵而至。

如果你的项目是一个长年累月的遗留系统,里面既有Spring管理的Bean,又有大量脱离容器管理的工具类,还希望后者也能被切面覆盖到,那就只能选择AspectJ这条路。如果项目里都是清一色的Spring Bean,事务、日志、缓存、限流这些常见横切场景,Spring AOP足以应对。

4.3 一张表看清核心区别

对比维度Spring AOPAspectJ
织入时机运行时动态代理编译期、二进制织入、加载期织入
底层机制JDK动态代理 / CGLIB字节码修改
连接点范围方法执行方法调用、执行、构造器、字段读写、静态初始化、异常入口
有无容器依赖必须由Spring管理目标Bean完全不依赖容器
代理限制final方法、自调用失效无这些限制
性能开销每次调用经过代理链,有一定额外开销编译期织入嵌入目标方法,额外开销极低
集成成本低,加依赖即可高,需配置编译器插件
典型使用场景事务管理、日志、接口耗时统计、权限校验复杂切点、非容器对象的切面、构建期静态织入

5. 实际运用中踩过的坑与排查思路

5.1 Spring AOP自调用失效的完整复盘

有段时间我在做一个内部数据同步模块,给一个方法加了耗时记录切面,测试发现另一个方法调用它时完全没被拦截。我当时第一反应是切点表达式写错了,检查半天也没发现异常。折腾了一会,忽然想起来一个经典场景——在同一个Bean内部,方法A直接调用方法B,而B才是切点。

因为代理模式的外层调用才走拦截器,内部this调用直接绕开了代理。这个问题最普遍的出现位置,就是在事务类里:外部接口方法调用内部私有/公有方法,想靠内部方法上的@Transactional管事务,结果事务完全失效。我后来固定的解决办法有两种,要么把被切的方法放进另一个Bean里,通过注入依赖去调用;要么通过AopContext.currentProxy()拿当前代理对象来转发:

((OrderService) AopContext.currentProxy()).processOrder(orderId);

用AopContext之前,要在配置里把@EnableAspectJAutoProxy(exposeProxy = true)开起来,否则运行时拿不到代理对象。这个开关我之前也忘记过,报了个IllegalStateException后才意识到。

5.2 AspectJ在整合和编译期的坑

用AspectJ时问题更多的在编译环境上。最典型的一次,我在一个项目里同时用了Lombok和AspectJ插件,编译时出现了奇怪的现象——Lombok生成的getter/setter没有被织入切面。查了资料才搞明白,AspectJ插件会接管整个编译过程,如果它没有把Lombok的注解处理器一起拖进编译链路,Lombok生成的代码就压根不存在,切面自然无处挂靠。

解决办法是在AspectJ插件的<configuration>里显式加上Lombok的annotation processor路径,或者干脆用AspectJ的Maven插件同时承担编译责任,并且让它在处理阶段能够正确找到Lombok生成的类文件。这块的配置细节繁琐,如果项目非必须用到AspectJ的专属连接点,大多数情况下我都不建议同时引入两套编译期佚代逻辑来增加复杂度。

还有一个常见问题是版本匹配。aspectjrt运行时库和aspectj-maven-plugin插件版本必须对齐,否则会出现java.lang.NoSuchMethodError这类奇怪异常,排查起来同样费劲。我的习惯是把AspectJ相关的版本统一固定,并检查aspectjrt版本与插件实际使用的版本号一致再打包。

5.3 怎么判断当前生效的是哪种代理,以及排错小技巧

有经验的开发者排查这类问题有一个惯用招数:直接看运行时对象类型。在目标Bean的调用处打一个断点或者打印getClass(),就能看见实际对象的类名。如果是com.sun.proxy.$ProxyN或者xxx.CGLIB$$EnhancerByCGLIB$$,说明Spring AOP代理生效了;如果看到的是原始类名,说明代理压根没产生。这个办法是我用过的最朴素的切面排错手段。

再看配置项:spring.aop.proxy-target-class在默认情况下的值,Spring Boot 2.x及以上是true,所以多数项目里看到的是CGLIB代理。但如果你在一个老版本Spring项目里没配置这个值,Spring默认会“优先接口代理”,一旦目标类实现了接口但接口上没有对应方法,就会出现“方法没走切面但类已经被代理”的中间态,这也是排查方向之一。

AspectJ的话,排错前先把showWeaveInfo打开,编译时它会打印出每一个织入点和织入通知,非常直观。如果输出里没有你要的那个方法,就说明织入没成功,这时候优先检查编译命令是否真的走了AspectJ插件、切点表达式是否精确匹配了类名和方法名。

我倒想把这几年在AOP上的一些体会集中说一句:多数业务系统里Spring AOP完全够用,不要被那些复杂的字节码技巧吸引去盲目上AspectJ;但如果你真的遇到代理模式解释不了的场景,比如要切入构建器、要切入非Spring对象、或者在消耗敏感场景下做高性能切面,AspectJ的编译器织入就是正经方向。踩过坑之后回头看,这两条技术路线没有简单的优劣之分,只有“在正确的问题下选择了正确的工具”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进