如果你用过 Spring 做开发,八成听过这句话:IOC 就是控制反转,把对象的创建和依赖管理交给容器。背下来容易,可真被问到“Spring 容器启动时到底干了什么?”“为什么三级缓存能解决循环依赖?”“@Autowired 和构造器注入到底怎么选?”的时候,很多人又会含糊。这篇博客我不打算复读官方文档,而是从我自己读源码、调线上问题、面试别人和被面试的经历出发,把 Spring IOC 从设计思想到底层原理,再到能直接落地的使用策略完整过一遍。整篇会用大量例子和踩坑记录,适合刚把 Spring Boot 跑通的新手,也适合准备面试或者想系统梳理一遍的初中级开发。
1. 为什么说 IOC 是 Spring 的“地基”
1.1 一个最直观的改变:从主动 new 到被动接收
学习 IOC 之前,我们写业务代码最自然的方式是需要什么就 new 什么。比如一个下单服务要用到库存服务、用户服务、优惠券服务,通常会在构造函数里挨个 new 出来,或者用一个静态工厂统一创建。这样做的直接后果是:类与类之间产生了强耦合,单元测试不好写,想要替换一个实现得改源码,项目一大了以后对象之间的依赖关系就像一团乱麻。
IOC 把这种关系彻底换了个方向:你不再负责创建依赖对象,而是声明“我需要什么”,容器在启动或运行时把对应的实例送给你。比如下面这段代码:
@Service public class OrderService { private final InventoryService inventoryService; private final UserService userService; public OrderService(InventoryService inventoryService, UserService userService) { this.inventoryService = inventoryService; this.userService = userService; } }没有一行 new。OrderService 只关心自己要用哪两个接口,具体是谁实现的、怎么创建出来的,全部由 Spring 容器在启动时根据配置决定。这个转变看着简单,但它把“组装对象”这件事从业务代码中彻底剥离了,代码的可测试性、可维护性和可替换性都上了一个台阶。
1.2 IOC、DI 和 IOC 容器,三个词到底什么关系
很多初学者会把 IOC 和 DI 混为一谈,面试时也经常被追问。其实它们并不是同一个层级的概念:
- **IOC(Inversion of Control,控制反转)**是一种设计思想,它强调“控制权”从调用方转移到外部容器或框架。哪里体现反转?以前是类自己控制依赖对象的创建和生命周期,现在是容器统一控制,类的控制权反转给了容器。
- **DI(Dependency Injection,依赖注入)**是 IOC 的一种具体实现方式。容器在创建对象时,把对象依赖的属性、构造函数参数、setter 参数主动注入进去。Spring 最常用的依赖注入方式有构造器注入、setter 注入和字段注入。
- IOC 容器则是承载这套机制的运行环境。在 Spring 里,它就是 BeanFactory 和 ApplicationContext 以及它们背后的一整套 Bean 生命周期管理逻辑。
所以严谨一点描述:Spring 通过 DI 的方式实现了 IOC 思想,而最能体现这一点的产品形态就是 IOC 容器。理解了这三层关系,你再去看 Spring 的源码和文档,会发现很多困惑自动就消失了。
1.3 生活化类比:点餐与餐厅后厨
为了帮助没接触过容器的朋友快速建立画面感,我常用“点餐”来类比。传统写法是你想吃饭,得自己去买菜、洗菜、切菜、炒菜、洗碗,整套流程都由你做主,但代价是你离不开厨房。IOC 的做法是你走进餐厅,告诉服务员“我要一份宫保鸡丁”,后厨用什么食材、什么锅具、什么火候、什么时候上菜,都由餐厅的整体调度系统决定,你只负责拿到菜品并使用它。
在这个类比里,菜单就是 Bean 定义(BeanDefinition),后厨就是 BeanFactory,上菜顺序就是依赖关系的装配顺序,你作为顾客就是业务代码。这样设计的好处很明显:你可以随时换一家餐厅(换实现类),只要菜品口味和规格(接口约定)不变,你的用餐体验就完全不受影响。
2. 底层原理深度拆解:容器启动时到底发生了什么
2.1 BeanDefinition:一切实例化的“图纸”
我一直觉得,看 Spring 源码最先要认识的不是某个注解,而是BeanDefinition。它直译是“Bean 定义”,你可以把它想象成一张图纸。图纸上记录了创建一个 Bean 需要的全部信息:
- Bean 的类名(beanClassName)
- 作用域(scope,singleton 还是 prototype)
- 是否懒加载(lazyInit)
- 初始化方法和销毁方法(initMethodName、destroyMethodName)
- 构造参数、属性值、自动装配模式
- 是否是抽象类、是否是主候选 Bean 等
容器在启动时,会通过扫描 @Component、@Service、@Repository、@Controller,或者解析 XML 中的 标签,或者处理 @Bean 注解方法,把这些信息组装成一个个 BeanDefinition 放进注册表。真正实例化 Bean 的时候,Spring 并不会直接 new,而是先查图纸,根据图纸上的类名反射创建对象,再按图纸上的属性配置完成赋值。
理解了BeanDefinition,很多面试题就比较通透了。比如“@Component 和 @Bean 有什么区别”,本质区别就在于 BeanDefinition 的来源不同:@Component 元注解通过 classpath 扫描被解析成BeanDefinition,而 @Bean 是通过 @Configuration 类里的方法被解析成BeanDefinition,两种方式最终都会统一到 BeanDefinition 这个标准格式上。
2.2 BeanFactory 与 ApplicationContext:两代容器
Spring 里有两个核心接口,初学者经常搞混:BeanFactory 和 ApplicationContext。
BeanFactory 是最底层的容器接口,定义了 getBean、containsBean、isSingleton 等基础方法,使用起来非常“朴素”。而 ApplicationContext 在 BeanFactory 之上做了大量增强:
- 支持国际化消息(MessageSource)
- 支持资源加载(ResourceLoader)
- 支持事件发布与监听(ApplicationEventPublisher)
- 自动注册 BeanFactoryPostProcessor、BeanPostProcessor
- 内置 Web 环境相关的能力
在 Spring Boot 项目中,ApplicationContext 是默认容器。我们经常在启动日志里看到那些“Tomcat started on port 8080”之类的信息,背后就是容器在完成环境准备、Bean 创建、内嵌服务器启动等一串操作。但如果你想更纯粹地研究 Bean 创建的源码,直接看 DefaultListableBeanFactory 和 AbstractAutowireCapableBeanFactory 这一条线会更清晰,ApplicationContext 最终也会委托给这些实现。
提示:日常开发不用直接操作 BeanFactory,但排查问题时要能分清楚,报错信息里的
NoSuchBeanDefinitionException、BeanCurrentlyInCreationException都是在容器创建和查找阶段出现的。
2.3 创建 Bean 的核心流程:实例化、填充、初始化
一个普通单例 Bean 从“图纸”变成“成品对象”,大致要经过下面几个阶段:
- 实例化(Instantiation):Spring 根据 BeanDefinition 里的类信息,通过反射调用构造器创建对象。此刻对象还是半成品,内部属性基本是默认值。
- 填充属性(Populate):Spring 拿到实例化出来的半成品对象,开始处理依赖注入。构造器参数、@Autowired 字段、@Value 配置值,都在这个阶段装配进去。
- 初始化(Initialization):属性装配完成后,容器会执行各种初始化逻辑。比如执行 BeanNameAware、BeanFactoryAware 等 Aware 回调,执行 BeanPostProcessor 的前置和后置方法,最后调用标注了 @PostConstruct 的方法或配置的 initMethod。
- 使用与销毁:初始化完成的对象被放入单例池(一级缓存),业务代码 getBean 时直接拿到成品。容器关闭时,再依次执行 @PreDestroy 标注的方法或 destroyMethod 完成资源释放。
很多人记不住 Aware、BeanPostProcessor 这些名字,其实它们就是 Spring 故意留出来的“扩展钩子”。你可以把 Bean 创建过程想象成一条汽车生产线:车架进来(实例化),装座椅和轮胎(填充属性),做整车检测(BeanPostProcessor),最后出厂交付(进入单例池)。这条生产线上的每个点位都可以插入自定义操作,BeanPostProcessor 就是那个允许你自己加装改造步骤的位置。
2.4 三级缓存与循环依赖:面试必问的底层机制
循环依赖是 Spring 面试中躲不开的话题,也是理解 IOC 底层最典型的例子。所谓循环依赖,就是 A 依赖 B,B 又依赖 A。比如下面的代码,如果让你手动管理,你几乎无法正常 new 出来:A 的构造器需要 B,B 的构造器又需要 A,谁先创建都会卡住。
@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; } }这里要说明一个细节:Spring 默认只对“单例、非构造器注入、非懒加载”的 Bean 解决循环依赖,上面这种构造器注入方式是解决不了的。所以新项目我强烈建议优先使用构造器注入,从根上避免循环依赖写入代码。
那 Spring 是怎么用三级缓存解决 setter 注入的循环依赖的?核心在DefaultSingletonBeanRegistry里那三个 Map:
- 一级缓存(singletonObjects):存放已经完整创建好的单例 Bean。
- 二级缓存(earlySingletonObjects):存放提前暴露出来的“早期引用”,即对象已经实例化但属性还没填充完的半成品。
- 三级缓存(singletonFactories):存放 ObjectFactory,也就是一个可以生成早期引用对象的工厂。
拿 A 和 B 互相依赖举例,流程是这样的:容器开始创建 A,A 实例化完成但还没有填充 B 的时候,Spring 先把一个 ObjectFactory 放入三级缓存,这个工厂能提前暴露 A 的半成品引用。接着 A 开始填充属性,发现需要 B,于是转去创建 B。B 实例化完成后开始填充属性,发现需要 A,此时 B 去三级缓存中找到 A 对应的 ObjectFactory,调用 getEarlyBeanReference 方法拿到 A 的早期引用,放进二级缓存,并把 A 这个半成品注入到 B 中。B 顺利创建完成,放入一级缓存。之后 A 继续完成剩余属性填充和初始化,最终也放入一级缓存。
为什么二级缓存看起来已经能存“半成品”了,还要三级缓存?关键在于AOP 代理的生成时机。如果一个 Bean 需要被 AOP 增强,容器创建出的最终对象其实是一个代理对象,而不是原始实例。如果直接存原始半成品在二级缓存,B 拿到的是未增强的对象,AOP 就失效了。三级缓存里保存的 ObjectFactory 可以保证在“提前暴露”这个时间点就生成正确的代理对象,这样注入到 B 里的依然是可以正常拦截的方法。这种设计把创建完整性和 AOP 代理的冲突平衡得非常好,值得反复琢磨。
3. 实战应用:配置、注入与生命周期控制
3.1 Java Config 与注解时代的最佳姿势
如果是 Spring Boot 项目,从启动类往下看,最常见的配置方式是三类:
- 组件扫描:@ComponentScan 指定扫描包路径,配合 @Component、@Service、@Repository、@Controller 自动注册 Bean。
- @Configuration + @Bean:在配置类中通过方法定义 Bean,适合组装第三方库、请求对象、数据源等无法直接加注解的类。
- @Import / @ImportResource:把分散的配置集中引入,Spring Boot 自动配置大量使用了 @Import。
我自己的习惯是:自家业务类尽量走组件扫描,用构造器注入;第三方接入和复杂对象组装统一放在 @Configuration 类中。举个例子,接入一个 Redis 连接池或者其他中间件客户端时,通常这样定义:
@Configuration public class ExternalClientConfig { @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { return builder .setConnectTimeout(Duration.ofSeconds(3)) .setReadTimeout(Duration.ofSeconds(10)) .build(); } }这里 RestTemplate 本身是第三方类,没法给它加 @Component,@Bean 就成了最合理的方式。方法参数里传入 RestTemplateBuilder 也正是依赖注入,Spring 会自动从容器中找到这个对象并传给方法。
3.2 三种注入方式怎么选:构造器、Setter 还是 Field
网上关于注入方式的争论很多,我把自己在团队里的规范分享一下。
| 注入方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 字段注入 | @Autowired private UserService userService; | 代码少,看着直观 | 依赖对外不可见;无法用 final 修饰;和容器强耦合;单元测试不方便;容易写成循环依赖 |
| Setter 注入 | @Autowired public void setUserService(UserService userService) | 可选依赖友好;可以重新赋值 | 注入后对象可能处于不全状态 |
| 构造器注入 | private final UserService userService; public OrderService(UserService userService) | 不可变、依赖清晰;测试可以直接 new;强制依赖完整 | 构造器参数多时会显得代码长 |
我的结论很明确:如果依赖是强制的,优先构造器注入。这样每个 Bean 创建出来就是完整可用的状态,而且因为 final 字段的存在,业务代码里想偷偷替换依赖都做不到,反而让代码更稳。字段注入只适合做快速原型或者某些框架强制要求的场景,日常业务代码我基本不用。
3.3 Bean 的作用域与生命周期钩子
Spring Bean 默认是 singleton(容器内单例),这意味着容器只会创建一个实例,所有注入点拿到的都是同一个对象。这个设计能节省大量内存和创建开销,但也有隐患:如果你把带状态的对象声明为单例,多线程下就可能出现数据错乱。
常用的作用域还包括:
- prototype:每次获取都创建新实例,适合无状态或临时对象。
- request / session / application:仅限 Web 环境使用,对应一次请求、一次会话和应用级共享。
有些同学遇到过这样的场景:一个单例 Service 依赖一个 prototype 的 Helper,结果每次请求拿到的是同一个 Helper,根本没起到“每次新建”的效果。问题根源在于单例 Bean 在创建时只注入一次依赖,后续不会重新注入。解决办法有几个:直接注入ObjectProvider<Helper>,每次需要时再getObject();或者通过@Lookup方法获取新实例;也可以在 @Scope 上配置proxyMode = ScopedProxyMode.TARGET_CLASS,让 Spring 生成代理对象,每次调用方法时再真正获取。
生命周期钩子方面,@PostConstruct 和 @PreDestroy 是最常用的。@PostConstruct 在依赖注入完成后执行,适合做资历校验、预热缓存、开启定时任务;@PreDestroy 在容器销毁前执行,适合释放连接、取消任务。还有一种方式是让 Bean 实现 InitializingBean、DisposableBean 接口,或者通过 XML/注解配置 initMethod、destroyMethod,但这些写法不如 JSR 250 规范简洁直观,我只有在处理第三方库时才用后两种。
3.4 条件装配与 Profile:拒绝硬编码开关
业务开发多了以后你会发现,测试环境和生产环境需要的配置往往不一样,某些功能模块只应在特定环境启用。Spring 提供了两种非常实用的手段:
- @Profile:按环境开关装配。比如用
@Profile("dev")注册一个内存数据库,用@Profile("prod")注册一个正式数据源,启动时通过 Spring Boot 的spring.profiles.active指定环境即可。 - @Conditional / @ConditionalOnProperty / @ConditionalOnClass:按条件装配。比如某个功能只有在配置了
feature.enabled=true时才注入 Bean,或者在 classpath 中存在某个类时才自动配置。
这两种方式表面上只是“条件判断”,实际上节省了大量重复代码,以前常写的if分支创建对象的逻辑,现在全部可以下沉到容器装配层完成。代码里不再出现if (env == "prod")这种硬编码,逻辑重心也更聚焦在业务本身。
4. 高频问题排查与经验笔记
4.1 为什么我的 Bean 是 null
这是新手上路最常见的问题,现象是运行时某个字段直接抛 NullPointerException。排查思路一般按下面几步走:
- 看是否被 Spring 管理:检查类上有没有 @Component 或 @Service 等注解,也可以看启动类 @ComponentScan 的包路径是否覆盖到了该类。
- 看注入点写没写错:字段上有没有 @Autowired/@Resource,构造器是否被正确识别。如果同时自定义了带参构造器又没有无参构造器,要确认是不是参数注入。
- 看是否 new 过对象:如果代码里用了
new UserService(),那这个对象是手动创建的,Spring 容器完全不参与,内部所有注入自然全是 null。 - 看作用域:如果 Bean 作用域是 prototype,获取新实例时要注意每次拿到的对象不是同一个。
- 看启动日志:有没有
NoSuchBeanDefinitionException、NoUniqueBeanDefinitionException之类的关键报错,它们会直接给出是哪个类型没找到或者有多个候选。
有一次我帮同事排查,找了大半天才发现问题出在代码里用了@Transactional但该类没有交给 Spring 管理,导致事务代理根本没生效,最终表现为某个依赖注入为 null。所以这类问题别急着看字段,先把“对象是不是容器创建的”这条根因捋清楚。
4.2 prototype 作用域失效的场景,以及为什么我给团队定的规矩是不用循环依赖
网上经常有“Spring 中 singleton 依赖 prototype 时注入失效”的文章,这类问题本质上不是 Spring 失效,而是我们的使用方式不符合容器的生命周期规则。单例 Bean 在创建时就被固定了依赖关系,后续无论调用多少次方法,字段里的 prototype Bean 都不会再变。如果你确定需要“每次调用拿新实例”,建议在方法内部显式获取。
更重要的教训是循环依赖。Spring 能解决 setter 注入的循环依赖,这给了不少人“顺手写一写”的底气。但循环依赖往往意味着设计上的味道不对,比如 A 和 B 的职责边界不清楚、应该拆分的组件没拆分。和团队一起做 code review 时,我见到过 4 个 Service 互相循环引用的代码,最后重构后变成两层清晰的依赖关系,代码量还少了。所以我的建议是:默认开启 Spring 的循环依赖检测,把解决循环依赖当作兜底方案,而不是日常手段。
4.3 启动慢、Bean 太多怎么定位
Spring Boot 项目启动慢,常见原因包括自动配置扫描范围过大、初始化阶段加载了外部资源、多个 BeanPostProcessor 串行执行耗时等。排查时可以先用--debug启动查看自动配置报告,明确哪些自动配置生效了;然后通过 actuator 的 beans 端点查看容器里注册了哪些 Bean,排除“误扫描”进来的组件。actuator 端点在生产环境要格外注意访问控制,别为了省事直接暴露全部端点,建议只开放自己真正需要的几个,并通过管理端口和权限做隔离。
另外,懒加载 @Lazy 可以缓解部分启动峰值压力,但注意它只是把 Bean 创建延后到第一次使用时,如果某个 Bean 确实有初始化失败风险,懒加载会让错误暴露得更晚。所以 @Lazy 适合明确知道“这个对象很重、又不影响启动核心链路”的场景。
4.4 曾经自己手写过一个简化版 IOC 容器
在还不完全理解 Spring 的某个阶段,我抽了一个周末,把手写 Spring IOC 当成练习。整个过程非常有用:过程就是维护一个 Map<String, Object> 存单例 Bean,再维护一个 Map<String, BeanDefinition> 存类和配置信息。扫描某个包下的所有类,把标记了自定义 @Component 的类注册进 BeanDefinition,然后由容器统一创建对象;创建时遍历所有字段,看到自定义 @Autowired 注解就递归查询依赖。初始版本只有几十行,但每写一行都能加深对反射和容器生命周期的理解。
写完后我反而不太建议生产环境自己去造“轻量 IOC 容器”轮子——Spring 已经提供了完善的生命周期管理和扩展机制,手写更适合作为学习的进阶路径。给自己设定一个目标,比如不依赖 Spring 只靠 JDK 的反射实现一个能完成依赖注入的小框架,可以帮你把平时读源码时吞下的知识真正消化掉。
5. 扩展:Spring AI 等新模块里的 IOC 身影
5.1 模型对象也是一种 Bean
最近 Spring AI 是社区里的热门方向。从架构上看,它依然延续了 Spring 一贯的思路:把模型、向量库、工具调用、Agent 等概念都抽象成可装配的对象,交给容器管理。你可以像注入一个普通 Service 一样,在业务类里注入模型或者 ChatClient 的接入对象。只要底层实现符合接口约定,你随时可以在配置里切换不同的模型服务,业务代码不需要大改。
这其实就是 IOC 思想的又一次胜利:无论是数据库、消息队列还是大模型,对业务代码来说都只是“需要的一个能力”,具体能力来自哪里、怎么初始化,交给容器和配置去处理。Spring AI 2.0 这样快速的迭代方向,让“AI 应用开发”和“Spring 传统开发”之间的学习曲线被压得很低,背后的一个很大原因正在于容器抽象统一了各类组件的接入方式。
5.2 用 IOC 思路组织 AI 功能模块
如果你准备在 Spring Boot 项目里接入 Spring AI,我建议保持一致的模块划分习惯。把模型调用、提示词模板、工具函数分别定义成独立的 Bean,再用配置类统一组装。比如一个 ChatClient 连接类,可以在 @Configuration 里通过 @Bean 组装;和外部服务有关的密钥放在配置中心;业务层通过构造器注入这些客户端对象。这样即便 Spring AI 的 API 版本发生调整,需要改的地方也相对集中在一个接入层,不会影响核心业务代码。
这也解释了为什么 Spring 官方在持续把 AI 能力“原生”地接入整个框架体系,而不是让大家在一个工具类里写一堆静态方法。因为静态方法面向实现,而容器面向接口和协作。只要保持这种思路,无论是 Spring MVC、Spring Security、Spring Boot 还是 Spring AI,学习新模块时的核心理解成本都会低很多。
我个人在团队里带新人的时候,最常用的一句话是:Spring 就是一个帮你管对象的框架,IOC 是它的灵魂。把“对象怎么创建、什么时候创建、怎么注入给谁”这件事想透了,再看 Spring Boot 的自动配置、Spring Security 的过滤器链、Spring AOP 的代理机制,都会有一种豁然开朗的感觉。如果这篇文章能帮你建立起对 IOC 的整体认知,后面的路会顺畅很多。