Spring

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 默认使用的是哪种代理?"

这时候你千万别只答一种,因为时代的底层逻辑变了:

  1. 传统的 Spring 时代(Spring 5.0 之前 / Spring Boot 1.x)
    1. 规则:如果有接口,优先用 JDK 动态代理;如果实在没有接口,才会被逼无奈去用 CGLIB。
  1. 现代的 Spring Boot 时代(Spring Boot 2.x 及以后)
    1. 规则全量默认使用 CGLIB! (底层配置 spring.aop.proxy-target-class=true 变成了默认值)。
    2. 为什么大厂要这么改? :因为无数新手程序员经常踩坑------他们写了接口,但习惯用具体的实现类去 @Autowired 注入,结果因为 JDK 生成的代理对象只是实现了接口,并不能强转成具体的实现类,导致启动疯狂报错 ClassCastException。改成全量 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 动态代理

    1. JDK 代理的底层逻辑 :它会在内存里生成一个替身类(假设叫 $Proxy0)。这个替身类仅仅实现了 OrderService****接口
    2. 关系剖析 :这个替身 $Proxy0 和你的 OrderServiceImpl平级关系(兄弟) ,它们都认 OrderService 当爸爸(实现了同一个接口),但它们俩之间没有任何继承关系

这个时候,Spring 准备把生成的替身 $Proxy0 注入到你的 Controller 里:

Java

复制代码
// Spring 底层尝试执行的赋值操作:
OrderServiceImpl orderService = new $Proxy0(); // 💥 轰塌!

这就是 ClassCastException****的根源! 在 Java 语法里,你不能把一个哥哥($Proxy0)硬塞给一个弟弟(OrderServiceImpl)的引用里。因为类型根本不匹配,启动瞬间直接报错瘫痪。

如果用 CGLIB 动态代理(现在的默认规则),又会发生什么?

Spring Boot 2.x 之后,官方实在是被这种报错 Issue 烦透了,于是强行规定:管你有没有接口,我全用 CGLIB!

    1. CGLIB 代理的底层逻辑 :它不在乎你有没有接口。它直接在内存里生成一个替身类(假设叫 OrderServiceImpl$$EnhancerBySpringCGLIB),这个替身类直接继承(extends)了你的 OrderServiceImpl
    2. 关系剖析 :这个替身是你写的类的亲儿子(子类)

这个时候,Spring 再次准备把替身注入到你的 Controller 里:

复制代码
// Spring 底层尝试执行的赋值操作:
OrderServiceImpl orderService = new OrderServiceImpl$$EnhancerBySpringCGLIB();
 // ✅ 完美通过!

为什么成功了? 因为 Java 的多态铁律:子类对象完全可以直接赋值给父类的引用!


总结一下 :JDK 代理靠接口 ,CGLIB 代理靠继承(生成子类)。现在你日常开发的 Spring Boot 项目,99% 的 AOP 替身都是 CGLIB 帮你悄悄生出来的"好大儿(子类)"。


三、 面试问题

在阿里、美团、腾讯的面试中,面试官绝对不会只问概念,而是直接掏出底层的"疑难杂症"来拷打你。

