依赖注入方式:构造器、Setter、字段注入的差异与循环依赖影响(085)

Spring与Spring Boot约 13 分钟阅读6,808 字符
知识库文档 86 / 251

一、为什么"用哪种注入方式"不是风格问题

081 讲了 IoC/DI 的整体框架,082 讲了 Bean 生命周期的各个阶段,083 讲了容器如何解析配置并驱动 refresh 流程,084 讲了作用域决定"造几个、给谁用"。这些内容有一个共同的隐含前提:容器要往 Bean 里"塞"依赖——但具体怎么塞,Spring 给了三条路:字段注入、Setter 注入、构造器注入。

很多人把这当成纯粹的代码风格偏好——"看着顺眼就行"。但实际上,选哪种注入方式会直接决定三件事:这个类能不能保证依赖不为空、能不能写单元测试而不依赖 Spring 容器、以及在出现循环依赖时容器是直接报错还是"想办法"绕过去。这三件事任何一个踩坑,代价都不小,所以这不是风格问题,是设计决策。

二、三种注入方式:语法与直觉

java
// 方式一:字段注入 —— 最常见,也最容易被滥用
@Service
public class OrderService {
    @Autowired
    private PaymentClient paymentClient;
    @Autowired
    private InventoryClient inventoryClient;
}

// 方式二:Setter 注入 —— 通过 setter 方法赋值,可选依赖的经典写法
@Service
public class OrderService {
    private PaymentClient paymentClient;

    @Autowired
    public void setPaymentClient(PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }
}

// 方式三:构造器注入 —— 通过构造方法一次性传入全部依赖
@Service
public class OrderService {
    private final PaymentClient paymentClient;
    private final InventoryClient inventoryClient;

    public OrderService(PaymentClient paymentClient, InventoryClient inventoryClient) {
        this.paymentClient = paymentClient;
        this.inventoryClient = inventoryClient;
    }
    // 类上只有一个构造器时,@Autowired 可以省略(Spring 4.3+)
}

三种写法效果看似相同——运行起来 paymentClient 都不是 null。但"怎么变成不是 null"这件事,恰恰决定了后面所有的差异。

文章配图
文章配图

三、容器内部怎么完成这三种注入

回顾 082 讲过的 Bean 创建三大步骤:实例化 → 属性填充 → 初始化。三种注入方式的关键差异就在于:它们分别发生在实例化阶段还是属性填充阶段。

3.1 构造器注入:发生在"实例化"这一步

容器创建 Bean 的第一步是调用构造方法得到一个实例。如果这个类只有一个构造器(或被 @Autowired 标注的构造器),容器必须在实例化之前就把所有构造参数准备好——这一步在 AbstractAutowireCapableBeanFactory#createBeanInstance 里,核心方法是 autowireConstructor():它会检查构造器的参数类型,逐个去容器里 getBean() 查找匹配的依赖,全部找到后才能调用构造器,"造出"这个实例。没有实例,就没有字段/Setter 可言——这意味着构造器注入的依赖,天然要求在"这个对象诞生的瞬间"就必须全部就位。

3.2 字段注入与 Setter 注入:发生在"属性填充"这一步

这两种方式发生在实例化之后——此时对象已经存在(哪怕字段还是 null),属于 populateBean() 阶段。@Autowired 注解的处理由 AutowiredAnnotationBeanPostProcessor 完成,它会扫描类里所有被标注的字段和方法,通过反射(字段注入)或方法调用(Setter 注入)把依赖塞进去。关键区别在这里:对象已经"活"着(构造器已经跑完),只是依赖还没填上。这个时序差异,正是后面循环依赖能不能被解决的根本原因。

文章配图
文章配图

四、不可变性:构造器注入的核心优势

java
// 构造器注入:依赖字段可以用 final,一旦构造完成,永远不可能是 null,也不可能被后续代码改掉
@Service
public class OrderService {
    private final PaymentClient paymentClient; // final —— 编译期就保证"必须赋值一次且只能一次"

