Java工程师复健AOP:所有的一切都是为了不做重复的事情

张开发
2026/4/9 10:53:20 15 分钟阅读

分享文章

Java工程师复健AOP:所有的一切都是为了不做重复的事情
在实际的工程开发中只要你接手过稍微复杂一点的系统很快就会遇到另一个极其让人头疼的问题。这个问题恰恰是伴随着我们最熟悉的面向对象编程带来的或者更本质地说是程序天然带来的因为程序的本质就是将代码按预设线性的顺序向下执行。OOP 的核心思想之一是继承和封装。它的复用逻辑是纵向的。比如不管是微信支付类还是支付宝支付类只要它们有相同的核心支付属性我们就可以抽离出一个基础支付父类让子类去继承。这种从上到下的树状结构处理业务逻辑的复用非常完美。但是真实系统里不仅有业务逻辑还有大量属于系统级别的“通用设施”比如数据库事务管理、接口耗时统计、系统日志记录、权限校验或者在现在的 AI 场景里调用大模型前后的 Token 消耗计费。这些通用需求有一个极其烦人的特点它们是横向的。它们无视了 OOP 的垂直继承体系像一张网一样横跨在各个毫不相干的业务模块之上。比如处理订单的OrderService和管理用户的UserService在业务上没有任何交集更不可能有一个共同的父类但它们在向数据库写入数据时都必须开启和提交事务。面对这种横向的需求传统的 OOP 毫无招架之力。一、AOP初识1.1 最常见的痛点——数据库事务为了把这个问题说清楚我们拿后端开发最绕不开的数据库事务来举例。假设你要写一个极其简单的业务逻辑用户下单。核心代码其实只有两步——向订单表插入一条记录然后扣减商品库存。// 核心业务逻辑只有两行 orderDao.insert(order); inventoryDao.decrease(item);但是为了保证数据的一致性这两步操作必须放在同一个数据库事务里。如果你不使用任何框架纯手工用 Java 的 JDBC 来写这段代码最终会变成这样public void createOrder(Order order, Item item) { Connection conn null; try { // 1. 获取连接并开启事务非业务的通用逻辑 conn dataSource.getConnection(); conn.setAutoCommit(false); // 2. 真正的核心业务逻辑(只有这两行) orderDao.insert(order); inventoryDao.decrease(item); // 3. 提交事务非业务的通用逻辑 conn.commit(); } catch (Exception e) { // 4. 出现异常回滚事务非业务的通用逻辑 if (conn ! null) { conn.rollback(); } throw e; } finally { // 5. 释放数据库连接资源非业务的通用逻辑 if (conn ! null) { conn.close(); } } }你看原本只有 2 行纯粹的业务代码硬生生被 15 行的事务控制代码死死包裹住了。如果整个系统里只有这一个地方需要事务那写一遍也就忍了。但现实是一个企业级应用里可能有几百上千个涉及到数据库增删改的方法。如果你按照这种写法不仅意味着你要把这段try-catch-finally的代码复制粘贴几百遍更可怕的是将来如果底层的数据库连接池技术要换你需要打开几百个文件挨个修改。这就是我们要 AOP 出现的核心原因所有的这一切都是为了不做重复的事情。程序员天然反感重复。我们需要一种机制能够把这些“横切”在各个模块里的通用代码比如事务控制、日志打印单独抽离出来写在一个地方然后在系统运行的时候再自动把它们“缝合”回原本的业务方法前后。业务开发人员在写代码时视线里应该只有那两行核心业务逻辑。为了实现这种把代码剥离再缝合的需求软件工程领域提出了一种思想——AOP面向切面编程。这里要明确一点AOP 仅仅是一种架构设计思想并不是 Spring 发明的。只要是想把横向的通用逻辑和核心业务解耦都可以叫 AOP。而 Spring 框架则是非常优雅地将这种思想引入了进来并在底层帮我们实现了这套复杂的代码自动缝合机制。接下来我们来看看 Spring 底层到底是怎么运作的。1.2 AOP 的核心概念其实就是一次“方法拦截”很多 Java 开发者初学 AOP 时都是被它那套极其生涩的学术名词给劝退的。什么连接点、切入点、通知、切面听起来云里雾里。其实只要抛开那些抽象的定义回归到实际的代码层面AOP 的本质非常直白就是找准时机拦截特定的方法然后在它执行前后塞入我们写好的通用代码。我们简单的技术话语把几个术语介绍一下Joinpoint (连接点)所有能够被拦截的方法在一个运行的程序中理论上有很多可以插入代码的节点比如修改某个属性、抛出某个异常。但在 Spring AOP 的具体实现里连接点仅仅指代方法的执行。 你可以简单粗暴地理解为你在 Spring 容器里写的每一个public方法都是一个 Joinpoint。它们是客观存在的、系统里所有具备被拦截潜力的位置。Pointcut (切入点)你想要拦截的筛选规则你的系统里可能有几万个方法Joinpoint但你显然不需要给每一个方法都开启数据库事务。你需要一种筛选条件挑出那些真正需要特殊处理的方法。 Pointcut 就是这个条件。它可以是一段极其精确的表达式比如execution(* com.service.OrderService.*(..))意思是只盯死OrderService里的方法也可以是通过注解来筛选比如只拦截头顶上标了Transactional的方法。Advice (通知/增强)你要添加的具体代码和时机筛选出目标方法后我们到底要干什么Advice 就是你真正要执行的那段通用逻辑比如上面提到的开启事务、记录日志、计算 Token。 不仅如此Advice 还需要规定这段逻辑执行的具体时机Before前置在业务方法执行之前跑比如做权限校验。After/AfterReturning后置在业务方法执行完毕后跑比如释放连接、记录成功日志。Around环绕最核心、最强大的时机。它直接接管了业务方法你可以同时在它执行前后写逻辑甚至可以根据条件直接短路不让原业务方法执行。Aspect (切面)把规则和代码绑在一起的“类”我们有了筛选规则Pointcut也有了具体的动作代码Advice需要有一个载体把它们统筹起来。 Aspect 就是这么一个物理存在的 Java 类。你在类头上打个Aspect注解里面写上你的拦截规则和具体的业务逻辑代码这个类就成为了一个独立的“切面模块”。我们来看一段最常见的拦截代码Aspect // 【这就是 Aspect (切面)】 Component public class TransactionAspect { // 【这就是 Pointcut (切入点)】规定只拦截标了 MyTx 注解的方法 Pointcut(annotation(com.xxx.MyTx)) public void txRule() {} // 【这就是 Advice (通知)】规定了时机Around和具体要执行的代码 Around(txRule()) public Object manageTransaction(ProceedingJoinPoint joinPoint) throws Throwable { System.out.println(1. 开启数据库事务...); try { // 【这里的 proceed() 就是放行去执行真实的业务方法也就是 Joinpoint】 Object result joinPoint.proceed(); System.out.println(2. 提交事务...); return result; } catch (Exception e) { System.out.println(3. 回滚事务...); throw e; } } }这四个词凑在一起就完成了一次完美的代码分离。作为业务开发人员你只需要专心写好纯粹的业务代码而系统在运行的时候自然会根据 Aspect 里的规则把 Advice 里的逻辑“织入”到你需要的方法前后。二、APO的实现在揭开 Spring 到底是怎么把非业务代码放入你的业务方法之前我们先来看一个面试中的常考题目。假设你在OrderService里写了两个方法方法 A 和方法 B。方法 B 涉及核心数据库更新你给它加上了Transactional注解开启了事务。然后你在方法 A 里调用了方法 B。Service public class OrderService { public void methodA() { System.out.println(执行常规校验...); // 同一个类里面方法 A 调用方法 B methodB(); } Transactional public void methodB() { System.out.println(执行核心数据库更新...); // 这里哪怕发生了异常事务也不会回滚 throw new RuntimeException(数据库异常); } }问这个事务会生效吗背过面试题的兄弟们都知道不生效。应为它触发的自己本身的类没有将Transactional的代码注入进去。但仔细思考一下是不是和我们上面的叙述不太一样这实际涉及到AOP的两种实现方式。2.1 AspectJ的织入实际上如果你用的是正统的 AOP 框架比如 Java 生态里最老牌的AspectJ根本就不会存在这个问题。同类调用事务照样完美生效。为什么 AspectJ 能搞定因为它的底层技术是用编译期织入Weaving的方式实现的。AspectJ 提供了一个特殊的编译器。当你写完代码把.java编译成.class文件时这个编译器会直接在物理层面上修改你的字节码。它硬生生地把开启事务、提交事务的代码塞进了methodB的指令里。 也就是说编译完之后你的methodB已经不再是你原来写的那个纯粹的业务方法了。所以不管你是外部调用还是同类内部的this.methodB()调用执行的都是已经被改造过的代码事务自然生效。2.2 Spring的妥协既然 AspectJ 这么完美Spring 为什么不用 因为太重了。要引入特殊的编译器要修改项目的构建流程对业务开发的侵入性太大。为了保持框架的轻量和开箱即用Spring 做了一个妥协放弃修改底层字节码改用运行时的“动态代理”。Spring 只是借用了 AspectJ 的那套注解比如Aspect、Around让我们写代码时觉得方便减少心智负担但它的底层执行引擎完完全全是基于 Java 内存里的动态代理机制。当 Spring IoC 容器在启动时如果它发现OrderService里的方法加了Transactional它不会把原本的那个OrderService原始对象直接放进 IoC 容器的单例池里。 相反Spring 会在内存里偷偷捏造出一个新的对象。这个新对象长得和你的OrderService一模一样它包装了你的原始对象并在内部加入了事务开启和提交的逻辑。这个新生成的对象就叫代理对象Proxy。你在 Controller 里通过Autowired注入进来的根本不是你亲手写的那个类而是这个代理对象。理解了代理对象的存在我们再回过头来看那道问题外部请求打进来调用orderService.methodA()时实际上打中的是代理对象。代理一看methodA头上没有事务注解不需要拦截于是直接把请求丢给了内部包裹着的原始对象。现在代码的执行权来到了原始对象内部。原始对象在methodA里调用了methodB。在 Java 的语法中完整的写法是this.methodB()。这里的this是谁这里的this是原始对象本身它根本不是外面那个带有事务拦截逻辑的代理类这就是原因内部方法调用完全绕过了外层的代理对象直接在原始对象内部执行了。既然没经过代理对象的拦截事务当然不可能生效。所以这就是这些八股产生的原因基本上都是前人在这上面踩了坑才拿来考大家的。本质是希望大家去看一下原理但由于众所周知的原因他演变成了八股丢掉了这些最重要东西。2.3 Spring的实现JDK 动态代理与 CGLIB既然 Spring 决定不在编译期修改源码那就必须在程序运行的时候在内存里创造出一个包含事务代码的替身类。它是怎么做的呢主要是依赖两种技术JDK动态代理和CGLIB。JDK 动态代理基于接口的替身这是 Java 原生自带的技术。它的核心前提是你的目标业务类必须实现了一个接口。假设你的OrderServiceImpl实现了IOrderService接口。Spring 在启动时会利用底层 APIjava.lang.reflect.Proxy向 JVM 申请“请在内存里帮我动态生成一个全新的类这个类也要实现IOrderService接口。”因为大家都实现了同一个接口所以在 Controller 里Autowired IOrderService时注入这个新生成的代理对象完全合法JVM 不会报错。 那这个代理对象内部是怎么工作的呢它里面的所有方法都会统一委托给一个叫InvocationHandler的拦截器。 当你调用代理对象的methodB()时请求先进入InvocationHandler。拦截器一看配置先执行开启事务的代码。接着利用 Java 的反射机制Reflection去真正调用你那个藏在背后的原始对象的methodB()。原始方法执行完后拦截器再执行提交或回滚事务的代码。CGLIB 动态代理基于继承的替身但在真实的业务开发中我们很多时候为了图省事写的 Service 就是一个普普通通的类根本没有去定义任何接口。这时候要求必须有接口的 JDK 动态代理直接就抓瞎了。面对这种情况Spring 使用了第二种方法CGLIB。 CGLIB 的底层利用了极其强悍的字节码操纵技术ASM。既然你没有接口那我就在内存里动态生成一个你的子类你如果在 Debug 时仔细看变量名会发现这个替身的名字通常带着$$EnhancerBySpringCGLIB的后缀。既然是你的子类它理所当然地具备了你的所有方法特征。CGLIB 会在这个动态生成的子类中重写Override你的业务方法。 当你调用这个子类替身时请求会被子类内部的MethodInterceptor拦截。拦截器先执行事务开启代码。接着直接通过super.methodB()调用父类方法的方式去执行你原本的业务逻辑。最后收尾提交事务。这时候再看一个经典的面试题为什么 private/final 会导致事务失效很多人只能死记硬背答案但如果明白了 CGLIB 的实现这就变成了送分题 因为当你没有写接口时Spring 只能用 CGLIB 去生成子类来做替身。而 Java 语法有着要求子类绝对无法重写父类的private或final方法既然子类都没法重写你的方法它当然也就无法在这个方法的外层包上事务拦截逻辑。代理一旦宣告失败事务自然失效。在 Spring AOP 的核心包里有一个专门负责制造替身的工厂类叫DefaultAopProxyFactory。里面有一个极其核心的方法createAopProxy()它就是决定究竟使用哪种方法的代码。这段源码的骨干逻辑精简掉冗余判断后非常清晰public class DefaultAopProxyFactory implements AopProxyFactory { Override public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { // 核心 IF 判断 // 1. isOptimize()是否开启了优化通常指 CGLIB // 2. isProxyTargetClass()开发者是否强制要求代理目标类强制 CGLIB // 3. hasNoUserSuppliedProxyInterfaces()这个类是不是压根就没实现任何业务接口 if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class? targetClass config.getTargetClass(); if (targetClass null) { throw new AopConfigException(TargetSource cannot determine target class...); } // 如果目标本身就是一个接口那还是只能用 JDK 动态代理 if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } // 走到这里说明是普通类直接使用 CGLIB 生成子类替身 return new ObjenesisCglibAopProxy(config); } else { // 类实现了接口且没有强制要求 CGLIB默认使用 JDK 动态代理 return new JdkDynamicAopProxy(config); } } }再补充一点按照 Spring 框架原本的规矩有接口就用 JDK没接口才用 CGLIB。 但是在实际开发中很多程序员经常犯一个低级错误OrderServiceImpl实现了IOrderService接口但在 Controller 里注入的时候脑子一抽直接写成了Autowired OrderServiceImpl orderService; // 错误注入了实现类而不是接口如果按照 JDK 动态代理Spring 给你造的代理是一个实现了IOrderService接口的全新类它和OrderServiceImpl在物理上是平级的兄弟关系根本不是同一个类这时候你强行用OrderServiceImpl去接收JVM 会当场抛出异常ClassCastException类型转换失败。为了彻底消灭这种因为开发者不规范导致的报错Spring Boot 从 2.0 版本开始直接做了一个决定不管你有没有接口默认全部强制使用 CGLIB它在底层自动把spring.aop.proxy-target-class这个配置项设为了true直接命中了上面源码里的isProxyTargetClass()条件。 因为 CGLIB 生成的是子类根据面向对象的“多态”原理子类替身无论赋值给接口还是赋值给父类实现类都不会报类型转换错误。这就是现代工程框架的演进思路宁可稍微牺牲一点点代理的启动性能和内存也要把开发者的心智负担降到最低把极易出错的坑直接在底层填平。三、三级缓存在上一篇里我们梳理了 Spring 是如何解决循环依赖的通过引入二级缓存先把实例化出来、但还没填充属性的“空铁壳子”提前暴露出去。这样当 A 依赖 BB 又依赖 A 时B 就可以先拿到 A 的引用完成装配从而打破死锁。这个逻辑在只有纯粹的 IoC 时是完美的。但是当我们把今天讲的 AOP 机制加入到这条流水线后一个Bug就出现了。3.1 AOP 代理生成的时机我们先复习一下 IoC 流水线的四个核心步骤画图纸BeanDefinition实例化Instantiation造出原始对象的空壳。属性填充PopulateBean把依赖的其他 Bean 塞进去。初始化Initialization执行自定义的初始化方法。在正常的 Spring 生命周期里AOP 生成代理替身的时机被严格放在了最后一步——初始化Initialization之后。 Spring 的设计初衷是只有当一个对象完完整整地被造出来、所有属性都填充完毕后再去给它穿上代理这层外衣这才符合逻辑。假设现在有两个类AgentService和MemoryService它们互相注入了对方循环依赖。同时AgentService的某个方法上加了Transactional意味着它最终必须变成一个代理对象。我们按照只有两级缓存的逻辑走一遍完整的流程容器开始创建AgentService。走到第 2 步实例化造出了AgentService的原始对象空铁壳子。为了防备循环依赖Spring 把这个原始对象的引用扔进了二级缓存。走到第 3 步属性填充发现需要MemoryService于是暂停去创建MemoryService。MemoryService创建完空壳开始属性填充发现需要AgentService。MemoryService去二级缓存里一掏拿到了AgentService提前暴露出来的原始对象塞进自己的对象里。MemoryService创建完成放入单例池。流程回到AgentService的第 3 步它把完整的MemoryService塞进自己对象里。BUG出现AgentService走到第 4 步初始化。因为配置了 AOPSpring 框架在这里强行给AgentService生成了一个全新的代理对象并把这个代理对象放入了最终的一级缓存单例池。发现问题了吗系统最终运行的时候一级缓存里放的是AgentService的代理对象但是MemoryService里装着的却是最初从二级缓存里拿到的那个原始对象数据不一致了。当MemoryService内部调用agentService.xxx()时它直接调用了裸奔的原始对象没有任何事务和拦截逻辑AOP 彻底失效。3.2 三级缓存与“延迟代理工厂”面对这个 Bug最直白的解法是什么 有兄弟说“那我在第 2 步实例化的时候直接就把代理对象造出来暴露出去不就行了吗”不行。如果这样做就彻底破坏了Spring 的架构原则。Spring 坚持认为非必要情况下代理对象必须在最后一步生成。因为此时绝大多数 Bean 是没有循环依赖的为了少部分有循环依赖的 Bean让所有 Bean 都在一开始就提前走完 AOP 流程这种一刀切的设计极其丑陋且容易引发连环 Bug。所以Spring 给出的最终解法是引入三级缓存singletonFactories。 三级缓存里存的既不是原始对象也不是最终的代理对象而是一个ObjectFactory对象工厂。你可以把它理解为一个包含了一段回调逻辑的 Lambda 表达式。// 1. 实例化造出空铁壳子 bean Object bean createBeanInstance(beanName, mbd, args); // 2. 把一个 Lambda 表达式工厂塞进三级缓存 // 注意这里仅仅是存入一段逻辑并没有真正执行 getEarlyBeanReference addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); // 3. 属性填充去解析依赖的其他 Bean populateBean(beanName, mbd, instanceWrapper); // 4. 初始化日常生成 AOP 代理的地方 Object exposedObject initializeBean(beanName, exposedObject, mbd);加上三级缓存后日常注入的真实判断流程是这样的实例化造出AgentService的原始对象。不放二级缓存而是往三级缓存里放一个“工厂”。这个工厂的逻辑是“如果有人现在急着要AgentService我就当场判断它需不需要 AOP。需要的话我立刻造一个代理对象交出去不需要的话我就把原始对象交出去。”属性填充去创建MemoryService。MemoryService需要AgentService。触发提早代理MemoryService依次找一级、二级缓存没找到。来到三级缓存找到了AgentService的工厂。工厂启动工厂执行回调逻辑发现AgentService需要事务于是当场、提前生成了AgentService的代理对象并把这个代理对象返回给MemoryService。缓存升级为了防止后面还有别的 Bean比如LogService也需要AgentService导致工厂被重复调用生成不同的代理对象Spring 会把刚才生成的代理对象放入二级缓存并把三级缓存里的工厂删掉。MemoryService拿到代理对象完成创建。收尾防坑流程回到AgentService的第 4 步初始化。Spring 准备做 AOP 时会先检查一下“二级缓存里是不是已经有别人逼着工厂提前造好的代理对象了”一看果然有。于是 Spring 放弃在这一步重复生成代理直接把二级缓存里的那个代理对象名正言顺地移入一级缓存。protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 1. 查一级缓存完全体 Object singletonObject this.singletonObjects.get(beanName); // 如果一级没有且 AgentService 正在被创建 if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { // 2. 查二级缓存早期暴露的成品 singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { // 3. 查三级缓存找工厂 ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { // 如果需要 AOP这里会当场造出代理替身如果不需要就原样返回原始对象。 singletonObject singletonFactory.getObject(); // 拿到代理对象后立马放入二级缓存 this.earlySingletonObjects.put(beanName, singletonObject); // 把三级缓存的工厂删掉防止工厂被重复调用生成不同的代理对象 this.singletonFactories.remove(beanName); } } } return singletonObject; }3.3 三级缓存中的数据到这里整个数据不一致的问题被解开了。我们最后梳理一下日常注入时这三级缓存里到底放的是什么一级缓存singletonObjects存放最终完全成型的 Bean。无论它是原始对象还是代理对象只要放进这里就意味着它的属性已经全部填满生命周期彻底走完可以直接对外提供服务。二级缓存earlySingletonObjects存放“半成品” Bean 的早期引用。最关键的是如果这个 Bean 需要 AOP二级缓存里放的一定是提前生成的代理对象。它保证了整个系统里所有依赖这个 Bean 的其他组件拿到的一定是同一份最终的引用类型。三级缓存singletonFactories存放延迟获取对象的“工厂”。它是用来推迟代理对象创建时机的核心机制。只要没有发生循环依赖这个工厂就永远不会被触发Bean 就能安安稳稳地走到最后一步再去生成代理。这也顺带回答了那个极其经典的面试追问“如果没有 AOPSpring 还需要三级缓存吗”答案是不需要。如果没有 AOP 代理的干预对象在实例化那一刻的内存地址就是它最终成型时的内存地址。只需要两级缓存把这个原始的内存地址提前暴露出去就足够解决纯粹的依赖死锁了。三级缓存完完全全就是为了填平 AOP 代理替身带来的坑。理解了这三级缓存的运作原理我们就可以来一起梳理一下这里相关的面试题了。四、八股文实战4.1 AOP 核心与底层机制Q1什么是 AOP它主要解决了什么问题Q2Spring AOP 和 AspectJ 有什么本质区别Q3JDK 动态代理和 CGLIB 动态代理有什么区别Spring 是怎么选的Q4为什么 Spring 官方建议尽量使用接口来实现 AOP4.2 事务失效相关Q5为什么同一个类中方法 A 调用标了Transactional的方法 B事务会失效Q6怎么解决同类调用事务失效的问题Q7为什么把Transactional加在private方法上事务不生效4.3 三级缓存相关Q8既然二级缓存就能解决循环依赖为什么 Spring 非要搞个三级缓存Q9三级缓存singletonFactories里存的到底是什么什么时候会被执行Q10如果我的项目里完全没有使用 AOPSpring 还需要三级缓存吗Q11Spring 代理对象正常情况是在哪一步生成的发生循环依赖时呢Q12为什么触发了三级缓存生成代理对象后还要把它放进二级缓存并删掉三级缓存

更多文章