Spring IoC 是怎么解决"循环依赖"的?

  • 面试官A 里面注入了 BB 里面又注入了 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 的"完美闭环"
  1. A 开始实例化(此时 A 只是个局部变量) : Spring 利用反射 new 出了 A 的实例。注意,此时的 A 是一个纯正的空壳半成品,它不在任何一级缓存里,仅仅是 Spring 方法里的一个局部变量。
  2. A 提前暴露(包装成工厂,入驻 3 级缓存) : 为了防止死循环,Spring 把这个作为局部变量的半成品 A,包装进了一个 Lambda 表达式(对象工厂)里。 然后,Spring 把这个工厂塞进了三级缓存 ( singletonFactories**)** 。 (画外音:此时半成品 A 本尊,被闭包"幽禁"在了三级缓存的工厂函数里。)
  3. A 需要 B(触发暂停) : A 开始执行属性注入,发现身上带有 @Autowired B。去 1、2、3 级缓存里翻了一圈,根本没有 B。于是 Spring 暂停了 A 的加工,保留现场,转头去创建 B。
  4. B 开始实例化与暴露(复刻 A 的操作) : Spring 同样 new 出了 B 的空壳半成品(局部变量),把它包装成 B 的对象工厂,塞进三级缓存
  5. B 需要 A(高潮来了,开始找 A): B 开始注入属性,发现需要 A。B 去找缓存:
  • 找一级成品库:没有。
  • 找二级半成品库:没有。
  • 找三级工厂库:找到了 A 留下的工厂!
  1. B 拿到 A(触发工厂,完成缓存升级): B 调用了 A 的工厂(执行 Lambda)。工厂发力,不仅吐出了之前幽禁在里面的"半成品 A",甚至如果 A 需要 AOP 代理,工厂会顺手把 A 的"代理替身"给造出来吐给 B!
  2. 【核心动作:缓存升级】 :既然 A 的半成品已经被造出来了,Spring 会立刻把拿到手的这个半成品 A,塞进二级缓存 ( earlySingletonObjects**)** 中!同时,把 A 的工厂从三级缓存里删掉!
  3. B 变身成品(入住 1 级缓存) : B 顺利拿到了半成品 A,完成了自己的属性注入,走完剩下的生命周期。此时 B 彻底成熟。 Spring 将成熟的 B 放入一级缓存 ( singletonObjects**)**,并清空 B 在二、三级缓存里的所有痕迹。
  4. 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; }
}

推演过程:

  1. A 试图实例化(造空壳) :Spring 准备 new A(...)。但是 Java 语法规定,调用构造方法时,必须传入一个真实的 B 对象
  2. A 被迫暂停,去寻找 B :此时 A 连个半成品的影子都造不出来,更不可能把自己塞进三级缓存!A 只能停下来,让 Spring 先去造 B。
  3. B 试图实例化(造空壳) :Spring 准备 new B(...)。同样,必须传入一个真实的 A 对象。
  4. B 去找 A:B 去 1、2、3 级缓存里疯狂找 A,结果全都是空的(因为 A 第一步就卡死了,根本没进缓存)。
  5. 死锁崩溃: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 。 两者的核心区别在于生成替身的方式

  1. JDK 动态代理 :基于接口。替身类和真实类实现了同一个接口。缺点是如果你的真实类没有实现任何接口,它就直接歇菜了。
  2. CGLIB 动态代理 :基于继承 。它会在内存中动态生成一个真实类的子类 ,并重写父类的方法。缺点是因为基于继承,所以它绝对无法代理被 final****修饰的方法或类(因为 final 无法被重写)。 大厂之所以默认用 CGLIB,是因为它不再强求业务类必须写接口,开发体验更丝滑。"
相关推荐
yychen_java1 小时前
深度解析电力交易系统的“硬核”战场
java·能源
无尽冬.2 小时前
个人八股之string字符串
java·开发语言·经验分享·后端·异世界
伯远医学2 小时前
Nat. Methods | 邻近标记技术:活细胞中捕捉分子互作的新利器
java·开发语言·前端·javascript·人工智能·算法·eclipse
RainCity2 小时前
Java Swing 自定义组件库分享(五)
java·笔记·后端
woniu_buhui_fei2 小时前
JVM垃圾回收
java·jvm
AC赳赳老秦2 小时前
文案策划提效:OpenClaw批量生成活动文案、宣传海报配文,适配不同渠道调性
java·大数据·服务器·人工智能·python·deepseek·openclaw
_codemonster2 小时前
系统分析师系列目录
java·网络·数据库
带刺的坐椅2 小时前
Spring AI 2.0 GA 倒计时:先别急,来看看 Java AI 框架的另一条路
java·spring·ai·llm·agent·solon
TE-茶叶蛋3 小时前
Java 8 引入的Stream API-stream()
java·windows·python