    public OrderService(PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }
}

// 字段注入:无法使用 final —— 反射赋值发生在对象创建之后,final 字段在构造器跑完后就不允许再赋值
@Service
public class OrderService {
    @Autowired
    private PaymentClient paymentClient; // 只能非 final,运行期之前,这个字段理论上一直是 null
}

字段注入有一个经常被忽略的风险点:在依赖被容器填充之前,这个字段确实是**null**。绝大多数情况下这不是问题,因为容器会保证 populateBean() 在业务代码能拿到这个 Bean 之前执行完。但如果代码里有意无意绕开了 Spring 的托管方式创建对象(比如 new OrderService()、或者在构造器/@PostConstruct 之前尝试用这个字段),拿到的就是一个 NullPointerException,而且报错位置往往离真正的问题很远,排查成本高。

构造器注入把这个问题在编译期就消灭了:final 字段没有赋值编译都通不过,OrderService 也不可能存在"构造完成但 paymentClient 是 null"的中间状态——这就是"不可变性"带来的确定性。

文章配图
文章配图

五、可测试性:能不能脱离 Spring 容器写单测

java
// 构造器注入:单测里直接 new,配 Mock 对象,完全不需要启动 Spring 容器
@Test
void testOrderService() {
    PaymentClient mockPaymentClient = mock(PaymentClient.class);
    OrderService service = new OrderService(mockPaymentClient); // 依赖一目了然
    // ... 断言
}

// 字段注入:没有 setter,也没有能传参的构造器,单测想注入 Mock 只能用反射或 Spring 测试工具
@Test
void testOrderService() {
    OrderService service = new OrderService();
    ReflectionTestUtils.setField(service, "paymentClient", mock(PaymentClient.class)); // 绕过编译器
    // 或者引入 @ExtendWith(MockitoExtension.class) + @InjectMocks,本质也是反射
}

这是构造器注入在工程实践里被反复推荐的另一个原因:它把"这个类依赖了什么"变成了构造方法签名的一部分,天然可见、天然可传参。字段注入把依赖"藏"在了类内部,写单测时不得不借助反射工具(ReflectionTestUtils、Mockito 的 @InjectMocks)强行绕开 Java 的访问控制,这本身就是一种"逆语言设计"的做法——能编译通过不代表这是好的实践。

六、循环依赖:三种注入方式的核心分水岭

这是本篇最重要的一节,也是后面 086 篇要展开的三级缓存机制的直接铺垫。先看一个循环依赖场景:

java
@Service
public class ServiceA {
    private final ServiceB serviceB;
    public ServiceA(ServiceB serviceB) { this.serviceB = serviceB; }
}

@Service
public class ServiceB {
    private final ServiceA serviceA;
    public ServiceB(ServiceA serviceA) { this.serviceA = serviceA; }
}

6.1 构造器注入:直接抛异常,无法自动解决

启动时容器创建 ServiceA,发现需要先造出一个 ServiceB;创建 ServiceB 时又发现需要先造出一个 ServiceA——而 ServiceA 此刻还没构造完成(回顾第三节:构造器注入要求依赖在实例化之前就绪,此时 ServiceA 连实例都还没有)。容器识别出这种"自己等自己"的状态后,直接抛出:

code
org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'serviceA': Requested bean is currently in creation:
Is there an unresolvable circular reference?

构造器注入的循环依赖,Spring 没有办法自动解决——这不是 bug,是这种注入方式的时序特性决定的必然结果:对象都还没实例化,就没有一个"半成品引用"可以提前暴露给对方使用。

6.2 字段注入 / Setter 注入:可以被三级缓存解决

