在实际的工程开发中,只要你接手过稍微复杂一点的系统,很快就会遇到另一个极其让人头疼的问题。这个问题,恰恰是伴随着我们最熟悉的面向对象编程带来的(或者更本质地说,是程序天然带来的,因为程序的本质就是将代码按预设线性的顺序向下执行)。
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 在启动时,会利用底层 API(java.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 依赖 B,B 又依赖 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 步(初始化)。因为配置了 AOP,Spring 框架在这里强行给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 就能安安稳稳地走到最后一步再去生成代理。
这也顺带回答了那个极其经典的面试追问: “如果没有 AOP,Spring 还需要三级缓存吗?” 答案是:不需要。 如果没有 AOP 代理的干预,对象在实例化那一刻的内存地址,就是它最终成型时的内存地址。只需要两级缓存,把这个原始的内存地址提前暴露出去,就足够解决纯粹的依赖死锁了。三级缓存,完完全全就是为了填平 AOP 代理替身带来的坑。
理解了这三级缓存的运作原理,我们就可以来一起梳理一下这里相关的面试题了。
四、八股文实战
4.1 AOP 核心与底层机制
Q1:什么是 AOP?它主要解决了什么问题?
Q2:Spring AOP 和 AspectJ 有什么本质区别?
Q3:JDK 动态代理和 CGLIB 动态代理有什么区别?Spring 是怎么选的?
Q4:为什么 Spring 官方建议尽量使用接口来实现 AOP?
4.2 事务失效相关
Q5:为什么同一个类中,方法 A 调用标了 @Transactional 的方法 B,事务会失效?
Q6:怎么解决同类调用事务失效的问题?
Q7:为什么把 @Transactional 加在 private 方法上,事务不生效?
4.3 三级缓存相关
Q8:既然二级缓存就能解决循环依赖,为什么 Spring 非要搞个三级缓存?
Q9:三级缓存(singletonFactories)里存的到底是什么?什么时候会被执行?
Q10:如果我的项目里完全没有使用 AOP,Spring 还需要三级缓存吗?
Q11:Spring 代理对象正常情况是在哪一步生成的?发生循环依赖时呢?
Q12:为什么触发了三级缓存生成代理对象后,还要把它放进二级缓存,并删掉三级缓存?
五、总结
到这里,咱们《Java工程师复健》第二篇的 AOP 与三级缓存就全部结束了。
回过头来看,AOP 和三级缓存到底是什么? 如果说 IoC 是一场为了追求代码的解耦而收回“New”权利的过程;那么 AOP 和它背后那一套极其复杂的代理与缓存机制,就是向现实妥协的工程艺术。
现实是什么?现实是哪怕你的面向对象(OOP)设计得再纯粹、再优雅,也抵挡不住事务、日志、大模型计费这些横跨所有模块的非业务需求。 为了维护核心业务代码的纯洁性,Spring 选择了妥协:它没有强求所有人去学习硬核的底层字节码修改(AspectJ),而是巧妙地利用了 Java 的反射和继承机制,在内存里动态捏造出一个个动态代理。
而为了给这些的替身擦屁股,解决它们和 IoC 循环依赖发生的Bug,Spring 又硬生生地在原有的两级缓存之上,逼出了一个装满 Lambda 表达式的“三级延迟代理工厂”。
所有的这些内容,没有一个是凭空想象出来的炫技,全都是为了“让我们不写重复代码”。理解了这层苦心,你再去看源码里那些密密麻麻的 if-else,是不是多了一丝亲切感?
但是,这就足够了吗?
有了 IoC 和 AOP,我们确实不用再 new 对象了,也不用再到处写 try-catch 事务了。可是,每当你新建一个项目,你依然要痛苦地写一堆大同小异的配置:配置组件扫描路径、配置数据源、配置 MyBatis、配置事务管理器、把 Tomcat 塞进去…… 所有的项目启动前,都要经历这极其繁琐且毫无营养的“搭架子”折磨。既然这些配置 99% 都是一样的,为什么框架不能直接帮我配好?
既然我们的终极目标是“不做重复的事情”,那这个优化就绝不能停。