拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring依赖注入三方式对比:构造器、Setter、字段注入与循环依赖解析

Spring依赖注入三方式对比:构造器、Setter、字段注入与循环依赖解析

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 这块基础彻底打扎实的,效果远胜过刷十篇面试题文章。

返回列表