如果把上面两个类换成字段注入,情况完全不同:容器创建 ServiceA 时,先执行构造器(无参,瞬间完成),得到一个"半成品"实例——字段还是 null,但对象已经存在,可以把这个半成品的引用提前放进一个缓存(084 提到过的 singletonFactories/earlySingletonObjects,086 会详细展开)。接着开始填充 ServiceA 的字段,发现需要 ServiceB,于是去创建 ServiceB;创建 ServiceB 时同样先造出半成品,再填充字段,发现需要 ServiceA——这时容器去缓存里一查,"哦,ServiceA 正在创建中,但半成品引用已经有了",直接把这个半成品引用塞给 ServiceB。ServiceB 创建完成后,再回头把完整的 ServiceB 塞给 ServiceA 的字段。整个过程能够闭环,根本原因是字段注入把"实例化"和"依赖填充"拆成了两步,中间给了容器一个可以提前暴露引用的窗口。

文章配图
文章配图

6.3 为什么"提前暴露"这件事只能在属性填充阶段做

java
// 字段/Setter 注入的关键时序:
// 1. 无参构造器执行完成 —— 此刻已经有一个"半成品"对象(字段全是默认值)
ServiceA half = new ServiceA();
// 2. 半成品引用被放入三级缓存,此时其他 Bean 若需要 ServiceA,能拿到这个引用
singletonFactories.put("serviceA", () -> half);
// 3. 之后才执行属性填充(反射赋值 serviceB 字段)
half.serviceB = getBean("serviceB"); // 这一步才真正需要 ServiceB

对比构造器注入:new ServiceA(serviceB) 是一个不可分割的调用——参数必须在调用发生之前全部备好,中间不存在"半成品对象已存在但依赖还没填"的状态,容器无从下手,也就没有提前暴露引用的可能。这就是为什么"构造器注入 + 循环依赖 = 报错"几乎是所有 Spring 版本里稳定不变的行为,而字段/Setter 注入的循环依赖能被"悄悄"解决——这个"悄悄"本身也是构造器注入被更推荐的另一层理由:循环依赖往往意味着设计上有问题(模块耦合过重),字段注入把这个设计问题隐藏了,而构造器注入会在启动时就把它炸出来。

文章配图
文章配图

七、Spring 官方立场的演进

早期 Spring 文档对三种方式没有明显倾向,但从 Spring 4.x 之后的官方文档明确写道:"The Spring team generally advocates constructor injection"(Spring 团队普遍推崇构造器注入)。原因正是前面几节讲的:不可变性、依赖显式化、可测试性、以及能让循环依赖在启动阶段就报错而不是被悄悄绕过。字段注入在早期教程和示例代码里非常流行(写起来最短),但这更多是"演示方便"而非"工程最佳实践"的体现。

八、可选依赖:Setter 注入仍有的一块存在空间

构造器注入并非没有代价:如果一个类有 6、7 个依赖,构造方法参数列表会很长(这本身也是一种"代码坏味道"的信号,提示这个类可能职责太多)。而对于真正可选的依赖(没有它也能正常工作,只是少一些功能),Setter 注入或者构造器注入配合 @Autowired(required = false) / Optional<T> 是更合适的选择:

java
@Service
public class NotificationService {
    private final SmsClient smsClient;      // 必需依赖,构造器注入
    private MetricsReporter metricsReporter; // 可选依赖,Setter 注入,未配置也能正常工作

    public NotificationService(SmsClient smsClient) {
        this.smsClient = smsClient;
    }

    @Autowired(required = false)
    public void setMetricsReporter(MetricsReporter metricsReporter) {
        this.metricsReporter = metricsReporter;
    }

    public void notify(String msg) {
        smsClient.send(msg);
        if (metricsReporter != null) metricsReporter.report(); // 判空后可选调用
    }
}

九、三种方式对照决策表

