Spring 依赖注入(DI)看起来是个老生常谈的话题,但我在面试候选人、做 Code Review 的时候发现,很多写了两三年 Java 的同学对三种注入方式的认知其实停留在“能用就行”的阶段,更别提背后的设计哲学和踩坑细节了。构造器注入、Setter 注入、字段注入,三种方式各有各的适用场景,也各有各的坑,选错了轻则代码难维护,重则项目启动直接报循环依赖错误。这篇内容我会结合自己实际项目里的经验,把三种注入方式的原理、代码示例、选型依据、常见问题一次讲透,同时也把面试里最容易问到的 @Autowired 与 @Resource 区别、三级缓存与循环依赖的关系这些硬核知识点一并梳理清楚。
1. 先把“为什么需要依赖注入”这件事说透
很多初学者学 Spring 的时候直接跳到注解用法,跳过了最根本的问题:IoC 容器到底解决了什么?依赖注入在这个体系里扮演什么角色?如果不先把这个问题想明白,后面选注入方式的时候大概率就是瞎蒙。
1.1 从“自己造轮子”到“容器给轮子”
想象一个很普通的业务场景:一个订单服务要调用库存服务,起初你可能是这样写的:
public class OrderService { private InventoryService inventoryService = new InventoryService(); public void createOrder(Order order) { inventoryService.deductStock(order.getSkuId(), order.getCount()); } }这段代码的问题非常明显:OrderService 和 InventoryService 被硬编码绑死了,InventoryService 的内部任何改动都可能波及 OrderService;如果要写单元测试,你也很难 mock 掉 InventoryService,因为引用是在 OrderService 内部自己创建的。这就是典型的“控制反转”需求——把创建依赖对象的控制权从调用方手里拿出去,交给一个统一的容器来管理。
Spring 做的事情就是把这个容器实现出来,它把所有 Bean 的创建、组装、生命周期管理都接管了。你只需要告诉 Spring“这个类需要依赖什么”,剩下的装配工作全部由容器根据配置完成。依赖注入(DI)本质上就是控制反转(IoC)的一种具体实现手段,而 IoC 是设计思想。这也是面试里最常被问到的一句话辨析:“IoC 和 DI 是什么关系”,记住这个逻辑链就能答得清楚。
1.2 三种注入方式的历史演进脉络
最早的 Spring 版本主要靠 XML 配置来装配 Bean,那时候构造器注入和 Setter 注入都是通过<constructor-arg>或者<property>标签实现的。后来 Spring 2.5 引入了@Autowired注解,字段注入开始流行起来,因为代码是真的简洁省事。再往后,Spring 官方文档和社区的最佳实践慢慢转向了“构造器注入优先”,原因是它在不可变性、测试友好性、强制依赖完整性上都有明显优势。
理解了这段演进历史,你就能明白为什么网上关于三种注入方式的争论这么多了——本质上不是某个方式“不能用”,而是不同时代、不同场景下的最佳选择在变化。现在的 Spring Boot 项目里,你看到的推荐写法是用构造器注入配合 Lombok 的@RequiredArgsConstructor,代码量不比字段注入多多少,但健壮性好一个档次。
2. 三种核心注入方式逐一拆解
2.1 构造器注入:Spring 官方推荐的第一选择
构造器注入是把依赖作为构造方法参数传入,Spring 在创建 Bean 实例的时候会自动匹配参数类型完成注入:
@Component public class OrderService { private final InventoryService inventoryService; private final PaymentService paymentService; // Spring 4.3+ 版本,如果类只有一个构造器,可以省略 @Autowired public OrderService(InventoryService inventoryService, PaymentService paymentService) { this.inventoryService = inventoryService; this.paymentService = paymentService; } }从 Spring 4.3 开始,单个构造器的情况下不需要显式加@Autowired注解了,这让代码看起来清爽不少。加上final关键字之后,依赖对象一旦被赋值就不能再被改变,这种不可变性在多线程环境下是很大的优势——你不用担心某个依赖在运行期间被其他代码意外替换。
构造器注入最典型的应用场景是“强制依赖”,也就是这个类缺了这些依赖根本无法工作。比如一个接口服务,没有 RedisTemplate 和 Mapper,业务逻辑根本跑不起来,这时候用构造器注入最合适。好处是:第一,依赖列表一目了然,别人看构造器签名就知道这个类需要什么;第二,测试的时候直接 new 出来往构造器里传 mock 对象就行,完全不需要 Spring 容器参与;第三,不容易出现依赖为 null 的情况,因为对象创建的必要条件就是依赖已经具备。
2.2 Setter 注入:给“可选依赖”留个余地
Setter 注入的形式很直白,通过 JavaBean 风格的 setter 方法设置依赖:
@Component public class ReportService { private DataSource dataSource; @Autowired public void setDataSource(DataSource dataSource) { this.dataSource = dataSource; } }Setter 注入和构造器注入最大的区别在于语义:构造器表达的是“我必须要这些依赖才能活”,Setter 表达的是“这些依赖是可选的,后补的”。在实际项目里,典型场景是某些非强制的组件,比如一个事件推送器,有消息队列的实现,也有降级用的本地日志实现;或者一些带默认配置的组件,允许容器初始化之后再替换或者补充。
不过要提醒一点,Setter 注入的字段不能声明为final,这就意味着依赖在对象的整个生命周期中是可能被重新赋值的,这在并发场景下会引入安全隐患。另外,Setter 注入在测试的时候要多一步,需要先 new 出对象再调用 setter 传入 mock 依赖,少了一层构造器注入的强制性保护。所以我的建议是:除非这个依赖真的是“可选”的,否则能不用就不用。
2.3 字段注入:写起来最快,但代价藏在后面
字段注入是现在很多项目里最常见的写法,因为真的太省事了:
@Component public class OrderService { @Autowired private InventoryService inventoryService; @Autowired private PaymentService paymentService; }用的时候直接三个注解搞定,非常简洁直观。它的底层原理是 Spring 容器创建完 Bean 之后,通过AutowiredAnnotationBeanPostProcessor扫描所有带@Autowired的字段,然后用反射强行给私有字段赋值。看到“反射”这两个字,你应该就能感觉到一些潜在问题了:绕过封装、依赖隐藏、测试困难。
字段注入最大的坑是“依赖被隐藏”。你只通过@Autowired注解标记字段,但类的公共 API(构造器、setter)完全看不出这个类依赖什么。新接手代码的人想快速了解类依赖关系,只能一个个字段翻过去看有没有注解,这在大型项目里是非常糟糕的体验。而且字段注入无法标记为 final,所有依赖默认是可变的,和构造器注入形成了鲜明对比。
另外一个更现实的痛点是单元测试。字段注入方案下,你想脱离 Spring 容器测试这个类,就得先把对象 new 出来,再用 ReflectionTestUtils 往私有字段里硬塞 mock 对象。虽然 Spring 提供了ReflectionTestUtils.setField()这个方法,但这明显是在绕开正常路径,写测试代码的时候会让你觉得浑身别扭。反观构造器注入,直接传 mock 进去就完事了。
我把三种方式的对比整理成了一个表格,方便后来的人一眼看清差异:
| 对比维度 | 构造器注入 | Setter 注入 | 字段注入 |
|---|---|---|---|
| 依赖是否 final 化 | 可以,推荐 | 不行 | 不行 |
| 依赖可见性 | 构造器签名一目了然 | setter 可见 | 隐藏,需翻字段 |
| 强制依赖约束 | 缺了无法创建对象 | 不强制 | 不强制 |
| 单元测试友好度 | 直接传参,无需容器 | 先实例化再 setter | 需反射工具 |
| 循环依赖支持 | 不支持(启动即报错) | 支持 | 支持 |
| 代码简洁度 | 中 | 中 | 高 |
| 官方推荐度 | 强烈推荐 | 有条件使用 | 不推荐 |
2.4 三种方式的本质区别一句话总结
字段注入是“让 Spring 偷偷塞给我”,Setter 注入是“让 Spring 光明正大地从门口递给我”,构造器注入是“让 Spring 在我出生之前就把一切准备好”。这三句话能帮你快速建立直觉,也适合面试时用通俗语言表达自己的理解。
3. 结合 Spring Boot 的工程实践方案
3.1 团队代码规范怎么定
如果是新建项目或者接手一个老项目需要定团队规范,我建议直接落一条规定:默认使用构造器注入,只有“可选依赖”才考虑 Setter 注入,字段注入直接 Code Review 打回。这条规则简单、可执行、不需要讨论,而且能避免团队里出现“一人一个写法”的混乱状态。
有一个工具可以帮你在 CI 阶段自动拦截问题,那就是 Spring 官方提供的spring-boot-configuration-processor之外还有一个比较小众的检查方式:用 SonarQube 的规则java:S3306。这条规则会提示“Fields should be injected via constructor or setter”,把严重级别设为 Error 之后,字段注入代码在 Merge Request 阶段就会被自动卡住。这个规则我实际体验下来误报率非常低,强烈推荐给正在做工程规范的团队。
Lombok 的@RequiredArgsConstructor是解决构造器注入代码冗长问题的最佳搭档:
@Component @RequiredArgsConstructor public class OrderService { private final InventoryService inventoryService; private final PaymentService paymentService; }Lombok 会为所有final字段生成一个全参构造器,字段列表就是依赖清单。新加一个依赖,只需要加一个private final字段声明,构造器自动跟着变,省去了手写构造器的样板代码。这样既保留了构造器注入的所有优点,又不会让人觉得代码啰嗦。
3.2 依赖多了之后怎么办
构造器注入经常被人吐槽的一点是“参数量爆炸”。一个类如果有六个甚至更多的构造器参数,代码会显得非常笨重,而且通常也是一个负面信号——说明这个类承担了过多职责。
我处理过的一个实际案例是:一个报表导出服务,构造器里塞了订单查询、用户查询、模板配置、OSS 客户端、事件发布器,一共五个依赖。看起来参数很多,但实际上这五个依赖都在为“导出报表”这一件事服务,职责并没有发散。所以我通常建议分两层判断:依赖数量只是表面现象,更重要的是看这些依赖是不是都在支撑同一个职责。如果确实是因为类太大导致依赖很多,正确的解决方式是拆分,而不是换成字段注入来“藏起来”。
另外,如果构造器参数中有一些确实是可选配置,比如某个通道的开关标志、超时时间等,可以通过@Value读取配置文件来注入:
public ReportService(OrderQueryService orderQueryService, @Value("${report.max.rows:10000}") int maxRows) { // ... }如果可选依赖本身是个对象,更优雅的做法是把可选逻辑封装成默认实现或者 Null Object,这样构造器里的依赖依然是“必需的”,但实际的逻辑可以走降级路径。比直接传一个 null 进来干净得多。
3.3 与 Spring Security、Spring AI 等生态的联动细节
在 Spring Boot 生态里,构造器注入的惯例同样适合各种模块。拿 Spring Security 举例,自定义 UserDetailsService 的时候:
@Service public class CustomUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public CustomUserDetailsService(UserMapper userMapper) { this.userMapper = userMapper; } }用构造器注入可以在初始化阶段就确定依赖关系,配置 Security 的认证逻辑时,把这个 Bean 塞给 AuthenticationManagerBuilder 即可,不会出现容器启动后找不到实例的问题。
再看 Spring AI 的场景。如果你在项目中接入 Spring AI,通常会定义一个 AiService 封装 LLM 调用,这个服务依赖模型客户端和向量存储。用构造器注入把这些依赖定义为 final,在服务启动时就能确认所有 AI 相关组件都就绪了,避免业务调用到一半报 NullPointerException。这种“依赖必须在创建时齐全”的特性,在集成第三方能力的时候尤其有价值。
4. 循环依赖、三级缓存与注入方式选择的三角关系
4.1 循环依赖是怎么发生的
两个 Bean 互相依赖对方,就是典型的循环依赖:A 需要注入 B,B 又需要注入 A。在构造器注入的模式下,Spring 直接拒绝创建,项目启动报错信息类似:
Requested bean is currently in creation: Is there an unresolvable circular reference?为什么构造器注入直接报错?因为 Spring 创建 A 时,必须先拿到 B 才能完成构造,于是转去创建 B,而 B 的构造又要求 A 已经存在——但 A 还在创建中没有返回,这就形成了一个死结。Spring 的设计哲学是“宁可启动失败,也不给你一个残缺的对象”,所以直接抛异常。
字段注入和 Setter 注入可以“破解”这个问题,核心依赖的是 Spring 容器的三级缓存机制。简单说,Spring 在创建 Bean 时会先实例化对象(此时对象还没有完全初始化完),如果发现这个 Bean 正在创建过程中被其他 Bean 引用,就会通过三级缓存放出一个早期引用(exposedObject),让依赖方先拿到一个“半成品”,后续再完成属性填充和初始化。
4.2 三级缓存到底缓存了什么
Spring 内部维护了三个 Map 来解决单例 Bean 的循环依赖:
singletonObjects:一级缓存,存放完全创建好的单例 Bean,这是对外暴露的最终成品。earlySingletonObjects:二级缓存,存放提前暴露的早期 Bean 引用,此时对象还没完成属性填充,但引用已经存在了。singletonFactories:三级缓存,存放的是 ObjectFactory 类型的工厂对象,通过getEarlyBeanReference()可以生成早期引用,这里还涉及到 AOP 代理的提前创建问题。
整个流程可以这样串起来:A 创建时发现需要 B,容器查不到 B,于是先把 A 的半成品放入三级缓存;B 创建时又依赖 A,容器从三级缓存里拿到 A 的 ObjectFactory,通过工厂得到早期引用,完成 B 的创建;B 创建完成后 A 再从容器里拿到完整的 B,继续完成自己剩余的初始化。
这里有个很容易踩的细节:AOP 代理对象和原始对象不是同一个对象。如果 Bean 被 AOP 增强过,Spring 在三级缓存阶段就会生成代理对象,确保后续拿到的引用是代理而非原始对象,否则依赖方拿到的对象和被容器管理的对象就不是同一个,事务注解、切面逻辑都会失效。
4.3 实际项目中如何处理循环依赖
说实话,我第一次在项目里遇到循环依赖报错时,第一反应是加@Lazy注解“混过去”,确实能解决,但这属于治标不治本。@Lazy的本质是让 Spring 暂时不要立即初始化依赖方,把它变成一个代理对象,真正调用时才去创建目标对象。这样虽然绕开了启动报错,但引入了很多隐性成本:代理对象的创建时机不固定、排错难度增加、性能也有损耗。
比较务实的处理方式是重构。最常见的手段是拆分依赖方向,让 A 依赖 B,但 B 不再依赖 A,把公共逻辑抽到第三个类里;还有一种方式是使用事件发布机制,让 B 通过监听 A 发布的事件来响应,而不是直接引用 A。这些重构方案虽然要花一点时间,但长期看是健康的,项目复杂度在小范围内是可控的。
这里我特别想强调:很多人问“字段注入不就是 Spring 官方默认支持的循环依赖方案吗?”没错,字段注入确实可以让循环依赖“存活”下来,但这也意味着你的项目里会悄悄出现本不该存在的循环依赖。这些循环依赖会让 Bean 的创建时序变得非常隐蔽,线上排查问题的时候很难直观定位。与其依赖这个能力,不如从设计上彻底避免。
5. 常见问题与高频面试题整理
5.1 运行期最常见的三个报错现场
NoSuchBeanDefinitionException / NoQualifyingBeanOfTypeException。这类报错意味着 Spring 找不到指定类型的 Bean,通常是三个原因:类没有被@Component或其派生注解扫描到;接口有多个实现类但你没有指定@Qualifier;Bean 定义被配置类编写的条件覆盖了。排查的时候先看包的扫描路径,再看有没有同类型多实现,最后看@Conditional*注解的行为。
Field injection is not recommended。这个其实是 IDEA 和 Sonar 的警告,不是错误,但很多团队把警告等级提高成了 Error。碰到这种报文,直接按前面说的方案改成构造器注入或者解决依赖问题即可。
BeanCurrentlyInCreationException。循环依赖的典型报错。先确认是通过构造器注入产生的还是其他原因,再决定是重构还是临时用@Lazy。按我的经验,80% 的情况都可以抽出一个中间类来解决循环依赖,只有极少数历史包袱特别重的模块不得不临时用@Lazy兜底。
5.2 @Autowired、@Resource、@Qualifier 的边界
面试里几乎必问这三个注解的区别,这里给你一个可以直接背的要点:
@Autowired是 Spring 框架提供的注解,默认按类型(byType)注入,如果有多个同类型 Bean 会尝试按名称(byName)匹配,如果还是分不清就报错。@Resource是 JDK 标准注解(javax.annotation 或 jakarta.annotation),默认按名称(byName)注入,名称找不到再按类型。两者混用容易出问题,建议团队里统一用一种。
@Qualifier通常和@Autowired搭配使用,显式指定 Bean 的名称。Java Config 里可以用@Bean(name = "xxx")定制名称,XML 配置里就是id属性。
我举一个小例子,接口PaymentService有两个实现AlipayService和WechatPayService,如果注入时只写@Autowired,Spring 会疑惑到底选哪个,启动直接报错;加上@Qualifier("alipayService")就是告诉容器我要支付宝那个实现。这也能直接引申到一个讨论:接口多实现时,设计上最好在配置层面解决选择问题,而不是在业务代码里三番五次写 Qualifier。
5.3 高频面试题逐条拆解
“为什么 Spring 推荐构造器注入?”
回答思路从四个角度展开:不可变性(final 支持)、依赖完整性约束(缺了直接启动失败)、可测试性(直接 new 传入 mock)、防循环依赖(构造器注入启动即暴露问题)。如果能补充一句“构造器参数列表就是依赖清单,阅读性最好”,面试官会觉得你是真的有工程经验。
“@Autowired 字段注入的原理是什么?”
回答关键是AutowiredAnnotationBeanPostProcessor这个后置处理器。它在 Bean 实例化完成后、初始化前后会被调用,扫描被@Autowired标记的字段或方法,然后通过ResolvableType解析目标类型,再调用beanFactory.resolveDependency()去容器里找匹配的 Bean。反射设值绕过了 private 限制,这也是为什么字段注入能工作但不符合封装设计的本质原因。
“三级缓存为什么能解决循环依赖?”
回答的时候把三个缓存逐步说清楚,强调“提前暴露半成品对象引用”这个思路,再解释 AOP 情况下三级缓存存在的必要性——如果不需要处理 AOP,理论上二级缓存就够了。这个补充细节是加分项,能体现出你理解 Spring 设计者在第三级放 ObjectFactory 的真实意图。
“手写 Spring 的话,IO 容器和 DI 核心需要实现什么?”
网上有很多手写 Spring 的教程,核心其实就四个环节:扫描类路径、解析注解、创建 BeanDefinition、通过反射实例化并完成依赖注入。我自己手写过一次简化版之后,对 Spring 底层的理解确实通透了很多。真要准备这类问题,用一个 Map 模拟 Bean 容器,用反射遍历字段做 Autowired 注入,基本上就能说清楚全过程。
5.4 一份可以复用的避坑清单
- 不要在一个类里混用三种注入方式。字段把代码写到一半发现加了个 Setter,这种混乱最影响维护。
- 构造器参数超过五个先审视类的职责,再考虑拆分。
- 使用 Lombok 的时候注意,
@RequiredArgsConstructor生成的构造器包含所有 final 字段,如果某个字段初始化后被替换了,可能绕过注入机制,要检查代码逻辑。 - 在单元测试里尽量用构造器直接创建被测对象,避免依赖 Spring 上下文。这样测试速度快、隔离性好。
- 有条件的话,在 CI 里加上 Sonar 或 Checkstyle 规则,把字段注入告警变成构建错误。
我在实际项目里还踩过一个比较隐蔽的坑:Spring Boot 多模块项目中,某些基础模块开放了接口给第三方调用,内部实现类没有显式标注@Service,而是写在了一个@Configuration里通过@Bean方式注册。这种情况下,如果字段直接用接口类型做@Autowired,容器启动时会发现有两个实现但没有任何限定条件,必须用@Qualifier或者把其中一个实现标记为@Primary。这类问题非常隐蔽,遇到启动报错先检查是不是多实现导致的,能省下大量排查时间。
另外,如果你想深入理解 DI 的运行机制,建议花一个周末看看AutowiredAnnotationBeanPostProcessor的源码,再对照DefaultListableBeanFactory.resolveDependency()走一遍流程。很多网上解释含混不清的地方,比如 byType 匹配失败之后如何回退 byName、required=false 是怎么生效的,看完源码都会豁然开朗。我自己当初就是靠这条路径把 Spring 这块基础彻底打扎实的,效果远胜过刷十篇面试题文章。