前言
Spring 的 IoC(控制反转) 和 DI(依赖注入) 是 Spring 框架最核心的基石。理解它们不仅是掌握 Spring 的前提,也是 Java 后端开发面试中的必考题。很多开发者对这两个概念的理解仅停留在"会用注解"的层面,一旦遇到循环依赖、AOP 失效或容器启动异常等问题便束手无策。
一、 核心概念解析
1. IoC (Inversion of Control) - 控制反转
IoC 是一种架构设计思想,而非具体的技术实现。
- 什么是"控制"?
在传统 Java SE 开发中,对象的创建、初始化、销毁以及对象间依赖关系的维护,都是由开发者在代码中通过new关键字主动控制的。这种"控制权"牢牢掌握在业务代码手中。 - 什么是"反转"?
Spring 将对象的创建权、生命周期管理权从开发者手中转移到了 IoC 容器 手中。开发者不再主动创建对象,而是被动地接收容器注入的对象。 - 为什么要反转?
- 解耦:类与类之间不再直接硬编码依赖,而是依赖于抽象(接口)和容器配置,修改实现类无需改动调用方代码。
- 可测试性:单元测试时可以轻松替换 Mock 对象,无需修改业务代码,也无需启动完整容器。
- 统一管理:集中管理对象的生命周期、事务、AOP 代理等横切关注点,避免资源泄漏和重复创建。
💡 核心总结
IoC 是目的,DI 是实现 IoC 的手段。没有 DI,IoC 只是一个空泛的概念;而除了 DI,IoC 理论上也可以通过服务定位器(Service Locator)模式实现,但 Spring 选择了更优雅的 DI,因为 DI 对单元测试更友好,且不引入对容器 API 的直接依赖。
2. DI (Dependency Injection) - 依赖注入
DI 是 IoC 的具体实现方式。它描述了容器如何将依赖关系"注入"到组件中。Spring 提供了三种主流注入方式,它们在工程实践中有着严格的适用边界,绝非可以随意互换:
| 注入方式 | 描述 | 优点 | 缺点 | Spring 推荐度 |
|---|---|---|---|---|
| 构造器注入 | 通过构造函数参数注入依赖 | ① 保证对象不可变且必需依赖不为 null② 方便单元测试(无需反射)③ 避免循环依赖暴露问题 | 参数过多时构造函数冗长 | ⭐⭐⭐⭐⭐ (官方首推) |
| Setter 注入 | 通过 setter 方法注入依赖 | 灵活,适合可选依赖 | ① 对象可能处于未完全初始化状态② 无法保证依赖不可变 | ⭐⭐⭐ (仅用于可选依赖) |
| 字段注入 | 直接在字段上使用 @Autowired |
代码最简洁 | ① 隐藏了依赖关系② 无法在单元测试中手动注入③ 容易忽略循环依赖风险 | ⭐ (不推荐,但广泛存在) |
⚠️ 为什么 Spring 官方强烈推荐构造器注入?
- 强制契约 :构造函数参数明确了该 Bean 正常工作所必须的依赖,缺少依赖时启动即报错,符合"快速失败"原则。
- Immutability :可以将依赖字段声明为
final,保证线程安全和状态稳定。- 容器无关性 :即使脱离 Spring 容器,也可以通过
new Service(dep)进行纯 Java 测试,无需任何 Mock 框架支持。
二、 Spring IoC 容器底层原理
1. 容器的两个核心接口
- BeanFactory :IoC 容器的根接口,定义了最基本的
getBean、containsBean等方法。它是 Spring 的底层基础设施,采用懒加载策略(首次 getBean 时才创建),通常不直接使用。 - ApplicationContext :BeanFactory 的子接口,是企业级开发的门面。它在 BeanFactory 基础上扩展了国际化、事件发布、资源访问、AOP 集成等企业级特性,并且默认预加载所有单例 Bean。预加载虽然增加了启动时间,但能在应用上线前暴露绝大多数配置错误和依赖缺失问题,这在生产环境中远比节省几秒启动时间重要。
2. BeanDefinition:一切配置的终极抽象
Spring 并不直接读取你的类文件来创建 Bean,而是先将所有配置源(XML <bean> 标签、@Component 扫描、@Bean 工厂方法)解析为 BeanDefinition 对象。
它是 Bean 的"元数据蓝图",包含了类名、作用域、是否懒加载、依赖列表、初始化/销毁方法等信息。容器后续的所有操作都是基于 BeanDefinition 进行的。这种设计实现了配置解析与 Bean 创建的彻底解耦,无论配置来源如何变化,容器内部的创建引擎只认 BeanDefinition,这也是 Spring 能够持续演进、支持多种配置范式的根本架构保障。
3. Bean 的生命周期(重中之重)
一个 Bean 从出生到死亡经历了极其复杂的流程,这也是面试考察的重灾区。标准单例 Bean 的生命周期如下:
- 实例化 (Instantiation):通过反射或 CGLIB 创建 Bean 的原始对象(此时属性为空)。
- 属性填充 (Populate Properties) :注入依赖(DI 发生在此阶段),处理
@Value、@Autowired等。这是循环依赖检测和处理的核心窗口期。 - Aware 接口回调 :如果 Bean 实现了
BeanNameAware、BeanClassLoaderAware、ApplicationContextAware等,容器会按固定顺序注入相应资源。 - BeanPostProcessor#postProcessBeforeInitialization :前置处理器,如
@PostConstruct注解的处理就在此处。 - 初始化 (Initialization) :按优先级执行
@PostConstruct→InitializingBean#afterPropertiesSet→ 自定义 init-method。 - BeanPostProcessor#postProcessAfterInitialization :后置处理器,AOP 代理通常在此阶段创建 。注意:你在初始化方法中看到的
this是原始对象,而容器中最终存储和对外提供的可能是代理对象。 - 就绪/使用:Bean 放入一级缓存(单例池),供应用程序使用。
- 销毁 (Destruction) :容器关闭时,逆序执行
@PreDestroy→DisposableBean#destroy→ 自定义 destroy-method。
⚠️ 重要警告
Prototype Bean 不会被容器管理销毁,容器只负责创建,不负责回收。如果 Prototype Bean 持有需要释放的资源(如文件句柄、网络连接),必须自行实现清理逻辑或使用
DestructionAwareBeanPostProcessor手动注册销毁回调。
三、 三级缓存解决循环依赖
Spring 通过三级缓存解决了单例 Bean 的 setter/字段注入循环依赖问题,这是一个在工程实用性与理论纯粹性之间做出的精妙妥协,也是面试必考的核心难点。
三级缓存的精确定义
- 一级缓存
singletonObjects:ConcurrentHashMap,存放完全初始化好的成品 Bean,是对外提供服务的最终数据源。 - 二级缓存
earlySingletonObjects:ConcurrentHashMap,存放提前暴露的半成品 Bean(已实例化但未填充属性),用于打破循环,保证同一个 Bean 的早期引用在容器中唯一。 - 三级缓存
singletonFactories:ConcurrentHashMap>,存放 Bean 的工厂对象,主要用于处理 AOP 代理。
为什么必须是三级?两级不行吗?
假设只有两级缓存,当 A 依赖 B、B 依赖 A,且 A 需要被 AOP 代理时,我们会陷入两难:
- 方案一:在实例化后立即无条件创建代理放入二级缓存。后果是所有 Bean 都会在早期被代理,违背了"AOP 应在初始化后创建"的生命周期规范。
- 方案二:放入原始对象。后果是 B 拿到的是原始 A,后续 A 被代理后,B 持有的引用与容器中的不一致,导致 AOP 增强失效。
三级缓存通过 ObjectFactory 延迟了代理创建决策:只有在真正发生循环依赖需要获取早期引用时,才触发工厂创建代理对象并放入二级缓存;如果没有循环依赖,AOP 代理依然在标准的后置处理阶段创建,保持了生命周期的正确性。
关键限制与破解之道
- 构造器循环依赖无法解决 :因为实例化就需要依赖,无法提前暴露半成品。可通过
@Lazy注入代理占位符破解,实际调用方法时才触发真实 Bean 的创建。 - Prototype 循环依赖无法解决 :Spring 明确不支持,会抛出
BeanCurrentlyInCreationException。因为 Prototype 语义是"每次新建",缓存机制与其本质冲突。解决方案是重构设计,或使用ObjectProvider<T>将循环依赖转化为运行时的按需获取。
四、 高阶知识点补充
1. FactoryBean vs BeanFactory
- BeanFactory:容器的顶层接口,管理所有 Bean。
- FactoryBean :一个特殊的 Bean,实现了该接口的 Bean 本身是一个"工厂"。当你
getBean("myFactory")时,返回的不是 FactoryBean 实例,而是其getObject()方法的返回值。常用于整合第三方框架(如 MyBatis 的 SqlSessionFactoryBean、Feign、Dubbo)。若要获取 FactoryBean 本身,需加&前缀:getBean("&myFactory")。
2. BeanFactoryPostProcessor vs BeanPostProcessor
两者名称相似但职责截然不同,混用会导致严重问题:
- BeanFactoryPostProcessor (BFPP) :作用于容器级别 ,在所有 Bean 实例化之前修改 BeanDefinition(如
PropertySourcesPlaceholderConfigurer处理${}占位符)。铁律:切勿在 BFPP 中尝试 getBean(),这会导致未预期的提前初始化,破坏生命周期顺序。 - BeanPostProcessor (BPP) :作用于Bean 级别,在每个 Bean 初始化前后进行拦截处理,是实现自定义注解、AOP 代理、性能监控的标准扩展点。
3. Singleton 注入 Prototype 的陷阱
当一个 Singleton Bean 注入了 Prototype Bean 时,Prototype 只会在 Singleton 创建时被注入一次,后续调用得到的始终是同一个实例,违背了 Prototype "每次新建" 的语义。解决方案按推荐度排序:
- ObjectProvider(首选) :注入
ObjectProvider<PrototypeBean>,每次调用getObject()获取新实例,类型安全、无侵入、语义清晰。 - @Lookup 注解 :在 Singleton 中定义抽象方法并标注
@Lookup,Spring 通过 CGLIB 重写该方法返回新实例。 - ApplicationContext.getBean():手动从容器获取,简单但引入了容器耦合,仅建议在无法使用前两种方式时使用。
4. Spring 容器启动核心流程
核心入口是 AbstractApplicationContext#refresh() 方法,包含 12 个关键步骤,其中最核心的三个是:
- invokeBeanFactoryPostProcessors():解析配置、处理占位符、注册 BeanDefinition。
- registerBeanPostProcessors():注册所有 BPP,为后续 Bean 创建做准备。
- finishBeanFactoryInitialization():实例化所有非懒加载单例 Bean,触发完整的生命周期流程。
五、 常见高频面试题
Q1: Spring 的 IoC 和 DI 有什么区别?
答:
- IoC 是设计原则,强调将对象的控制权交给外部容器,目的是解耦。
- DI 是实现模式,是 IoC 的一种具体落地方式,指容器在运行期间动态地将依赖关系注入到组件中。
- 类比:IoC 像是"外包公司管理模式"(理念),DI 像是"外包公司给项目组派人的具体流程"(执行)。除了 DI,IoC 还可以通过服务定位器模式实现,但 Spring 选择了 DI,因为 DI 对单元测试更友好,且不引入对容器 API 的依赖。
Q2: @Autowired 和 @Resource 的区别?
答:
| 维度 | @Autowired | @Resource |
|---|---|---|
| 来源 | Spring 特有注解 | JDK 标准注解 (JSR-250) |
| 匹配规则 | 默认按类型匹配,多个候选时按名称 | 默认按名称匹配,找不到再按类型 |
| 必填属性 | required=false 允许注入 null | 无此属性,找不到直接报错 |
| 支持位置 | 构造器、Setter、字段 | Setter、字段(不支持构造器) |
| 性能 | 需要遍历容器查找类型,稍慢 | 直接按名查找,较快 |
最佳实践 :推荐使用
@Autowired+@Qualifier组合,或者直接用构造器注入。若项目要求去 Spring 耦合,可用@Resource。
Q3: Spring 是如何解决循环依赖的?为什么需要三级缓存而不是两级?
答:
- 解决思路:提前暴露半成品 Bean。当 A 依赖 B,B 又依赖 A 时,A 实例化后立即将自己放入缓存,B 注入 A 时从缓存获取半成品,待 B 完成后 A 再继续完成自身初始化。
- 为什么需要第三级缓存?
为了正确处理 AOP 代理 。如果只有两级缓存,当 A 需要被代理时,我们必须在属性填充前就决定是存原始对象还是代理对象。三级缓存通过ObjectFactory延迟了这个决策:只有在真正发生循环依赖需要获取早期引用时,才触发工厂创建代理对象并放入二级缓存。如果没有循环依赖,AOP 代理仍然在正常的postProcessAfterInitialization阶段创建,保持了生命周期的规范性。
Q4: Spring Bean 的作用域有哪些?
答:
- singleton(默认):整个容器只有一个实例,线程不安全需注意。
- prototype:每次请求都创建新实例,容器不负责销毁。
- request:Web 环境有效,每个 HTTP 请求一个实例。
- session:Web 环境有效,每个 HTTP Session 一个实例。
- application:ServletContext 级别共享。
- websocket:WebSocket 会话级别。
注意 :Singleton Bean 注入 Prototype Bean 时,Prototype 不会每次都新建。解决方案:使用
@Lookup注解、ObjectProvider<T>、或ApplicationContext#getBean()手动获取。
Q5: Spring 容器启动过程是怎样的?
答:
核心入口是 AbstractApplicationContext#refresh() 方法,包含 12 个关键步骤:
- 准备刷新 (
prepareRefresh) - 获取 BeanFactory (
obtainFreshBeanFactory) - 准备 BeanFactory(设置类加载器、表达式解析器等)
- 后处理 BeanFactory (
postProcessBeanFactory) - 调用 BeanFactoryPostProcessor(如 PropertySourcesPlaceholderConfigurer 处理占位符)
- 注册 BeanPostProcessor
- 初始化消息源、事件广播器
- onRefresh()(子类扩展点,如 Spring Boot 在此启动内嵌 Tomcat)
- 注册事件监听器
- finishBeanFactoryInitialization()(核心:实例化所有非懒加载单例 Bean)
- finishRefresh()(发布 ContextRefreshedEvent)
- 注册 MBean / 完成刷新
Q6: 如何优雅地处理 Bean 的初始化和销毁?
答: 优先级从高到低:
- JSR-250 注解 :
@PostConstruct/@PreDestroy(推荐,标准且简洁) - 接口实现 :
InitializingBean/DisposableBean(Spring 特有,侵入性强) - XML/注解指定 :
init-method/destroy-method - BeanPostProcessor:全局拦截,适合框架级处理
执行顺序 :
@PostConstruct→afterPropertiesSet→init-method
Q7: Spring 的自动装配模式有哪些?
答:
在 XML 配置时代,Spring 提供了以下自动装配模式(autowire 属性):
- no:不自动装配,需显式 ref(默认值)。
- byName:根据属性名匹配 Bean ID,找不到则不注入。
- byType:根据属性类型匹配,多个候选则报错,零个则不注入。
- constructor:类似 byType,应用于构造函数参数。
- autodetect:已废弃,优先 constructor,回退 byType。
现代实践说明 :在现代 Spring 开发中,几乎全部使用注解驱动的自动装配(
@Autowired、@Resource),XML 时代的这些模式了解即可,面试时能说出历史演变和现代替代方案即可体现知识广度。
六、 学习建议
- 不要只背八股文:尝试自己手写一个简易 IoC 容器(支持注解扫描、Bean 创建、简单 DI、三级缓存),这是理解 Spring 最快的方式。
- 阅读源码技巧 :从
AnnotationConfigApplicationContext入手,断点打在refresh()方法,跟踪单例 Bean 的创建全流程。重点关注doCreateBean、getSingleton、populateBean三个方法。 - 关注版本差异:Spring 5 到 6 变化巨大,面试时注意区分版本特性,尤其是 Spring Boot 3 要求的 JDK 17+ 和 Jakarta EE 迁移。