6. [原理剖析] 深入 IoC 容器

6. [原理剖析] 深入 IoC 容器
Prorise6. [原理剖析] 深入 IoC 容器
摘要: 本章我们将探讨 Spring IoC 容器中两个非常重要的底层话题。首先是经典的面试难题——循环依赖,我们将从原理上剖析 Spring 是如何通过三级缓存解决它的。其次是作为 Spring 设计思想基石的工厂模式。
6.1. Bean 的循环依赖问题 (面试高频)
首先,我们需要理解什么是循环依赖,以及为什么它在 IoC 容器中是一个值得被探讨的问题。
6.1.1. "痛点"场景: 什么是循环依赖?
循环依赖(Circular Dependency),也称为循环引用,指的是两个或多个 Bean 之间相互持有对方的引用,形成一个闭环。最简单的场景就是 A 依赖 B,同时 B 又依赖 A。
A -> B -> A
一个生动的比喻:
想象一下“丈夫”(Husband
)和“妻子”(Wife
)两个类。Husband
对象有一个 Wife
类型的属性,Wife
对象也有一个 Husband
类型的属性。当 Spring 容器试图创建它们时,就会遇到一个难题:
- 要创建
Husband
实例,必须先注入一个Wife
实例。 - 但要创建
Wife
实例,又必须先注入一个Husband
实例。这就形成了一个看似无解的“死循环”。
准备代码:
我们将使用这个“夫妻”模型来贯穿本节的实验。
文件路径: src/main/java/com/example/spring6/bean/Husband.java
(新增文件)
1 | package com.example.spring6.bean; |
文件路径: src/main/java/com/example/spring6/bean/Wife.java
(新增文件)
1 | package com.example.spring6.bean; |
6.1.2. 实践验证:Spring 能否解决循环依赖?
Spring 能否解决循环依赖,取决于 Bean 的作用域和注入方式。让我们通过三个核心场景来一探究竟。
准备配置文件:
文件路径: src/main/resources/spring-dependency.xml
(新增配置文件)
场景一: singleton
+ Setter 注入 (成功)
这是最常见的场景:两个 Bean 都是单例,并且通过 Setter 方法相互注入。
XML 配置:
1 |
|
测试代码:
1 |
|
1
2
Husband{name='张三', wifeName=小红}
Wife{name='小红', husbandName=张三}
结论: Spring 完美地解决了单例 Bean 通过 Setter 方式注入的循环依赖问题。
场景二: prototype
+ Setter 注入 (失败)
如果我们将两个 Bean 的作用域都改为 prototype
,情况会如何?
XML 配置 (修改 scope
):
1 | <bean id="husband" class="com.example.spring6.bean.Husband" scope="prototype"> |
1
2
3
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'husband' defined in class path resource [spring-dependency.xml]: Cannot resolve reference to bean 'wife' while setting bean property 'wife'
at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference(BeanDefinitionValueResolver.java:377)
... (省略堆栈信息)
结论: Spring 无法解决 prototype
作用域 Bean 的循环依赖问题,并会抛出 BeanCurrentlyInCreationException
异常。
场景三: singleton
+ 构造器注入 (失败)
我们回到 singleton
作用域,但改用构造器进行注入。
准备构造器注入的代码:
1 | // HusbandWithConstructor.java |
XML 配置:
1 | <bean id="husband" class="com.example.spring6.bean.HusbandWithConstructor"> |
1
2
3
4
org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'husband': Requested bean is currently in creation:
Is there an unresolvable circular reference?
... (省略堆栈信息)
结论: Spring 同样无法解决构造器注入方式的循环依赖问题,即使 Bean 是单例的。
6.1.3. 原理浅析:Spring 如何通过“三级缓存”解开死结?
为什么只有“singleton
+ Setter 注入”的组合能成功?这背后的功臣,就是 Spring IoC 容器内部精巧的“三级缓存”机制。
刚才的实验结果很有趣。你能量化一下,为什么 Spring 能解决 singleton 和 Setter 的循环依赖吗?
核心原因在于 Spring 将 Bean 的创建过程分为了两步:1. 实例化 和 2. 属性填充。对于单例 Bean,Spring 可以在完成第一步实例化之后,不等第二步属性填充完成,就将这个半成品的对象提前暴露出去。
很好。那 “提前曝光” 是通过什么机制实现的呢?能具体谈谈吗?
它是通过一个位于 DefaultSingletonBeanRegistry 类中的三级缓存来实现的。
一级缓存 : 存放已完成初始化的单例 Bean,是最终的成品区。
二级缓存 : 存放提前曝光的、未完成属性填充的半成品单例 Bean。
三级缓存 : 存放能生产半成品 Bean 的工厂 (ObjectFactory)。
能描述一下创建 husband 和 wife 的完整流程吗?
当然。
- getBean(“husband”),三级缓存中都没有,开始创建。
- new Husband() 实例化一个半成品 husband 对象。
- Spring 并不立即填充其 wife 属性,而是将一个能获取这个半成品 husband 的工厂放入三级缓存。
- 开始填充 husband 的属性,发现它需要 wife。
- getBean(“wife”),三级缓存中都没有,开始创建。
- new Wife() 实例化一个半成品 wife 对象,并同样将其工厂放入三级缓存。
- 开始填充 wife 的属性,发现它需要 husband。
- 再次 getBean(“husband”),此时 Spring 从三级缓存中找到了 husband 的工厂,通过工厂拿到半成品 husband 对象,并将其放入二级缓存。
- wife 成功获取到半成品 husband 的引用,完成属性填充和初始化,成为一个成品。随后,wife 被放入一级缓存。
- husband 也成功获取到成品 wife 的引用,完成自己的属性填充和初始化,也成为成品,并被放入一级缓存。循环依赖解决。
非常清晰!那最后请解释一下,为什么构造器注入和 prototype 作用域就不行呢?
构造器注入 的问题在于,它的实例化和属性填充是在同一步 (new Husband(wife)
) 中完成的。在 new 的那一刻就必须拿到 wife 的实例,无法提前曝光一个半成品,所以陷入了死循环。
prototype 作用域 的问题在于,Spring 容器不对 prototype Bean 进行缓存。每次请求都是全新的,也就没有了可以存放半成品的缓存机制,自然也无法解决循环依赖。
6.2. [背景知识] 工厂设计模式
6.2.1. "痛点"场景: 为什么我们需要工厂?
在面向对象编程的初期,我们创建对象的方式通常非常直接:
1 | public class Client { |
这段代码存在一个严重的问题:客户端 (Client
) 与具体的产品 (Tank
, Fighter
) 强耦合。
- 缺乏弹性: 如果将来我们新增一种武器
Dagger
,就必须修改Client
类的代码。 - 违反开闭原则 (OCP): 我们的系统对“扩展”(增加新武器)是关闭的,因为扩展需要“修改”现有代码。
解决方案:
引入一个“工厂”角色,专门负责创建对象。客户端不再关心对象是如何被创建的,只管向工厂索要即可。这就实现了创建过程与使用过程的分离。
6.2.2. 模式概览: 简单工厂 vs 工厂方法
简单工厂模式
这是最基础的工厂模式,它将所有产品的创建逻辑集中在一个工厂类中。
1. 准备代码
文件路径: src/main/java/com/example/spring6/factory/Weapon.java
(及实现类)
1 | package com.example.spring6.factory; |
文件路径: src/main/java/com/example/spring6/factory/WeaponFactory.java
1 | package com.example.spring6.factory; |
2. 客户端调用
文件路径: src/main/java/com/example/spring6/factory/Client.java
1 | package com.example.spring6.factory; |
1
2
坦克开炮!
战斗机投弹!
优点: 实现了创建和使用的分离。
缺点: 违反了开闭原则。每当新增一种武器,我们都必须修改 WeaponFactory
类的 get
方法
工厂方法模式
为了解决简单工厂的 OCP 问题,工厂方法模式将工厂也进行了抽象。
核心思想: 不再由一个全能工厂来创建所有产品,而是为每一种产品都提供一个专门的工厂。
1. 准备代码
我们继续使用上面的 Weapon
产品类。
文件路径: src/main/java/com/example/spring6/factory/WeaponFactory.java
(修改为接口)
1 | package com.example.spring6.factory; |
文件路径: src/main/java/com/example/spring6/factory/TankFactory.java
(等具体工厂)
1 | package com.example.spring6.factory; |
2. 客户端调用
1 | public class Client { |
1
2
坦克开炮!
战斗机投弹!
优点: 完美遵循了开闭原则。如果现在需要新增 Dagger
武器,我们只需新增一个 DaggerFactory
即可,完全不需要修改任何现有代码。
缺点: 当产品种类非常多时,会导致工厂类的数量急剧增加。
6.2.3. Spring 中的体现: BeanFactory
与 FactoryBean
的区别
理解了工厂模式后,我们就可以来辨析 Spring 中两个最容易混淆的核心概念了。
我们经常听说 Spring 是一个大工厂,也常提到 BeanFactory
和 FactoryBean
。它们之间有什么关系和区别?
这是一个经典问题。虽然名字很像,但它们是两个完全不同维度的概念。
BeanFactory 是工厂。它是 Spring IoC 容器的顶级接口,是 Spring 框架的“心脏”,是管理所有 Bean 的“总工厂”。它的职责是定义如何获取和管理 Bean,是 Spring IoC 功能的基石。我们通常使用的 ApplicationContext
就是它的一个功能更强大的子接口。
FactoryBean 是一个 Bean。它是一个可以被总工厂 (BeanFactory
) 管理的、比较特殊的 Bean。特殊之处在于,它本身的作用不是给自己用,而是作为一个“小型、可插拔的零件工厂”,去生产另一个 Bean。
那么从容器中获取 Bean 时,这两者有什么不同?
这是最关键的区别。假设我们有一个 id
为 myCarEngine
的 FactoryBean
。
当我们调用 context.getBean("myCarEngine")
时,我们得到的不是这个 FactoryBean
本身,而是它内部 getObject()
方法返回的那个“发动机”对象。
如果我们确实需要 FactoryBean
这个“机床”本身,需要使用一个 &
符号,像这样调用:context.getBean("&myCarEngine")
。
总结来说,BeanFactory 是管理者,FactoryBean 是被管理者中的一个特殊工匠。
重要信息: 后续内容还有重要的注解开发和面向切面AOP的核心,并不是笔记这里不讲了,是我认为他更适合在Spring-boot章节来讲,我们对比了这么多SpringBoot相关的知识点,所以我们与其学习Spring的基础用法,不如直接过渡到Spring-boot中,相信我,在学习了以上内容之后,过度到Spring-boot是最好契机!