维度 构造器注入 Setter 注入 字段注入
不可变性(可用 final) ✅ 支持 ❌ 不支持 ❌ 不支持
依赖是否显式可见 ✅ 构造签名即文档 ⚠️ 需要看方法 ❌ 需要看类内部
脱离容器单测难度 低(直接 new+传参) 中(需调 setter) 高(需反射工具)
是否适合必需依赖 ✅ 最佳 ⚠️ 可以但不推荐 ⚠️ 可以但不推荐
是否适合可选依赖 ⚠️ 需配合 Optional ✅ 经典场景 ⚠️ 可以但不显式
循环依赖能否被解决 ❌ 直接报错 ✅ 可被三级缓存解决 ✅ 可被三级缓存解决
Spring 官方推荐程度 ✅ 首选 特定场景 不推荐(示例代码常见)

文章配图
文章配图

十、选型决策路径

把前面的内容串成一条实践路径:先分类这个依赖是"必需"还是"可选"——必需依赖首选构造器注入(享受不可变性和显式依赖的好处,同时循环依赖会在启动期就报错,逼着你重新审视设计);可选依赖用 Setter 注入或**@Autowired(required=false)**;字段注入仅建议用在测试类、临时脚本等不追求工程规范性的场景,生产代码尽量避免。

文章配图
文章配图

十一、常见误区

  • "字段注入更简洁,性能也更好" —— 不对。三种方式的性能差异(反射调用 vs 方法调用 vs 构造调用)在实际场景中几乎可以忽略,简洁性也不能替代不可变性和可测试性带来的工程价值。
  • "构造器注入解决不了循环依赖就是 Spring 的缺陷" ——不对。这恰恰是构造器注入的设计意图:把隐藏的强耦合问题在启动阶段暴露出来,而不是靠框架"魔法"悄悄绕过去。
  • "只要用了 @Autowired 就是同一种注入方式" ——不对。@Autowired 可以标注在字段、Setter 方法、构造器上,标注位置不同,触发的容器内部路径(实例化阶段 vs 属性填充阶段)完全不同。
  • "Setter 注入和字段注入对循环依赖的处理是等价的" —— 基本对,二者都发生在属性填充阶段,都能被三级缓存机制覆盖,但字段注入是反射直接赋值,Setter 注入是方法调用赋值,触发点略有差异,实践中不影响循环依赖的可解性。

十二、总结

依赖注入的方式选择本质是在"确定性"与"简洁性"之间做权衡:

  1. 字段注入语法最短,但牺牲了不可变性、依赖可见性和可测试性,且把"依赖何时就位"隐藏在了容器内部时序里。
  2. Setter 注入适合真正的可选依赖场景,能配合判空逻辑优雅处理"没有也能工作"的情况。
  3. 构造器注入是 Spring 官方推荐的首选方式:不可变、依赖显式、易测试,并且会让循环依赖在启动期直接报错,而不是被三级缓存悄悄"化解"。
  4. 循环依赖能否被自动解决,根本上取决于注入方式是否在"实例化"和"依赖填充"之间留出了一个可以提前暴露半成品引用的窗口——构造器注入没有这个窗口,字段/Setter 注入有。

延伸阅读

  • Spring 源码:AbstractAutowireCapableBeanFactory#createBeanInstance、#populateBean、AutowiredAnnotationBeanPostProcessor
  • Spring 官方文档:Core Technologies → Dependency Injection → Constructor-based vs Setter-based DI
  • 本系列相关篇目:081(Spring IoC 与 DI)、082(Bean 生命周期)、083(BeanDefinition 与容器刷新)、084(Bean 作用域)、086(循环依赖:三级缓存与无法解决的场景)

面试官视角提问(不计入阅读时间)

Q1:Spring 官方推荐构造器注入,但如果一个类有多个构造器,容器怎么知道该用哪一个?

如果类上只有一个构造器,Spring 4.3+ 会自动把它当成注入点,不需要 @Autowired。如果有多个构造器,必须显式在其中一个上标注 @Autowired,否则容器会因为无法确定使用哪个构造器而抛出 BeanCreationException("no default constructor found" 或"more than one constructor found with arguments"之类的提示)。如果多个构造器都标了 @Autowired,同样会报错——@Autowired(required = true) 语义上只允许有一个"必需"的注入点。

