IoC 与 DI 彻底讲透:控制反转到底反转了什么
这一篇是整个 Spring 内核的观念转折点,前面五篇教的都是「怎么写」,这里要改的是「谁来做决定」。
先说人话:你写的每一个类,都需要别的类陪它干活(订单服务需要库存仓库、支付客户端、邮件发送器)。问题从来不是「怎么协作」,而是「谁来准备这些伙伴」。传统写法是你自己在代码里 new 出来;Spring 的写法是你只写一句「我要一个库存仓库」,剩下的交给一个专门管对象的大管家——容器(就是 ApplicationContext 这个对象,你的应用启动时它会先把所有 Bean 造好、装配好、摆在架子上等你取)。
点外卖。你不需要会做菜,也不需要知道哪口锅归谁、几点生火——在 App 上下单「一份番茄炒蛋」,菜就端到面前。自己 new 依赖,等于每吃一顿都从买菜、洗锅、热油开始;交给容器,你只保留「说清楚我要什么」。反转的从来不是代码量,而是谁主动:以前是你的代码主动出门找依赖,现在是容器主动把依赖送到你手上。

学完这一篇,你应该能回答三个问题:
- IoC 反转的具体是哪三件事?DI 和 DIP 又各自站在哪个位置?
- 构造器注入、setter 注入、字段注入差在哪一步时序上?为什么官方首选构造器?
- A↔B 互相依赖时,为什么字段注入能救、构造器注入只能报错?
先看一段你觉得眼熟、但一改需求就爆炸的代码:
package com.example.order;import com.example.pay.AlipayClient;import com.example.stock.MysqlStockRepository;import com.example.mail.SmtpMailSender;public class OrderService { // 依赖被"焊死"在三处 new 里 private final MysqlStockRepository stockRepo = new MysqlStockRepository("jdbc:mysql://..."); private final AlipayClient alipayClient = new AlipayClient("APP_ID", "PRIVATE_KEY"); private final SmtpMailSender mailSender = new SmtpMailSender("smtp.example.com", 465); public void placeOrder(Long userId, Long productId, int count) { stockRepo.deduct(productId, count); alipayClient.pay(userId, productId, count); mailSender.send(userId, "订单已支付"); }}这段代码"能跑",却把三个具体实现死死焊在了业务逻辑上。改一处,坏三处:
- 想换成微信支付?得改
OrderService的字段和所有相关调用 - 单元测试想用假的库存仓库?做不到——
new MysqlStockRepository(...)在构造对象时就执行了,一测就得连真实数据库 - 想让库存服务支持分库分表?又得回来动业务类
根因不是"代码写得丑",而是"谁来决定依赖的实现"这件事被搞反了。OrderService 既负责"处理订单业务",又负责"决定用什么库存、什么支付、什么邮件"——两个职责挤在一个类里,于是任何一方的变化都会波及另一方。

"控制反转(IoC)"四个字听起来玄乎,其实可以拆成三件具体的事:
| 反转的是谁 | 反转前 | 反转后 |
|---|---|---|
| 谁负责创建对象 | 业务类自己 new | 容器统一创建 |
| 谁负责装配依赖 | 业务类自己挑实现、自己赋值 | 容器按声明把依赖塞进来 |
| 谁负责生命周期 | 想用就 new,用完等 GC | 容器管创建、初始化、销毁、单例缓存 |
三者的共同点是:创建与装配的主动权,从"使用者"转移到了"外部容器"。业务类从"导演 + 演员"退化成"只演戏的演员"——它不再关心搭档从哪来,只在需要时声明"我要一个库存仓库"。
IoC 不是某个具体技术,而是一类设计思想。Spring 之所以叫"容器",正是因为它把这三件事都接了过去:你手里拿到接口就能干活,具体实现由容器在运行时决定。
IoC 是目标,依赖注入(DI)是达成它的主要手段。同一个依赖,有三种注入写法:
@Servicepublic class OrderService { // ① 构造器注入(推荐) private final StockRepository stockRepo; private final PayClient payClient; public OrderService(StockRepository stockRepo, PayClient payClient) { this.stockRepo = stockRepo; this.payClient = payClient; }}@Servicepublic class OrderService { // ② Setter 注入 private StockRepository stockRepo; @Autowired public void setStockRepo(StockRepository stockRepo) { this.stockRepo = stockRepo; }}@Servicepublic class OrderService { // ③ 字段注入(最省事,也最不推荐) @Autowired private StockRepository stockRepo;}| 维度 | 构造器注入 | Setter 注入 | 字段注入 |
|---|---|---|---|
| 可测试性 | 直接 new 传 Mock,无需容器 | 需先构造再 set | 必须靠反射或容器才能注入 |
| 不可变性 | final 字段,建后不变 | 可变 | 可变 |
| 循环依赖 | 立刻暴露,启动失败 | 可借助提前暴露破环 | 依赖容器容错 |
| 官方推荐 | 推荐 | 可选依赖时用 | 不推荐 |
| 缺失依赖 | 编译期就报错 | 运行期才发现 | 运行期才发现 |
Spring 官方文档明确建议——必需的依赖用构造器注入,可选的依赖用 setter 注入,字段注入只用于最简单的场景,甚至完全不用。原因很实在:构造器注入让"依赖不完整"的对象根本无法被创建出来。
面试常追问:"IoC 和 DIP 到底是什么关系?"答案其实很清晰:DIP 是设计原则,IoC 是实现该原则的手段之一。
// ✗ 违反 DIP:高层业务直接依赖低层细节public class OrderService { private final AlipayClient alipayClient = new AlipayClient(); // 依赖具体实现}// ✓ 满足 DIP:双方都依赖抽象public class OrderService { private final PayClient payClient; // 只认接口 public OrderService(PayClient payClient) { this.payClient = payClient; }}DIP 有两条"倒置":
- 高层模块不应依赖低层模块,二者都应依赖抽象。
OrderService(高层)不该盯着AlipayClient(低层),而应面向PayClient接口。 - 抽象不应依赖细节,细节应依赖抽象。
PayClient接口不该知道支付宝的签名细节;恰恰相反,是AlipayClient去实现接口。
所以关系链是:DIP 说"依赖抽象"→ DI 提供"把抽象的具体实现注入进去"的机制 → IoC 容器负责"在运行时决定注入哪个实现"。一句话:原则定方向,容器干脏活。
new 本身没有错。判断是否违反 DIP 的标准不是"有没有 new",而是"高层模块是否依赖了具体实现类型"。在工厂方法或配置类里 new 一个具体实现,恰恰是把依赖方向掉转回来的正确做法。

"Don't call us, we'll call you"是好莱坞选角导演的名言:演员别天天打电话问有没有角色,展现好你的能力,角色来了自然会联系你。IoC 容器正是那个导演。上面那张动图给了全景,下面这六格则能一格一格点——尤其第 ③ 格,它解释了一件很多人从没想过的事:创建顺序不是你写的。
对比"服务定位器"模式——业务代码主动 ctx.getBean(...) 去要东西,那是"我打电话找导演",查找逻辑耦合在业务类里;而 DI 是"导演来找我",业务类完全不知道容器的存在。主动查找 vs 被动接收,这就是控制反转最直观的体现。
很多人以为 DI 只是"少写几个 new",其实容器替你做的事远不止于此:
| 能力 | 自己实现要写的代码 | 容器提供 |
|---|---|---|
| 单例管理 | 手写双重检查锁 + 静态字段 | scope="singleton" 一步到位 |
| 依赖解析 | 手动拓扑排序,处理创建顺序 | 自动解析依赖图并排序 |
| 生命周期回调 | 自己约定 init/destroy 方法并在合适时机调用 | @PostConstruct / @PreDestroy |
| 条件装配 | 手写 if/else 判断环境 | @Conditional 系列注解 |
| AOP 集成 | 手写 JDK / CGLIB 代理包装 | 声明 @Aspect 即生效 |
| 配置注入 | 手动读配置文件并解析 | @Value / @ConfigurationProperties |
这张表的意义不是"容器多厉害",而是让你在评估"要不要上容器"时有依据。当你的需求正好落在表里,容器就是在帮你省掉成百上千行胶水代码。
容器不是万能药,它的能力都建立在"对象需要被统一管理"这个前提上。以下场景硬上容器反而是负担:
// 一个 30 行的数据清洗脚本:为它搭一个 ApplicationContext 纯属自找麻烦public class CsvOneOff { public static void main(String[] args) throws Exception { try (var reader = Files.newBufferedReader(Path.of(args[0]))) { reader.lines() .filter(line -> !line.startsWith("#")) .forEach(System.out::println); } }}- 一次性任务、CLI 小工具、单个
main跑完就退出的脚本——启动容器的开销和心智成本都不划算 - 纯算法 / 纯函数库:没有依赖需要装配,容器没有插手的地方
- 需要极致启动速度的极简函数计算:容器的反射与扫描会拖慢冷启动
总结一句话:容器解决的是"对象之间如何组装"的问题。当你的代码里根本没有"多个需要协作的对象"时,就没有 IoC 可反转。
下面这个演示展示依赖注入在容器里真实发生的过程。切换条件开关,观察装配链的连锁变化(它是内核实验;第十一节那个才是可改参数、立刻看结果的沙盘):
把四种写法放到同一个坐标系里(横轴:好不好测;纵轴:依赖定下来之后还能不能改),选择就变得直观了。这里的单元测试指不启动整个应用、只用 new 就能跑起来的小测试;Mock 指你临时造的「假依赖」,用来顶替真实数据库或支付接口。

四种写法各站在哪个象限,图上已经一目了然。麻烦的是另一半:每种写法各自会把你怎么着。这种事背表背不住,来玩一局——先点左边的写法,再点你认为的后果,配错了当场告诉你为什么。
三种写法的装配时序完全不同,这正是「字段注入的对象为什么能带着 null 出生」的答案。第一个实验看最干净的构造器注入:依赖是构造参数,对象诞生那一刻就完整。
第二个实验切到字段注入,同一批 Bean、同一条依赖链,装配时机挪到了实例化之后。看到「实例已创建」和「依赖已注入」之间出现一段空隙,你就理解了第十节所有坑的来源。
第三个实验是最有价值的一格:A 要 B、B 也要 A,而且用的是构造器注入。你会看到容器在第四步直接放弃——因为此时连一个「半成品 A」都还不存在,没有任何引用可以借出去。
DI 就像组装电脑。主板说明书只写「这里要插一张显卡,接口 PCIe 5.0」,它不关心你买哪个牌子;只要接口对得上,A 卡 N 卡都能点亮。你的类就是那块主板:构造器参数是插槽规格,容器是那个帮你把卡插好的人。插槽规格写得越死(final + 构造器),装机出错就越早被发现——线没插紧却在开机后才冒烟的机器,没人敢用。
循环依赖指两个或多个对象互相需要:A 里有 B,B 里又有 A。三级缓存是容器解环用的三个 Map,其中最关键的是第三级 singletonFactories——它存的不是对象本体,而是一张「取货单」:一个能在别人急需时先交出半成品引用的工厂。

动画的第 2 步与第 3 步是全部关键:实例化(反射调构造器,得到一个空壳对象)发生在装配(往里填依赖)之前。字段注入和 setter 注入都属于「先有空壳、后填内容」,所以空壳在第 3 步就能被挂出去救急;构造器注入要求「内容就是入参」,空壳根本不存在,也就无从可借。
开与不开三级缓存,差别就在这两段日志上:
| 组合 | 结果 | 为什么 |
|---|---|---|
| 单例 + 字段/setter 注入 | 能启动 | 实例化后立刻有半成品,取货单能兑 |
| 单例 + 构造器注入 | 启动即失败 | 还没有任何引用可交出去 |
| prototype + 任意注入 | 一律失败 | 原型从不进单例池,压根没有仓库可提前借 |
Spring Boot 2.6 起 spring.main.allow-circular-references 默认为 false,也就是说即使是三级缓存本来能解的环,也会在启动时被提前拒绝。别急着把它改回 true——那只是允许坏结构继续存在,不是新的解法。真正的修法是把公共部分抽成第三个 Bean,或者在注入点加 @Lazy(详见第 10 篇)。
前九节都在讲「怎么把依赖送进来」,还有一件事必须分清:同一个类型,容器给你一个实例还是多个。scope(作用域)就是这个开关——singleton(默认,全容器一份)或 prototype(每次索取新建一份)。
先看清一个 Bean 从出生到销毁要过几站,@PostConstruct(初始化回调)与 @PreDestroy(销毁回调)分别落在哪一站:
把范围切成原型,你会发现最后一站不再执行:容器把对象交出去之后就撒手不管了。
但上面两个实验只走了 singleton 与 prototype 两档。真实项目里还有 request / session 两档,外加两个专职坑:prototype 的销毁回调根本不执行、request 作用域的 Bean 注入进单例就废了。下面这个实验把「此刻容器里到底有几个活着的对象」做成一个计数器,逐次索取,数字当场给你看:
现在回答小白最容易懵的那个问题:同一个 UserService 我调用两次 getBean,拿到的是同一个对象吗? 左边改 scope 与 @Lazy,右边立刻给出 hashCode 的形状和结论。
getBean #1 -> UserService@7a3b19getBean #2 -> UserService@7a3b19== 比较:truesingletonObjects: {userService=UserService@7a3b19}# 启动阶段就创建好,第一次取用零耗时
沙盘里那句 UserService@7a3b19 就是 toString() 打出的 hashCode 十六进制形状——同一个哈希 = 同一个对象。以后判断「我到底拿到的是不是同一份」,最快的办法就是在两个地方各打一行 hashCode,而不是靠猜。
沙盘看的是「两种写法各给什么结果」,而下面这台控制台给你的是容器此刻的真实账本:命令发进浏览器里那个真容器,回显由它算出来。先 boot 再 beans,然后逐条把本篇结论敲一遍:
beans 与 di 的差别值得记住——beans 报的是「容器里有谁」(名字、scope、是否懒加载),di 报的是「谁靠谁活着」。第十二节那些报错,九成能用这两条命令各敲一次定位到。
下面每一行的「报错原文」都可以整段粘进搜索框,别意译、别缩写:
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
java.lang.NullPointerException: Cannot invoke "com.example.StockRepository.deduct(java.lang.Long, int)" because "this.stockRepo" is null | 你手上的对象是自己 new 出来的,不在容器里,@Autowired 字段永远不会被填 | 改成从容器取:ctx.getBean(OrderService.class);需要断点验证就在构造后打印 stockRepo 是否为 null | 本篇第一、五节 · 第 5 篇 |
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.OrderService' available | 容器里没有这个类型的 Bean:类忘标 @Service/@Component,或它的包不在扫描路径内 | 先看类上有无组件注解,再看主类是否在它的父包;都对了就打一行 getBeanNamesForType(OrderService.class) 确认 | 第 5 篇 · 第 7 篇 |
org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.PayClient' available: expected single matching bean but found 2: alipayClient,wechatPayClient | 同一接口有两个实现都被扫进来了,容器不知道该给你哪个 | 在注入点用 @Qualifier("alipayClient") 点名,或在默认实现上标 @Primary | 本篇第九节象限图 · 第 7 篇 |
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference? | A↔B 互相等待,而且用的是构造器注入(或 scope 是 prototype),三级缓存没有半成品可借 | 先读那句 The dependencies of some of the beans in the application context form a cycle 下面的环图,再把构造器改成字段/setter,或抽出公共依赖 C | 本篇第十节 · 第 10 篇 |
@Autowired 标在 static 字段或 static 方法上毫无反应,调用时 NullPointerException | 注入是「往某个实例的成员上填值」,static 属于类而不属于实例,容器的装配步骤根本没有落点 | 去掉 static 改实例成员;确实要静态工具方法就把依赖当参数传进去 | 本篇第三节 |
加了 @Qualifier 还是选不出来,或者两处注入想要不同实现 | @Primary 管「默认给谁」(全局兜底),@Qualifier 管「这次点名要谁」(局部精确),两者混用时以 Qualifier 为准 | 一个接口只留一个 @Primary;每个注入点按需写 @Qualifier,或直接换用 Map<String, PayClient> / List<PayClient> 全量接收 | 第 7 篇 · 第 8 篇 |
搜这类报错时只搜冒号后面那段英文原文(例如 expected single matching bean but found 2),命中率比搜整句高得多——不同 Spring 版本的尾部话术会变。
表格里第三行就是本篇出场率最高的一次崩溃。先别看结论,在下面这段真实堆栈里点出你认为的「凶手行」——点错了也会告诉你为什么不是它:
把支付实现从支付宝换成微信之后,你自己的机器跑得好好的,同事拉下代码一启动就崩在 half-second 里。
把本篇的观念压成一张对照图。左边那条路你其实一直在走,只是没意识到代价长在哪儿。

| 场景 | 自己 new | 交给容器 |
|---|---|---|
| 换一份实现 | 20 处调用点全要改 | 改一行配置或换一个注解 |
| 写单元测试 | 得连带起数据库、邮件服务 | 传一个 Mock 进构造器就行 |
| 临时加一层缓存 | 每个调用点各包一层 | 给缓存实现标 @Primary |
| 依赖关系写错 | 编译器不拦,全靠人肉找 | 启动即失败,当场告诉你缺谁 |
IoC 就是点外卖。你不需要会做菜、不需要知道哪口锅归谁、更不用自己种菜——只要在 App 上下单说「来一份番茄炒蛋」,菜就端到面前。过去你自己 new,等于每吃一顿都要从买菜、洗锅、热油开始;现在你把「做菜」整件事外包给厨房(容器),自己只保留「说清楚我要什么」。反转的从来不是代码量,而是谁主动:以前是你的代码主动出门找依赖,现在是容器主动把依赖送到你手上,你只需在构造器参数里留好收货地址。
下面这个首页同款的容器装配演示值得多玩几分钟。切换条件开关你会发现:某个 Bean 的条件不满足时,报错往往在下游第一个需要它的地方冒出来,而不是在它自己身上——这正是第十二节那行 NoSuchBeanDefinitionException 的真实成因。
目标:亲手制造并读懂本篇最前面的两条报错。注意这里故意不用容器:
package com.example.order;import com.example.stock.StockRepository;import org.springframework.beans.factory.annotation.Autowired;public class OrderService { @Autowired private StockRepository stockRepo; // 字段注入 public void placeOrder(Long productId, int count) { stockRepo.deduct(productId, count); // ← 在这里炸 } public StockRepository getStockRepo() { return stockRepo; // 只为演示:看一眼它是不是 null }}package com.example.demo;import com.example.order.OrderService;public class ManualNewDemo { public static void main(String[] args) { OrderService service = new OrderService(); // ← 病根在这一行:它不在容器里 System.out.println("stockRepo = " + service.getStockRepo()); // null service.placeOrder(1L, 2); // ← 下一行就炸 }}预期输出(JDK 17+ 的 NPE 文案会把缺失的变量名直接写给你):
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.example.StockRepository.deduct(java.lang.Long, int)" because "this.stockRepo" is null at com.example.order.OrderService.placeOrder(OrderService.java:12) at com.example.demo.ManualNewDemo.main(ManualNewDemo.java:9)再把 main 改成从容器取,并在配置类里补上一个 StockRepository 的 Bean,同一行业务代码就通了:
package com.example.demo;import com.example.order.OrderService;import com.example.stock.FakeStockRepository;import com.example.stock.StockRepository;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationclass AppConfig { @Bean StockRepository stockRepository() { return new FakeStockRepository(); // 测试替身,不连数据库 } @Bean OrderService orderService() { return new OrderService(); // 交给容器实例化,字段注入才会发生 }}public class ContainerDemo { public static void main(String[] args) { try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class)) { ctx.getBean(OrderService.class).placeOrder(1L, 2); // 装配由容器完成 } }}验收清单:你能口头说出「为什么第一种写法里 @Autowired 完全没反应」——因为这个对象根本没走过容器的装配步骤。
三个小改动,一次只动一个变量,各自记录你观察到什么:
- 把上面的字段注入改成构造器注入,然后仍然用
new OrderService()。你会观察到编译期直接报constructor OrderService in class OrderService cannot be applied to given types——依赖缺失被提前到写代码那一刻,这就是象限图左上角的红利。 - 让
AServiceImpl与BServiceImpl互相以构造器注入。你会观察到启动失败,日志里有The dependencies of some of the beans in the application context form a cycle和一段 ASCII 环图;把其中一边改成字段注入,环立刻被三级缓存解开。再回第十节把circular实验切到「关闭三级缓存」,看连字段注入也一起崩掉的样子。 - 给
UserService加上@Scope("prototype"),在两处各打印一次getBean(UserService.class)的 hashCode。你会观察到两个哈希不同;删掉注解又变回相同——第十一节的沙盘就是这一对结果的静态版本。
做一个「三种注入方式对比演示工程」,把本篇观念变成可运行的证据。
- 一个接口
PayClient,两个实现AlipayClient、WechatPayClient(构造器里各打一行日志,方便看创建时机) - 三个业务类分别用构造器注入、setter 注入、字段注入拿到
PayClient - 一个
main:先用AnnotationConfigApplicationContext正常启动,打印三者的 hashCode 以及依赖是否为 null;再用new直接创建第三个类,复现那条 NPE - 最后一步:两个实现同时存在会抛
NoUniqueBeanDefinitionException,用@Primary与@Qualifier各修一次,比较两种写法的适用范围
验收清单:① 只用 JUnit + new 就能测通构造器注入那个类,全程不启动 Spring;② 字段注入那个类用同样写法必然失败,你能指出原因出在哪一步;③ 用三句话说清「谁主动创建对象」的差别,讲给同事听也能懂。
不看上文,说出 IoC 反转的三件事(谁创建、谁装配、谁管生命周期),各举一个「反转前 / 反转后」的写法。
IoC、DI、DIP 三个词各管什么?用一句话把它们串成因果链。
为什么构造器注入能让字段是 final,字段注入不能?答案藏在哪个阶段的顺序里?
A↔B 循环依赖,字段注入救得了、构造器注入救不了,分界线落在「实例化」还是「装配」?prototype 为什么一律救不了?
同一类型有两个实现时,@Primary 和 @Qualifier 分别解决什么问题?什么时候该改用 List<T> 全量接收?
你 new 是你主动,容器给才是反转;构造器定终身,字段注入补后课。
IoC 反转的是"创建与装配的主动权";DI 是实现 IoC 的手段,首选构造器注入;DIP 是背后的设计原则,容器是它的执行者;好莱坞原则用一句话概括这一切——别找容器,容器会来找你。判断要不要用容器,只看一件事:你的代码里是否存在"多个需要协作的对象"。