AOP和IOC
在现代 Java 后端开发中,Spring 框架是绝对的霸主,而 IoC(控制反转)和 AOP(面向切面编程)则是它的两大灵魂。很多开发者天天写 @Autowired 和 @Transactional,却对底层发生了什么一无所知。
本篇文档将用最通俗的语言、最真实的业务场景,彻底扒开这两个概念的底层面纱,并直击大厂面试最核心的深水区。
一、 IoC (Inversion of Control):控制反转与依赖注入
痛点推演:如果没有 IoC,代码会有多烂?
假设我们正在写一个电商系统,OrderService(订单服务)需要调用 InventoryService(库存服务)来扣减库存。 在传统的"控制正转"时代,代码是这样写的:
public class OrderService {
// 痛点 1:控制权在自己手里,强耦合!
// 痛点 2:如果 InventoryService 的构造函数需要传参,这里全得改!
private InventoryService inventoryService = new InventoryService();
public void create() {
inventoryService.deduct();
}
}
这就是灾难的开始。 整个系统成千上万个类互相 new,一旦底层某个类的创建规则变了,上层调用方全都要跟着改代码。系统像一团互相死死打结的乱麻。
简单的来说,控制反转(IoC)的核心 就是交出兵权与解耦 。它将对象的实例化以及对象之间依赖关系的绑定,从程序员手动硬编码的 new 操作中剥离出来,反转交由 Spring 容器统一接管。我们只管写业务逻辑,剩下的**'找对象、造对象、塞对象'全归 Spring 管**
底层逻辑:IoC 是如何"夺走兵权"的?
IoC 不是一种技术,而是一种架构思想 。它的核心就四个字:交出兵权。
大白话比喻:你(OrderService)是一个包工头,需要一个挖掘机司机
- 以前(自己 new):你得自己去满大街找人、培训他、发工资(自己管理对象的创建和生命周期)。
- 现在(IoC) :你直接向劳务派遣公司(Spring 容器)提交一个需求:"给我来个司机"。Spring 会在后台把司机招好、培训好,直接派到你面前。你拿到手直接用就行。
DI (Dependency Injection):依赖注入
IoC 是思想,DI 是实现手段。 Spring 把造好的对象塞给你的过程,就叫注入。
@Service // 告诉 Spring,这个包工头归你管了
public class OrderService {
@Autowired // 依赖注入:Spring,快给我派一个司机过来!
private InventoryService inventoryService;
public void create() {
// 直接用,根本不关心它是怎么被 new 出来的
inventoryService.deduct();
}
}
架构师总结 :IoC 的本质是极致的解耦。对象与对象之间不再直接产生物理羁绊,而是全部交给 Spring 这个"大管家"来统筹分配。
二、 AOP (Aspect-Oriented Programming):面向切面编程
AOP的具体参数:
|---------------------|----------------|-----------------------------------------------------------------------------------------|
| AOP 术语(黑话) | 现实比喻 | 大白话解释 |
| Target(目标对象) | 大明星 | 真正干活的类(比如包含了 saveUser 方法的那个类)。 |
| Advice(通知/增强) | 经纪人干的具体事情 | 提取出来的公共代码,比如"打印日志"、"开启事务"。并且它还规定了什么时候执行(比如是在明星唱歌前 @Before,还是唱歌后 @After,还是包揽前后 @Around)。 |
| Join Point(连接点) | 明星能接的所有活动 | 在 Spring 里,就是你能拦截到的所有方法。理论上,类里的每一个方法都可以是一个连接点。 |
| Pointcut(切入点) | 经纪人实际接的活动 | 也就是拦截规则。虽然有上百个方法,但我只想给带有 UserService 名字的方法加日志。这个匹配规则就是切入点。具体的来说就是要给那个方法加 |
| Aspect(切面) | 完整的经纪人公司规定 | 切面 = 切入点 + 通知。也就是说:要在哪些地方(Pointcut),在什么时机干什么事情(Advice),这两者组合起来就是一个切面配置。 |
痛点推演:被"杂活"逼疯的程序员
在写核心业务逻辑(写表、扣钱)时,我们往往还要做很多"杂活":记 日志、开启/提交 事务、权限校验。 如果没有 AOP,你的代码会被这些杂活淹没:
public void createOrder() {
log.info("开始执行"); // 杂活
transaction.begin(); // 杂活
// ------ 真正的核心业务只有这一行 ------
insertOrderToDB();
transaction.commit(); // 杂活
log.info("执行结束"); // 杂活
}
灾难后果 :如果系统有 1000 个方法,这些杂活你得复制粘贴 1000 遍。如果某天要把日志格式改一下,人直接原地爆炸。这叫代码散射与纠缠。
底层逻辑:AOP 的"替身(代理)"魔术
AOP 的核心思想是:抽离杂活,动态织入。
- 大白话比喻 :公司大楼里有 100 个员工,以前每个人干活前都要自己检查一遍胸牌(写死在代码里)。现在,公司请了一个保安(切面 Aspect) 站在大门口(切入点 Pointcut)。所有员工只要经过大门,保安就会统一拦截检查胸牌。员工只管干活,根本感觉不到保安的存在。
AOP 的底层原理是:动态代理 (Dynamic Proxy) 。 当 Spring 发现你的方法需要**"加点杂活"** (比如打了 @Transactional 注解),它绝对不会 去修改你的原代码,而是在内存里偷偷为你生成一个**"替身类(代理对象)"**。
// Spring 在内存中悄悄生成的"替身"public class OrderServiceProxy extends OrderService {
@Overridepublic void createOrder() {
// 1. 保安干杂活:开启事务
transaction.begin();
// 2. 调用真实的、你写的业务方法super.createOrder();
// 3. 保安干杂活:提交事务
transaction.commit();
}
}
架构师总结 :AOP 的本质是无侵入的增强。让业务代码保持绝对的纯粹,所有的非核心公共逻辑全交由代理替身在外围拦截执行。
AOP具体的实现技术:
在 Spring AOP 中,那个能在运行时悄无声息地帮你把"杂活(事务、日志)"塞进业务逻辑的"替身(代理对象)",底层主要是由两种技术 来实现的:JDK 动态代理 和 CGLIB 动态代理。
它们就像是 Spring 手里的两套"易容术",Spring 会根据你写的代码结构,自动决定使用哪一套。我来为你把这两套底层武功扒得清清楚楚:
一、 JDK 动态代理 ------ "基于接口的模仿者"(JKD是找兄弟)
这是 Java 原生自带的代理机制(在 java.lang.reflect.Proxy 包下),不需要引入任何外部依赖。
- 底层原理 :它要求你的目标类(比如
OrderServiceImpl)必须实现至少一个接口 (比如OrderService)。JDK 会在内存中动态生成 一个也实现了该接口的新类(替身)。因为大家实现了同一个接口,所以长得一样,调用方根本察觉不出被掉包了。 - 致命弱点 :"无接口,不代理" 。如果你的类就是一个普通的 Java 类,没有去
implements任何接口,JDK 动态代理直接当场罢工,它拿你毫无办法。 - 大白话比喻 :这种替身术要求原本的员工必须有**"岗位说明书(接口)"** 。替身只要照着岗位说明书去干活,就能伪装成原员工。
二、 CGLIB 动态代理 ------ "基于继承的篡位者"(CGLIB是认干爹)
CGLIB(Code Generation Library)是一个强大的高性能代码生成包。Spring 把它集成到了自己的源码里。它是为了解决 JDK 动态代理"必须要有接口"的痛点而生的。
- 底层原理 :如果你的类没有实现接口,CGLIB 就会在内存中动态生成一个你这个类的子类(继承你的类)。然后在这个子类里,重写(Override)你父类的业务方法,在重写的时候把"杂活(AOP 逻辑)"悄悄塞进去。这叫"子类替父出征"。
- 致命弱点 :"遇 final****则瞎" 。既然它的底层是基于继承和重写的,那么根据 Java 的语法铁律,如果你的目标类被标记为 final**,或者你的某个方法被标记为** final**/** private,它是绝对无法被子类重写的!这时候 CGLIB 代理就会悄无声息地失效(增强逻辑加不上去)。
- 大白话比喻 :这种替身术不看说明书,它直接克隆了一个你(生成子类),然后把你的行为习惯全部覆盖掉。但如果你给自己上了锁(
final),它就无能为力了。
三、 架构师实战揭秘:Spring 到底怎么选?
在面试大厂时,面试官通常会抛出一个陷阱题:"Spring AOP 默认使用的是哪种代理?"
这时候你千万别只答一种,因为时代的底层逻辑变了:
- 传统的 Spring 时代(Spring 5.0 之前 / Spring Boot 1.x):
-
- 规则:如果有接口,优先用 JDK 动态代理;如果实在没有接口,才会被逼无奈去用 CGLIB。
- 现代的 Spring Boot 时代(Spring Boot 2.x 及以后):
-
- 规则 :全量默认使用 CGLIB! (底层配置
spring.aop.proxy-target-class=true变成了默认值)。 - 为什么大厂要这么改? :因为无数新手程序员经常踩坑------他们写了接口,但习惯用具体的实现类去
@Autowired注入,结果因为 JDK 生成的代理对象只是实现了接口,并不能强转成具体的实现类,导致启动疯狂报错ClassCastException。改成全量 CGLIB 后(生成的是子类),无论你是按接口注入还是按实现类注入,都丝滑无比,极大降低了开发者的心智负担。
- 规则 :全量默认使用 CGLIB! (底层配置
具体的流程如下:
假设你按照最标准的规范,写了一个接口和一个实现类,并且在实现类上加了 @Transactional(触发了 AOP):
// 1. 定义接口
public interface OrderService {
void createOrder();
}
// 2. 编写实现类,并打上事务注解(触发 AOP)
@Service
public class OrderServiceImpl implements OrderService {
@Transactional
@Override
public void createOrder() {
// 核心业务逻辑
}
}
接下来,你要在 Controller 里注入这个 Service 来使用了。 重点来了!由于很多程序员图省事,或者由于业务习惯,他们没有用接口去接收,而是直接用了实现类去接收:
@RestController
public class OrderController {
// 【致命错误点】:习惯性地用具体实现类去注入
@Autowired
private OrderServiceImpl orderService;
// ...
}
如果用 JDK 动态代理(老版本的默认规则),会发生什么?
Spring 启动时,看到 OrderServiceImpl 实现了 OrderService 接口,于是开心地决定使用 JDK 动态代理。
-
- JDK 代理的底层逻辑 :它会在内存里生成一个替身类(假设叫
$Proxy0)。这个替身类仅仅实现了 OrderService****接口。 - 关系剖析 :这个替身
$Proxy0和你的OrderServiceImpl是平级关系(兄弟) ,它们都认OrderService当爸爸(实现了同一个接口),但它们俩之间没有任何继承关系。
- JDK 代理的底层逻辑 :它会在内存里生成一个替身类(假设叫
这个时候,Spring 准备把生成的替身 $Proxy0 注入到你的 Controller 里:
Java
// Spring 底层尝试执行的赋值操作:
OrderServiceImpl orderService = new $Proxy0(); // 💥 轰塌!
这就是 ClassCastException****的根源! 在 Java 语法里,你不能把一个哥哥($Proxy0)硬塞给一个弟弟(OrderServiceImpl)的引用里。因为类型根本不匹配,启动瞬间直接报错瘫痪。
如果用 CGLIB 动态代理(现在的默认规则),又会发生什么?
Spring Boot 2.x 之后,官方实在是被这种报错 Issue 烦透了,于是强行规定:管你有没有接口,我全用 CGLIB!
-
- CGLIB 代理的底层逻辑 :它不在乎你有没有接口。它直接在内存里生成一个替身类(假设叫
OrderServiceImpl$$EnhancerBySpringCGLIB),这个替身类直接继承(extends)了你的 OrderServiceImpl。 - 关系剖析 :这个替身是你写的类的亲儿子(子类)。
- CGLIB 代理的底层逻辑 :它不在乎你有没有接口。它直接在内存里生成一个替身类(假设叫
这个时候,Spring 再次准备把替身注入到你的 Controller 里:
// Spring 底层尝试执行的赋值操作:
OrderServiceImpl orderService = new OrderServiceImpl$$EnhancerBySpringCGLIB();
// ✅ 完美通过!
为什么成功了? 因为 Java 的多态铁律:子类对象完全可以直接赋值给父类的引用!
总结一下 :JDK 代理靠接口 ,CGLIB 代理靠继承(生成子类)。现在你日常开发的 Spring Boot 项目,99% 的 AOP 替身都是 CGLIB 帮你悄悄生出来的"好大儿(子类)"。
三、 面试问题
在阿里、美团、腾讯的面试中,面试官绝对不会只问概念,而是直接掏出底层的"疑难杂症"来拷打你。
Spring IoC 是怎么解决"循环依赖"的?
- 面试官 :
A里面注入了B,B里面又注入了A。Spring 在创建A的时候要去拉取B,创建B的时候又要拉取A,这不是死循环了吗?Spring 是怎么解决的? - 答法:
破局的核心前提:实例化与初始化的"时间差"
我们要向面试官明确第一件事:Spring 能解决循环依赖,是因为它把造对象分成了两步。
- 实例化(造个半成品) :仅仅是在堆内存里
new了一个空壳对象,里面的@Autowired属性全都是 null。 - 初始化(加工成成品) :给那些打了
@Autowired的属性赋值(依赖注入),并执行 AOP 等操作。
正是因为有这个时间差,Spring 才能玩出"提前暴露半成品"的魔术。
底层架构:三级缓存 (Three-Level Cache)
Spring 内部用了三个 Map 来管理 Bean 的生命周期,这就是大名鼎鼎的三级缓存:
- 一级缓存 ( singletonObjects**)** :"成品库"。里面存放的是完全创建好、属性全赋值完的最终 Bean。我们平时业务里拿到的都是这里的对象。
- 二级缓存 ( earlySingletonObjects**)** :"半成品库" 。里面存放的是刚
new出来、但属性还是 null 的早期对象。 - 三级缓存 ( singletonFactories**)** :"车间图纸库(对象工厂)"。里面存的不是完整的对象,而是一个 Lambda 表达式(工厂方法)。当有人需要半成品时,调用这个工厂就能拿得到。
极简推演:A 和 B 的"完美闭环"
- A 开始实例化(此时 A 只是个局部变量) : Spring 利用反射
new出了 A 的实例。注意,此时的 A 是一个纯正的空壳半成品,它不在任何一级缓存里,仅仅是 Spring 方法里的一个局部变量。 - A 提前暴露(包装成工厂,入驻 3 级缓存) : 为了防止死循环,Spring 把这个作为局部变量的半成品 A,包装进了一个 Lambda 表达式(对象工厂)里。 然后,Spring 把这个工厂塞进了三级缓存 ( singletonFactories**)** 。 (画外音:此时半成品 A 本尊,被闭包"幽禁"在了三级缓存的工厂函数里。)
- A 需要 B(触发暂停) : A 开始执行属性注入,发现身上带有
@Autowired B。去 1、2、3 级缓存里翻了一圈,根本没有 B。于是 Spring 暂停了 A 的加工,保留现场,转头去创建 B。 - B 开始实例化与暴露(复刻 A 的操作) : Spring 同样
new出了 B 的空壳半成品(局部变量),把它包装成 B 的对象工厂,塞进三级缓存。 - B 需要 A(高潮来了,开始找 A): B 开始注入属性,发现需要 A。B 去找缓存:
- 找一级成品库:没有。
- 找二级半成品库:没有。
- 找三级工厂库:找到了 A 留下的工厂!
- B 拿到 A(触发工厂,完成缓存升级): B 调用了 A 的工厂(执行 Lambda)。工厂发力,不仅吐出了之前幽禁在里面的"半成品 A",甚至如果 A 需要 AOP 代理,工厂会顺手把 A 的"代理替身"给造出来吐给 B!
- 【核心动作:缓存升级】 :既然 A 的半成品已经被造出来了,Spring 会立刻把拿到手的这个半成品 A,塞进二级缓存 ( earlySingletonObjects**)** 中!同时,把 A 的工厂从三级缓存里删掉!
- B 变身成品(入住 1 级缓存) : B 顺利拿到了半成品 A,完成了自己的属性注入,走完剩下的生命周期。此时 B 彻底成熟。 Spring 将成熟的 B 放入一级缓存 ( singletonObjects**)**,并清空 B 在二、三级缓存里的所有痕迹。
- A 满血复活(完成闭环) : B 已经就位,原本暂停的 A 重新启动。A 从一级缓存里拿到了完全体成品 B,把 B 注入到自己体内。 A 也顺利走完生命周期,彻底成熟。Spring 将成熟的 A 放入一级缓存,并清空 A 在二级缓存里的痕迹。死循环完美破解!
如果面试官顺着你的回答问:"第 6 步拿到半成品 A 后,为什么要把它从 3 级移到 2 级缓存?直接让它留在 3 级工厂里不行吗?"
你可以给出这个一针见血的回答:
"这是为了保证单例(Singleton)的唯一性和性能 ! 假设除了 B 需要 A,还有一个 C 也需要 A。 如果不升级到二级缓存,那么 C 来找 A 时,又会去调用一次 A 的三级工厂。如果 A 恰好配置了 AOP,工厂就会被触发两次,生成两个不同的 A 代理替身!这就打破了 Spring 单例的铁律 。 所以,三级缓存的工厂只能被调用一次。调用完吐出半成品后,必须马上把它升入二级缓存(相当于把结果缓存下来)。以后不管是 C 还是 D 再来找 A,直接从二级缓存里拿同一个半成品引用就可以了。"
但是,三级缓存的架构却是解决不了构造器造成的循环依赖。
如果 A 和 B 都是通过构造方法互相注入:
@Service
public class A {
private final B b;
// 构造器注入
public A(B b) { this.b = b; }
}
@Service
public class B {
private final A a;
// 构造器注入
public B(A a) { this.a = a; }
}
推演过程:
- A 试图实例化(造空壳) :Spring 准备
new A(...)。但是 Java 语法规定,调用构造方法时,必须传入一个真实的 B 对象。 - A 被迫暂停,去寻找 B :此时 A 连个半成品的影子都造不出来,更不可能把自己塞进三级缓存!A 只能停下来,让 Spring 先去造 B。
- B 试图实例化(造空壳) :Spring 准备
new B(...)。同样,必须传入一个真实的 A 对象。 - B 去找 A:B 去 1、2、3 级缓存里疯狂找 A,结果全都是空的(因为 A 第一步就卡死了,根本没进缓存)。
- 死锁崩溃:A 等着 B 出生,B 等着 A 出生。Spring 发现这两个连半成品都造不出来,直接宣告放弃,抛出异常。
解决方案:
使用 @Lazy****延迟加载 这是 Spring 官方给出的强力补丁。在其中一方的构造器参数上加上 @Lazy:
public A(@Lazy B b) {
this.b = b;
}
底层实例化过程 :当 Spring 准备 new A(...) 时,发现参数标了 @Lazy,它会当场捏造一个 B 的代理替身(CGLIB Proxy) 塞给 A。A 拿着这个替身顺利完成了实例化。等 A 真正调用 **b.doSomething()**时,替身才会去 Spring 容器里把真实的 B 找出来。这招叫"瞒天过海"。
同类内部方法调用,会导致 AOP (如 @Transactional) 失效吗?为什么?
- 面试官 :
OrderService里有两个方法a()和b()。a()没有打事务注解,b()打了@Transactional。我在外部调用a(),然后在a()的代码内部直接调用this.b()。请问b()的事务会生效吗? - 答法:
"绝对会失效!这是生产环境最容易踩的史诗级大坑!底层原因 :AOP 的核心是动态代理(替身) 。我们在外部调用服务时,其实是调用的 Spring 生成的'替身' ,替身会帮我们开启事务。 但是,在 a() 方法内部直接调用 b(),本质上是调用的 this.b()。这个 this****指向的是真实对象本身,而不是那个替身!既然绕过了替身,保安没有拦截到,事务自然就彻底失效了。
大厂解法 :绝不用 this 调用。可以把自己( OrderService**)通过** @Autowired****再次注入到自己内部 ,用注入的实例去调用 b();或者使用 **AopContext.currentProxy()**强行获取当前线程的代理对象来调用。"
Spring AOP 的底层代理机制,JDK 动态代理和 CGLIB 有什么区别?
- 面试官发难:如果我想对一个类做 AOP 增强,Spring 会选哪种代理方式?它们俩的底层原理有什么不同?
- 架构师降维答法:
"在 Spring Boot 2.x 之后,默认全量使用 CGLIB 。 两者的核心区别在于生成替身的方式:
- JDK 动态代理 :基于接口。替身类和真实类实现了同一个接口。缺点是如果你的真实类没有实现任何接口,它就直接歇菜了。
- CGLIB 动态代理 :基于继承 。它会在内存中动态生成一个真实类的子类 ,并重写父类的方法。缺点是因为基于继承,所以它绝对无法代理被 final****修饰的方法或类(因为 final 无法被重写)。 大厂之所以默认用 CGLIB,是因为它不再强求业务类必须写接口,开发体验更丝滑。"