
如果你用过 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 混为一谈面试时也经常被追问。其实它们并不是同一个层级的概念**IOCInversion of Control控制反转**是一种设计思想它强调“控制权”从调用方转移到外部容器或框架。哪里体现反转以前是类自己控制依赖对象的创建和生命周期现在是容器统一控制类的控制权反转给了容器。**DIDependency 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作用域scopesingleton 还是 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 从“图纸”变成“成品对象”大致要经过下面几个阶段实例化InstantiationSpring 根据 BeanDefinition 里的类信息通过反射调用构造器创建对象。此刻对象还是半成品内部属性基本是默认值。填充属性PopulateSpring 拿到实例化出来的半成品对象开始处理依赖注入。构造器参数、Autowired 字段、Value 配置值都在这个阶段装配进去。初始化Initialization属性装配完成后容器会执行各种初始化逻辑。比如执行 BeanNameAware、BeanFactoryAware 等 Aware 回调执行 BeanPostProcessor 的前置和后置方法最后调用标注了 PostConstruct 的方法或配置的 initMethod。使用与销毁初始化完成的对象被放入单例池一级缓存业务代码 getBean 时直接拿到成品。容器关闭时再依次执行 PreDestroy 标注的方法或 destroyMethod 完成资源释放。很多人记不住 Aware、BeanPostProcessor 这些名字其实它们就是 Spring 故意留出来的“扩展钩子”。你可以把 Bean 创建过程想象成一条汽车生产线车架进来实例化装座椅和轮胎填充属性做整车检测BeanPostProcessor最后出厂交付进入单例池。这条生产线上的每个点位都可以插入自定义操作BeanPostProcessor 就是那个允许你自己加装改造步骤的位置。2.4 三级缓存与循环依赖面试必问的底层机制循环依赖是 Spring 面试中躲不开的话题也是理解 IOC 底层最典型的例子。所谓循环依赖就是 A 依赖 BB 又依赖 A。比如下面的代码如果让你手动管理你几乎无法正常 new 出来A 的构造器需要 BB 的构造器又需要 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 互相依赖举例流程是这样的容器开始创建 AA 实例化完成但还没有填充 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 本身是第三方类没法给它加 ComponentBean 就成了最合理的方式。方法参数里传入 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 在创建时只注入一次依赖后续不会重新注入。解决办法有几个直接注入ObjectProviderHelper每次需要时再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.enabledtrue时才注入 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 当成练习。整个过程非常有用过程就是维护一个 MapString, Object 存单例 Bean再维护一个 MapString, 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 的整体认知后面的路会顺畅很多。