Q2:构造器注入的循环依赖直接报错,有没有办法强行让它工作?

有,用 @Lazy 标注在其中一个构造参数上:

java
public ServiceA(@Lazy ServiceB serviceB) { this.serviceB = serviceB; }

@Lazy 会让容器注入一个代理对象而不是真实实例,代理对象在真正被调用方法时才去触发 getBean("serviceB")——这就把"立即拿到真实实例"的强制要求推迟到了"第一次实际调用",绕开了实例化阶段的死锁。但这只是一个应急手段,本质上还是在掩盖设计问题,正确做法通常是重新梳理两个类的职责边界,把公共逻辑抽出成第三个类,或者改用事件机制解耦。

Q3:同一个类里同时出现构造器注入和字段注入的**@Autowired**,容器的处理顺序是什么?

构造器注入永远先于字段/Setter 注入执行,这是由 Bean 创建的两大阶段决定的:createBeanInstance()(决定用哪个构造器、传什么参数)在前,populateBean()(处理字段和 Setter 上的 @Autowired)在后。所以即使代码里字段声明在前,实际生效顺序也是"构造器先跑完,实例已经存在,然后才轮到字段被反射赋值"。这也解释了为什么构造器方法体里不能访问那些用字段注入的依赖——此时它们还是 null。

Q4:三级缓存能解决 singleton 的循环依赖,那 prototype 的循环依赖能不能用同样的机制解决?

不能。三级缓存(singletonObjects/earlySingletonObjects/singletonFactories)是 DefaultSingletonBeanRegistry 专属的机制,只对 singleton 生效——它依赖"容器只需要造一个实例,可以把这个实例的引用提前登记、后续复用"这个前提。而 prototype 语义要求"每次都是全新实例",如果强行"提前暴露一个半成品再复用",就违反了 prototype 本身的定义。所以两个互相依赖的 prototype Bean 循环创建时,Spring 直接抛 BeanCurrentlyInCreationException,跟构造器注入的处理方式类似——都是"没有合法的机制可以安全绕过,所以直接报错"。

Q5:怎么从异常信息判断循环依赖是发生在构造阶段还是属性填充阶段?

看异常类型和堆栈的触发点:BeanCurrentlyInCreationException 且堆栈显示是在 getSingleton() 里判断"该 bean 正在创建中,且不允许提前暴露引用"时抛出的,通常对应构造器注入或者 allowCircularReferences=false 的场景;如果堆栈里出现 addSingletonFactory/getEarlyBeanReference 相关调用链,说明容器正在走"提前暴露半成品引用"这条路径,这条路径本身不会抛异常——只有当这条路径也无法闭环时(比如两个都是构造器注入)才会最终报错。生产环境排查时,直接看异常信息里"currently in creation"提示的 Bean 名称链条,通常就能反推出是哪两个类互相依赖,以及是不是构造器注入导致的。

Q6:如果业务上确实存在两个类互相调用,你会怎么设计来避免依赖注入层面的循环依赖,而不是依赖框架去"解决"它?

优先考虑的不是"用哪种注入方式绕过报错",而是"这两个类为什么会互相依赖"——常见的重构方向有三种:一是抽取公共逻辑到第三个无依赖的类,两者都依赖它而不是依赖对方;二是把其中一个方向的调用改成事件驱动(ApplicationEventPublisher 发布事件,另一方监听),物理上切断编译期依赖;三是如果互相调用只是为了共享某个数据或状态,考虑把这部分状态提升到一个共享的领域对象或缓存里,两个 Service 各自依赖这个数据对象而不是依赖对方。@Lazy 或字段注入能让代码"跑起来",但循环依赖本身往往是模块划分不合理的信号,能在设计阶段解决就不要留给运行时。