Spring IoC 系列写到第四篇,前几篇主要聊了容器的基本模型、Bean 的创建流程和装配规则,这次专门把 DI(依赖注入)的三种落地方式讲透。构造器注入、Setter 注入、字段注入,这三个名字你肯定都听过,但什么时候该用哪个、每种方式背后有什么代价、为什么官方一直在推荐构造器注入,很多人其实没真正想明白。这篇就把它们的写法、原理、优劣、坑,一次性讲完。
这篇文章不是给完全零基础的人看的,但只要你已经把 Hello World 跑起来、开始写 Service 和 Dao,就完全跟得上。读完你能回答这几个问题:构造器注入为什么排第一?字段注入到底能不能用?循环依赖和注入方式之间是什么关系?我会把几个项目中踩过的真实问题也放进来。
1. 先拆开“注入”这个词的本质
1.1 控制反转是根,依赖注入是果
要理解三种注入方式,先要把 IoC 和 DI 的关系捋清楚。IoC(控制反转)是一个更宏观的思想:对象不再自己创建所依赖的其他对象,而是把“创建和查找依赖”的控制权交给外部容器。DI 是 IoC 的一种落地实现方式——容器在创建对象时,把对象依赖的其他 Bean 直接送给它。
很多开发者习惯把 IoC 和 DI 当一回事,严格说不对,但日常沟通里这么叫也没太大问题。理解到这一层就够了:Spring 启动时会扫描 Bean 定义,然后实例化、处理依赖、放入容器,你要用时直接从容器里拿,而不是自己“new”一个。这就是反转。传统代码是 Service 内部 new 一个 Dao,依赖由自己创建;Spring 是在 Service 创建时把 Dao 实例传进来,依赖由容器注入。
我习惯用一个点单的例子解释:你不做饭,在平台上下单,平台把菜送到你手上。你只声明“我要什么”,平台负责“找谁做、怎么做、怎么送”。依赖注入相当于外卖小哥把餐交到你手里的那个动作,构造器、Setter、字段注入就是交接方式的不同——是递到你手上、放到柜子里,还是直接打开你家门放桌上。
1.2 三种注入方式,解决的其实是同一件事
这三种方式的共同点是:Spring 都会把依赖装配到目标 Bean 中。差异在于“什么时候给”和“怎么给”。
| 注入方式 | 代码特征 | 依赖传入时机 | 可变性 |
|---|---|---|---|
| 构造器注入 | 通过构造方法参数 | Bean 实例化时,先传参后生成对象 | final,不可变 |
| Setter 注入 | 通过 setter 方法 | Bean 实例化后,调用 setter 完成 | 可变,可重新赋值 |
| 字段注入 | 直接给字段加注解 | Bean 实例化后,反射给字段赋值 | 可变,反射修改 |
从 JVM 层面看,构造器注入是“先把依赖准备好了再把对象 new 出来”,Setter 和字段注入都是“先 new 一个半成品,再装配完整”。这个先后顺序的差异,会引发一系列连锁反应,包括循环依赖能不能解决、依赖缺失什么时候爆出来、单测好不好写。后面的章节会围绕这些差异展开。
2. 三种注入方式怎么落地:写法和特点一次讲清
2.1 构造器注入:最工整的写法
构造器注入就是在类的构造方法上声明参数,Spring 会从容器中找对应类型的 Bean,作为参数传入。自 Spring 4.3 开始,如果一个类只有一个构造器,可以连@Autowired都省掉。
@Service public class OrderService { private final OrderDao orderDao; private final InventoryService inventoryService; public OrderService(OrderDao orderDao, InventoryService inventoryService) { this.orderDao = orderDao; this.inventoryService = inventoryService; } public void createOrder(OrderDTO dto) { // 业务逻辑 } }这种写法有几个很突出的点。字段能声明为final,意味着依赖一旦注入就不会被替换,整个对象从创建起就是完整的。依赖关系被显式暴露在构造器签名里,一个类依赖什么,看构造器就知道。如果依赖缺失(容器里没有对应的 Bean),应用启动时会直接抛出UnsatisfiedDependencyException,而不是等运行到那一行业务代码才报空指针。
实际项目里,构造器参数越来越多的时候,会引发两种选择:一是继续堆参数,二是拆分对象。堆到四五个以上其实就在提醒你,这个类承担了太多职责。这时候优先考虑职责拆分,而不是硬凑构造器。如果只是参数多个但职责清晰,可以用 Lombok 的@RequiredArgsConstructor,让代码更紧凑,代价是隐去了构造器本身。
2.2 Setter 注入:灵活但有点散
Setter 注入是用@Autowired修饰 setter 方法,Spring 在 Bean 实例化后会调用这个方法完成装配。
@Service public class OrderService { private OrderDao orderDao; private InventoryService inventoryService; @Autowired public void setOrderDao(OrderDao orderDao) { this.orderDao = orderDao; } @Autowired public void setInventoryService(InventoryService inventoryService) { this.inventoryService = inventoryService; } }这种方式的优势是灵活性:Bean 先创建出来,依赖之后可以重新设置,甚至可以在配置阶段根据条件决定注入哪个实现。Spring 的 XML 时代非常流行,<property>标签配的就是 Setter 注入。
但它的劣势也很明确。对象会经历一个“不完整”的阶段,如果依赖没有设置成功,Bean 已经存在了,只是字段是空的,业务调用时才会空指针。所有字段都不能声明为final,失去了不可变性的保证。同时 setter 是 public 的,任何外部代码都能拿到 Bean 后重新设置依赖,等于给了对象一个“被篡改”的口子。
在纯注解配置的现代 Spring 项目里,Setter 注入用的场景越来越少,但我并不认为它应该被完全淘汰,后面第 5 章会展开讲它真正适合的场景。
2.3 字段注入:最省事但争议也最大
字段注入是日常开发中出现频率最高的一种,写起来确实爽:
@Service public class OrderService { @Autowired private OrderDao orderDao; @Autowired private InventoryService inventoryService; public void createOrder(OrderDTO dto) { // 业务逻辑 } }一个注解搞定一个依赖,没有构造器,也没有 setter。Java 代码少到不能再少。但代价是什么?依赖的注入依赖反射,字段是private的,Spring 通过反射强行赋值。这个机制本身不是问题,问题是它把依赖关系藏起来了,而且让测试变得很难受,还会助长循环依赖的滋生。
我见过一个业务系统,Service 类里躺着七八个@Autowired字段,类顶部长长一排。从代码上看,你根本分不清这些依赖是构造时就要的,还是某个方法里才用的。新人接手时一脸懵,Code Review 时也没法快速判断依赖是否合理。这就是字段注入最直观的问题——它把依赖关系“隐藏”在字段列表里,而不是显式暴露在外部接口上。
3. 构造器注入凭什么排在第一,我是怎么理解的
3.1 让依赖关系变成硬约束
依赖这种“硬需求”,应该是不可商量的。一个 Service 没有 Dao,它就没法工作,那最合理的表达方式就是“创建这个对象必须提供 Dao”——构造器注入天然就是这个语义。
如果使用字段注入,依赖缺失时 Spring 启动不会报错。注意,这里我说的是容器能创建出 Bean、只是这个字段找不到匹配类型时的情况。实际上如果@Autowired字段找不到 Bean,Spring 在启动阶段就会抛出NoSuchBeanDefinitionException,所以“缺依赖启动不报错”这种说法不完全准。更准确说,字段注入的失败往往发生在运行期调用时才暴露,因为 Spring 的启动校验是有的,但某些场景下 Bean 创建完毕后的字段确实可能为 null,例如手动 new、或者某些代理场景。相比之下,构造器注入把“必须提供依赖”这件事写进了 Java 语法层面:你编译就绕不开,容器创建对象时必须有这个参数。
一个前置的检查,越早越好。构造器注入把错误暴露时机从“业务运行时”提前到“容器启动时”,这是它最大的价值之一。启动时挂掉,日志一拉就知道少了哪个 Bean;运行到半夜业务量上来才空指针,排查成本完全不是一个量级。
3.2 final 字段带来的不可变保证
构造器注入的字段天然是final的,这个特性很多人没有重视。final意味着对象一经创建,依赖关系就固定下来了。
这件事在多线程环境下特别有价值。一个单例 Bean 在并发请求中被多个线程共享,如果依赖字段能被 setter 重新赋值,就会出现“线程 A 正在用的对象,依赖突然被线程 B 换掉”的极端情况。虽然生产环境没人会故意这么干,但代码规范上锁死这个可能性,本身就是收益。
final还让代码的可读性变强。看一个类的构造器和 final 字段,就能确定它的依赖集合,不需要去扫所有字段上的注解。代码审查时,依赖清单一目了然。
3.3 单测友好是隐藏加分项
真正使用字段注入后上过一次手写单测,就会明白构造器注入有多香。
字段注入的类,单元测试时你想手动new出来并传入 mock 依赖,是做不到的,因为字段是private的,没有构造器也没有 setter。你只能启动 Spring 容器,让容器完成注入,或者借助ReflectionTestUtils反射设置字段。这两种方式,前者笨重,后者绕。
而构造器注入的类,测试代码里直接new OrderService(mockOrderDao, mockInventoryService)就行了。依赖是方法的入参,mock 对象随手传进去,干净利落。对于测试驱动开发的团队,这一条基本决定了注入方式的选择。
4. 字段注入的坑,是我在项目里一步步踩出来的
4.1 依赖被隐藏,类开始悄悄膨胀
字段注入最阴险的地方,是它不会给你的代码任何“警告信号”。构造器注入时,依赖多了,构造器参数变长,你会自然感觉到不舒服。但字段注入完全不会有这种感觉,顶部加一行@Autowired太容易了,不知不觉一个类就凑了八个字段。
依赖数量膨胀的背后,是类职责的膨胀。一个类同时依赖用户服务、订单服务、库存服务、消息服务、优惠券服务……它一定不是什么高内聚的类。我在多个团队见过这种现象:业务开发期间大家图快,都往一个 Service 里堆依赖,等需求迭代到中期,这个类已经成为改动集中地,改一个逻辑要回归一堆功能。依赖注入方式本身不背全部锅,但字段注入确实降低了这种坏味道的感知门槛。
4.2 循环依赖因为字段注入而“侥幸”存在
循环依赖是 Spring 开发里绕不开的问题。A 依赖 B,B 又依赖 A,两个 Bean 互相引用。如果都用构造器注入,Spring 启动会直接抛BeanCurrentlyInCreationException。但如果都用字段注入,在某些单例场景下,Spring 靠三级缓存可以把它“凑合”出来。
“凑合”这个词我是认真用的。循环依赖本身就是设计问题,字段注入让它没在启动期暴露出来,业务运行中两个对象互相引用,后续维护非常痛苦。Spring Boot 2.6 以后默认禁止了循环依赖,启动阶段就会报错,强行把这个坏味道摆在明面上。这是框架层面在告诉你:别再这么写了。
如果循环依赖实在绕不开,优先从设计上拆解——把公共逻辑抽成第三个类,让依赖变成单向的。实在不行再考虑@Lazy或者 Setter 注入兜底,但这都是临时手段,不是长远方案。关于循环依赖的底层处理机制,我在第 6 章会详细分析。
4.3 测试阶段,字段注入让你反复怀疑人生
前面提到字段注入的类不方便手动 new,这里展开说说是怎么个难受法。
假设你要测OrderService的某个方法,这个方法只用到OrderDao,但类里还有InventoryService、MessageService、CouponService三个@Autowired字段。你没法只传一个OrderDao就把它 new 出来,必须把所有依赖都准备齐。如果走容器,要配一整条 Bean 依赖链;如果走反射,要写一串ReflectionTestUtils.setField(),测一个方法先写十行准备代码。
这种体验会逼着开发者逃避单测,要么直接跳过,要么写一个重量级的集成测试把整个容器拉起来。长此以往,测试代码变成维护负担。而构造器注入的类,有多少依赖就传多少 mock,依赖多的类在测试阶段也会反向推动职责拆分。
5. Setter 注入的正确使用姿势,别一棍子打死
5.1 适用的场景:可选依赖和运行时变更
虽然现代项目里 Setter 注入的曝光率变低了,但有两个场景它依然是最优解。
第一个是可选依赖。Spring 的@Autowired(required = false)配合 Setter 注入,可以让某个依赖存在就注入、不存在就保持 null。构造器注入在这个场景下很别扭,因为你没法轻易在构造器里表达“有就传没有就不用”。Setter 注入表达可选依赖的语义非常自然。
第二个是运行时可变的依赖。虽然这种需求不多见,但有些动态切换策略的场景,确实需要依赖“可换”。比如某个系统支持多种消息通道,运行时要根据配置切换,这时候有一个 setter 方法,配合灵活刷新机制,比把字段锁死为 final 更容易实现。
5.2 主依赖用构造器,可变依赖用 Setter,组合起来用
我见过最合理的组合用法是:核心依赖用构造器注入,保证不可变和启动期校验;少数需要灵活切换的依赖用 Setter 注入。
@Service public class PaymentService { private final PaymentGateway gateway; private PaymentLogger logger; // 可选依赖,运行时可能替换 public PaymentService(PaymentGateway gateway) { this.gateway = gateway; } @Autowired(required = false) public void setLogger(PaymentLogger logger) { this.logger = logger; } }这种方案兼顾了两种注入方式的优点。主链路依赖是硬性的,必须有;辅助能力是可选的,有就注入,没有也不影响主流程。注意,这种混合风格在代码审查时要跟团队说清楚,否则每个人理解不一致,规范就形同虚设。
5.3 注意 Bean 创建顺序的隐形问题
Setter 注入有一个容易被忽略的点:Bean 的创建顺序。
构造器注入中,依赖必须先于当前 Bean 创建,Spring 能严格保证顺序;Setter 注入是当前 Bean 先创建,再调用 setter。这意味着在 setter 方法里访问构造器里的字段,是没有问题的,但如果你在 setter 里做了某些依赖当前 Bean 自身完整性的操作,就可能踩到初始化顺序的坑。
我遇到过的情况:某个 Bean 的 setter 注入方法里,调用了依赖对象的方法,而这个依赖对象又在创建过程中间接需要当前 Bean 的部分能力,加上@Lazy之后问题消失了。这类问题排查起来非常隐蔽,因为报错信息往往是“空指针”或“创建中异常”。事后复盘,根因就是 Bean 的初始化顺序没有理清。
6. 循环依赖与三级缓存:底层逻辑其实不难
6.1 为什么构造器注入解决不了循环依赖
先明确一点:Spring 默认的单例 Bean 之间存在循环依赖,不一定启动即报错。关键在于注入方式决定了依赖获取的时机。
构造器注入时,创建 A 必须先拿到 B 的完整实例,创建 B 又必须先拿到 A 的完整实例,这就成了先有鸡还是先有蛋的问题。谁都没法先完成构造,所以必然失败。
字段注入和 Setter 注入为什么能侥幸成功?因为 A 可以先用“半成品”状态提前暴露出来。Spring 用了三级缓存:
- 一级缓存
singletonObjects:存放创建完成的单例 Bean - 二级缓存
earlySingletonObjects:存放早期暴露的半成品 Bean - 三级缓存
singletonFactories:存放可以生成早期 Bean 引用的工厂
字段注入时,A 实例化后还没填充字段,Spring 先把一个“半成品 A”的工厂放到三级缓存里。接着 A 开始注入 B,创建 B 时 B 发现需要 A,于是从三级缓存拿到 A 的早期引用,B 完成创建,A 再继续填 B 的引用。整个过程借助“提前暴露半成品”打了个时间差。
6.2 真的遇到循环依赖,我的处理顺序
循环依赖是个信号,不是 bug 本身。我的习惯是按下边顺序来处理。
先检查是不是设计问题。A 和 B 互相依赖,多半意味着它们的职责边界没划清。把 A 依赖 B 的那段逻辑抽成一个 C 类,B 依赖 A 的逻辑抽成一个 D 类,依赖就变成了 A→C、B→D,循环自然解开。这是最彻底的方案。
再看能不能用@Lazy。在构造器注入的参数上加@Lazy,Spring 会为这个依赖生成一个代理对象,等到真正调用时才去解析目标 Bean。这个手段能让循环依赖通过启动校验,但代理对象的存在会让问题更加隐晦,建议只是应急。
最后才考虑改成 Setter 注入或者字段注入,让半成品提前暴露、绕开循环依赖。这个方案是让代码向框架的“兼容机制”妥协,代价是丢掉了构造器注入的全部优点,不到万不得已不用。
7. 实操中的几个高频问题,我统一整理一下
7.1 @Autowired 和 @Resource 怎么选
这两个注解都能完成注入,但机制不同。@Autowired是 Spring 提供的,默认按类型注入;@Resource是 JDK 提供的 JSR-250 规范,默认按名称注入。
当一个接口有多个实现类时,@Autowired会因为没有唯一候选而报NoUniqueBeanDefinitionException,你需要配合@Qualifier("beanName")指定名字。@Resource则直接通过name属性指定,语义更简洁。但在一个全 Spring 的体系里,@Autowired依然是主流,因为按类型注入配合@Qualifier的表达能力更强,IDE 支持也更好。项目里统一用一个就好,混用容易让新人困惑。
7.2 注入接口还是注入实现类
原则上是面向接口编程,注入接口类型,这样后续替换实现类时对调用方无感。但有一个场景要特别小心:实现类是原型 Bean(prototype scope)时,注入方式会影响每次获取的是不是新实例。
把 prototype Bean 注入到单例 Bean 中,无论是构造器还是字段注入,拿到的都是同一个实例,原型 Bean 的“每次新实例”语义就失效了,除非用ObjectProvider或@Lookup。这个等以后专门写一篇聊,记住一点就行:注入方式和 Bean 作用域是两件事,不要以为注进来就会自动遵循作用域规则。
7.3 注入进来的 Bean 是线程安全的吗
要看你注入的是什么。如果 Bean 本身无状态,只有方法局部变量,那天然线程安全;如果 Bean 内部有可变成员变量,且没有同步机制,那么并发下就可能出问题。容器本身不负责 Bean 的线程安全,这是你自己要考虑的。通常注入的 Service、Dao 都是无状态的,放心用;但如果你自己写了一个带缓存的组件挂在字段上,就要想清楚并发访问的控制了。
8. 三种方式选型速查,给团队定规范用
| 注入方式 | 推荐级别 | 最佳场景 | 避免场景 |
|---|---|---|---|
| 构造器注入 | 首选 | 核心必选依赖、不可变依赖、单测友好的类 | 参数过多时先拆分职责 |
| Setter 注入 | 按需 | 可选依赖、运行时可替换依赖 | 核心依赖用 Setter 会导致启动期不报错 |
| 字段注入 | 尽量避免 | 快速 Demo、一次性脚本、装配链路简单的工具类 | 正式业务代码、复杂依赖链、不可变要求 |
团队规范这个事,我的建议是直接在 IDE 检查规则里加持。比如用 IDEA 检查规则或静态检查工具,把字段注入标记为 warning,这样 Code Review 就不用全靠人肉盯了。规范没有工具约束,执行一两个迭代就会走样。
9. 常见报错排查与我的调试习惯
9.1 报错信息速查
| 报错信息 | 常见原因 | 处理方式 |
|---|---|---|
NoSuchBeanDefinitionException | 容器中没有对应类型的 Bean | 检查类是否加了@Component/@Service等注解 |
NoUniqueBeanDefinitionException | 接口有多个实现类,未指定 | 加@Qualifier或@Resource(name="...") |
UnsatisfiedDependencyException | 构造器注入的依赖缺失 | 从报错堆栈里找缺失的 Bean 名称,检查其创建条件 |
BeanCurrentlyInCreationException | 循环依赖,且用了构造器注入 | 分析设计,优先重构,临时可用@Lazy |
Field xxx in X required a bean of type ... | 字段注入找不到 Bean | 检查包扫描路径,确认 Bean 是否被扫描到 |
9.2 我的排查习惯
遇到依赖注入相关的问题,我基本按照这个流程来:先看报错堆栈里的 Bean 名称,确定是哪个依赖出了问题;再查这个 Bean 是不是在扫描路径下,是不是被条件注解(比如@ConditionalOnProperty)挡住了;如果是多实现类的场景,看是不是因为没有指定名称导致类型冲突。八成的问题到这里就能定位。
剩下两成比较隐蔽,例如代理对象导致的类型不匹配。某个依赖在 AOP 后变成代理类,类型判断时可能产生歧义。遇到这种情况,我会先把报错堆栈完整贴到 IDE 里,从“根因”开始往上翻,把中间那些CGLIB、Proxy相关的内容看明白,基本就能找到问题所在。实在不行就打开logging.level.root=debug,把 Bean 创建的日志打开,从启动日志里看整个创建链,非常直观。
关于注入方式的软件工程讨论,这两年越来越两极分化:要么“字段注入被喷得体无完肤”,要么“我写了五年代码全是字段注入也没出问题”。我个人实际体验是,构造器注入带来的启动期失败、不可变约束和单测友好,在项目规模变大后收益会被持续放大;而字段注入的“快”,会在后续维护和测试中连本带利还回去。
最后再分享一个团队落地的小技巧:新项目一开始就把构造器注入写成强制规范,配到检查规则里;老项目逐步推进,先给核心业务模块做整改,不要想着一次性把所有类都改完。比起“哪种方式绝对正确”,更值得坚持的是一支团队用一种方式,注入方式长期不统一,才是一种慢性消耗。