前几天帮一位朋友做Spring面试复盘他卡在了一道很基础的问题上Spring的三种注入方式是什么他张口就答Autowired、Resource、Inject。面试官追问一句那这三种注解和三种注入方式是什么关系他就接不上来了。这个场面其实很典型也是很多Spring开发者的共同盲区Spring语境下常说的三种注入方式指的是构造器注入、Setter注入、字段属性注入而Autowired、Resource、Inject只是实现注入的注解。一个讲的是把依赖送进来的路线一个讲的是如何标记依赖需要送进来这是两个维度的东西。这篇文章我会把三种注入方式从代码形态、底层执行时机、循环依赖表现到真实项目里的选型逻辑完整梳理一遍。不管是准备面试还是日常写Spring Boot想规范代码都可以直接参考。1. 注入方式不是注入注解先把这两个概念拆开平时我们嘴上说的注入方式严格讲是在回答一个问题依赖究竟通过什么入口进入当前对象是构造器参数、是Setter方法还是直接打在字段上的注解。这三个入口决定了对象的装配时机、可变性和可测试性。而Autowired、Resource、Inject这些注解只是告诉Spring这个位置需要注入它们可以出现在任意一种入口上。1.1 三种注入方式的原始定义与代码形态先说最标准的三种方式用一段简单的UserController依赖UserService的代码来对比。字段注入是绝大多数人入门Spring第一眼看到的形态RestController public class UserController { Autowired private UserService userService; }Setter注入是在类里提供一个公有的Setter方法把注解打在方法上RestController public class UserController { private UserService userService; Autowired public void setUserService(UserService userService) { this.userService userService; } }构造器注入则是通过构造器参数把依赖传进来RestController public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } }注意第三种写法里构造器只有一个参数Spring 4.3之后可以省略Autowired容器会自动选择这个构造器完成注入。如果类里有多个构造器就必须在其中一个上面显式标注Autowired否则Spring默认调用无参构造器那个需要参数的构造器会被忽略。1.2 被遗忘的第四种接口注入严格说依赖注入发展历史上还真出现过第四种方式——接口注入。它的做法是定义一个注入接口比如UserServiceAware让需要UserService的类实现这个接口Spring在初始化Bean时检测到Aware接口就会调用接口里声明的setUserService方法把依赖送进去。Spring后来保留了大量类似的Aware接口比如ApplicationContextAware、BeanFactoryAware但它们已经不是常规业务依赖的注入通道更多是让组件感知容器环境。所以现在面试和工程实践里讨论三种注入方式说的就是构造器、Setter、字段这三种接口注入作为背景知识了解即可。1.3 注入注解到底是什么角色把注解梳理清楚这个维度的概念就不会混了。注解来源默认装配策略AutowiredSpring框架2.5引入按类型查找多个候选时按字段名/参数名兜底ResourceJSR-250规范按名称查找找不到再按类型InjectJSR-330规范和Autowired行为基本一致需要额外依赖这三个注解都可以放在构造器、Setter、字段上所以注解和注入方式是组合关系不是并列关系。面试中如果要回答三种注入方式无论如何都要先把这个区分讲出来否则很容易被追问到失分。2. 字段注入代码最少争议最大字段注入的流行程度和它的争议程度完全成正比。任何一个在IntelliJ IDEA里写过Autowired private XXX xxx;的人都见过那个黄色警告提示——Field injection is not recommended建议换成构造器注入。但你问十个这样写的开发者八个会告诉你能用就行。这段我聊聊为什么它能用但代价在哪里。2.1 一行注解的诱惑字段注入最大的优势就是写起来省事尤其当类里依赖很多的时候页面一拉下去全是注解几乎不用思考。我早年接手过一个老项目一个Service类里堆了十几个Autowired字段新增依赖变得非常轻松于是所有人都在往里面加字段。这种无痛的追加方式其实是最危险的地方——它在掩盖类职责膨胀的问题。打个比方。你装修房子如果材料想什么时候买就什么时候买工人想什么时候进场就什么时候进场你可能在施工中不断改变主意最后房子会变成一个功能混乱的混合体。字段注入就提供了这种随时塞东西进类里的便利。构造器注入则强迫你在开工前把所有工人、材料都列清楚依赖一多你立刻会意识到这个类管得太宽了。2.2 官方不推荐字段注入的真正原因Spring官方文档在讨论构造器和Setter注入时明确表达过对字段注入的保留态度。我把字段注入的问题拆成几个实际会踩到的点来讲。第一是破坏不可变性。字段注入的依赖无法声明为final因为final字段必须在构造器里或者声明时赋值。这意味着这个依赖在对象创建之后理论上可以被修改虽然正常情况下没人会去setField但反射工具、代理框架都可能绕过这层限制。一个设计良好的组件依赖关系应该从创建那一刻就固定下来。第二是脱离容器之后组件基本不可用。字段注入的对象直接new出来依赖全是null你没法脱离Spring容器去组装它。反过来构造器注入的对象任何时候只要传入依赖就能完成初始化这是组件复用和单测的前提。第三是依赖关系变得不透明。字段藏在类中间IDE里扫一眼构造器签名就能数清依赖这件事对字段注入不成立。依赖一多类的真实复杂度就被掩盖了。很多静态分析工具也是靠构造器参数来识别依赖图的。第四是单元测试被迫走捷径。后面第6章我会专门对比测试写法这里先给结论字段注入的单元测试要么被迫启动Spring容器要么用ReflectionTestUtils反射塞值两条路都比直接new一个带参数的对象要费劲。2.3 requiredfalse不是万能解药字段上写Autowired(required false)确实能表达这个依赖可选比如某些可选组件不配置就不注入。但字段注入的可选依赖同样面临空指针隐患你无法保证使用方在访问前判断了null。相比之下Setter注入搭配required false会更合适——它至少保留了一个运行中可以手动替换依赖的入口而字段注入连这个入口都没有private字段外部根本访问不到。所以我的结论很直接字段注入只能用于快速验证想法、写Demo、或者给已有的老代码做兼容。生产环境业务代码里我不建议用字段注入作为默认选项。3. Setter注入从XML时代的绝对主力到现在的补充位如果你经历过Spring 2.x、3.x时代一定对这套写法不陌生bean iduserController classcom.example.UserController property nameuserService refuserService/ /beanproperty标签会调用UserController上的setUserService方法完成装配。那个年代Spring还没有AutowiredXML是绝对主流Setter注入也因此统治了Spring早期的大部分实战场面。注解驱动流行之后Setter注入的位置变成了Autowired打在方法上但它的语义没有变。3.1 一个被严重低估的细节运行时可变很多人一提到Setter注入本能反应是过时了其实它有一个字段注入和构造器注入都不具备的能力依赖可以在运行时重新赋值。我举一个真实的场景。项目里有一个通知服务默认发给短信网关运维同学临时想切到邮件网关做灰度验证。如果这个网关依赖是靠构造器注入的你只能重启应用或者写一堆配置开关如果是Setter注入就可以通过一个配置中心、JMX、甚至一个管理接口去更新那个引用的指向。这种需求在业务代码里不常见但在框架代码、工具类、或者一些需要动态切换实现的组件里Setter注入是唯一合理的方案。Spring官方对Setter注入的定位也是用于可选依赖以及运行时可以重新赋值的依赖而不是过时弃用。3.2 Setter注入的适用场景与判断标准那实战里到底什么时候用Setter注入我给自己定了几条标准。第一依赖确实可选。比如日志上报组件、监控Collector启动时没有配置对应Endpoint系统也该正常运行。第二依赖存在合理的默认实现并通过Setter暴露替换入口。第三Bean的生命周期里有Spring或外部框架需要回调的入口典型例子是JSR-250的PostConstruct之前需要把一些资源注入进去。Setter注入的代价也要说清楚对象创建后存在一个依赖未填完的窗口期如果有人在初始化过程中提前调用了业务方法可能撞上NPE。所以在类内部使用依赖之前务必加空判断或者把Setter集中在初始化阶段调用。另外setXxx是公有的它会被当作公开接口暴露出去等于允许外部随意改依赖这在安全敏感的组件里要慎重。4. 构造器注入Spring团队的默认答案前面铺垫了那么多现在讲到真正的重点。Spring官方文档里有一段非常经典的建议The Spring team generally advocates constructor injection.团队之所以把构造器注入列为默认选项不是因为它新而是因为它能从语言层面保证对象的完整性。4.1 Spring 4.3带来的变化单构造器自动注入早年间写构造器注入是要加Autowired的public class UserController { private final UserService userService; Autowired public UserController(UserService userService) { this.userService userService; } }Spring 4.3之后规则简化了**只要类里只有一个构造器Spring就默认用这个构造器完成自动装配不需要任何注解。**多构造器场景下才需要在其中一个显式标注Autowired。这个变化意义很大因为它让构造器注入的代码可以做到零注解污染配合Lombok的RequiredArgsConstructor业务类里几乎看不到Spring的注解。4.2 构造器注入的四个核心优势我把构造器注入的好处归纳成四个词不可变、不空、完整、可测。不可变体现在final关键字上。依赖一旦在构造器里赋值整个生命周期内都不会被重新指向这在并发场景下能省掉很多心智负担也符合函数式编程推崇的尽量消灭可变状态。不空也很好理解。构造器注入的依赖不可能为null——如果你不传根本编译不过去。强行传null属于自欺欺人一般也不会有人这么干。这从源头消灭了一整类空指针问题。完整指的是完全初始化状态。Spring在构造器执行完成后才把Bean引用暴露给其他对象也就是说客户端拿到的对象它的所有强制依赖都已经就绪了。字段注入虽然也能注入但对象在构造器执行期间其实是空壳状态只有等属性填充阶段结束才完整只是这个差异平时感受不到。可测性是四个优势里最实用的。构造器注入让单元测试变成一行new就能搞定的事后面第6章会有详细对比这里先记住结论构造器注入的类在设计上就不依赖Spring容器也能被组装。4.3 构造器注入的常见顾虑与真实代价听到这里肯定有人问构造器注入有没有缺点有但问题往往不在注入方式本身而是它暴露出来的设计问题。最常见的顾虑是参数太多太难看了。构造器一长串参数确实不美观但你得停下来想想为什么这个类需要这么多依赖这正是构造器注入最珍贵的反馈机制——它让依赖数量一目了然逼着你拆分职责过重的类。字段注入不会给你这个反馈。第二个顾虑是解决不了循环依赖。这个说法是对的构造器注入的循环依赖无法通过Spring的三级缓存解决。但换个角度想循环依赖本身就是设计问题构造器注入恰好让你没法靠框架偷偷绕过去而是必须正面重构。关于这一点下一章展开细讲。第三个顾虑是我要加一个可选依赖构造器注入怎么做答案是用Java 8的Optional参数或者Spring的Nullable注解标注参数。Optional是标准做法依赖可能缺失时构造器参数声明成OptionalXxxSpring会把null包装成Optional.empty()传入效果比Autowired(requiredfalse)更安全。5. 循环依赖和三级缓存三种注入方式的分水岭聊到注入方式循环依赖和三级缓存几乎是绕不开的连招问题。很多Spring手册会把三级缓存讲成Spring为了解决循环依赖的机制这个说法不准确——准确说是为了在允许循环依赖的默认策略下尽可能让Setter和字段注入能跑通。而对构造器注入三级缓存无能为力。5.1 三级缓存到底缓存了什么先看一个经典循环依赖案例AService需要BServiceBService又需要AService。Service public class AService { public AService(BService bService) { ... } } Service public class BService { public BService(AService aService) { ... } }Spring容器启动时先创建AService发现它构造器需要BService就去创建BService结果BService构造器又需要AService两边都没创建完直接报BeanCurrentlyInCreationException。这就是构造器注入遇上循环依赖的标准结局。Spring的三级缓存对应着容器里的三个Map缓存级别存储内容作用一级缓存singletonObjects存放完整创建好的Bean成品二级缓存earlySingletonObjects存放早期引用Bean已实例化但属性还没填完三级缓存singletonFactories存放ObjectFactory用于延迟生成早期引用或代理对象用一句话解释一级是成品店二级是装修了一半的房三级是如果需要我可以现在给你造一把钥匙的工厂。5.2 为什么字段和Setter能解循环依赖构造器不行关键差异在于实例化完成这个时间点。字段注入和Setter注入的Bean构造器是空的或者无参的所以实例化很快就完成了Bean的骨架已经存在只是里面的依赖还没填。Spring会在这个时间点把Bean的早期引用通过三级缓存暴露出去于是AService虽然bService还没填但它已经是一个可以被其他Bean引用的真实对象了。BService拿到AService的早期引用完成自己的装配反过来再回头填AService的bService循环就解开了。构造器注入不一样构造器执行完之前对象根本不存在。AService构造器必须等BService传进来才能执行完但BService构造器又在等AService两边都在出生前僵持谁都没资格进三级缓存自然是谁也救不了谁。顺带解释一个更深的追问为什么Spring要设计三级缓存而不是直接放到二级缓存因为要兼顾AOP代理。三级缓存里存的是ObjectFactory它可以在真正有人获取早期引用时再决定是否生成代理对象。如果提前把代理对象生成好放进二级缓存那些没有被循环依赖引用的Bean也会白白执行代理创建逻辑时序上会出问题。三级缓存的价值就是懒——只有被需要时才把代理的创建动作触发出来。5.3 Spring Boot 2.6之后禁用循环依赖意味着什么从Spring Boot 2.6开始循环依赖默认被禁用了启动时如果检测到循环引用会直接报错只有通过spring.main.allow-circular-referencestrue才能恢复。这是Spring团队的态度转变以前靠三级缓存硬扛循环依赖是在给设计问题打补丁现在直接不让循环依赖存在逼你重构。对这两种姿态我举双手赞成后者。我接手项目时见过的最难维护的代码往往就是那些靠循环依赖周转的老系统Bean初始化顺序互相纠缠改一个地方炸一片。注入方式的选择不是背答案而是在引导你写出方向清晰的依赖图。那一部分确实存在循环依赖且一时改不完怎么办临时方案有两个在其中一个依赖上标注Lazy让Spring生成一个代理对象延迟注入或者改用ApplicationContext.getBean手动获取。注意这两种都只是缰绳不改变依赖混乱的本质最终还是要靠拆分AService和BService的职责来解决。6. 项目实战我现在的Spring Boot注入写法前面的理论知识最后都要落回到日常代码里。我现在的项目规范很简单**强制依赖全部走构造器注入可选依赖走Setter注入业务代码禁止字段注入。**下面把这套规范怎么落地讲清楚。6.1 强制依赖走构造器可选依赖走Setter以最常见的Controller-Service-Mapper三层结构为例RestController public class UserController { private final UserService userService; private final AuditService auditService; public UserController(UserService userService, AuditService auditService) { this.userService userService; this.auditService auditService; } }这两个依赖都是用户的必经路径缺了系统就没办法工作所以设计成构造器注入创建UserController的时候必须把它们传进来少一个都编译不过。可选依赖的例子比如一个指标上报组件没配置上报地址时系统也要正常工作Component public class MetricsReporter { private MetricsSink sink new LogOnlyMetricsSink(); Autowired(required false) public void setSink(MetricsSink sink) { if (sink ! null) { this.sink sink; } } }这种写法保留了运行中替换实现的能力又不会影响核心流程。判断标准就一句话**这个依赖缺失时组件还能不能正常工作**能就放Setter不能就进构造器。6.2 Lombok的RequiredArgsConstructor组合拳手动写构造器代码量不小尤其字段多了以后容易看花眼。我会用Lombok的RequiredArgsConstructor消除样板代码RestController RequiredArgsConstructor public class UserController { private final UserService userService; private final AuditService auditService; }RequiredArgsConstructor会为所有final字段生成一个全参构造器。因为Spring 4.3之后单构造器可以自动装配这里连Autowired都不用加Controller类干净得像纯POJO一样。大部分Spring Boot项目的Controller、ServiceImpl层我都推荐这套写法。有个小坑提醒一下如果类里存在非final字段RequiredArgsConstructor不会把它们放进构造器如果这个字段又需要依赖注入就会出现服务能启动但某个依赖是null的潜伏问题。所以我才会强调业务代码里不要用字段注入统一用final字段让构造器生成规则变得可预期。6.3 可测试性对比new出来和反射灌进去单测是注入方式最直观的试金石。同样一个UserController测试代码的差别非常大。构造器注入的测试UserService mockService mock(UserService.class); AuditService mockAudit mock(AuditService.class); UserController controller new UserController(mockService, mockAudit);字段注入的测试UserService mockService mock(UserService.class); AuditService mockAudit mock(AuditService.class); UserController controller new UserController(); ReflectionTestUtils.setField(controller, userService, mockService); ReflectionTestUtils.setField(controller, auditService, mockAudit);更糟的字段注入测试姿势是给测试类打上SpringBootTest把整个Spring容器拉起来依赖注入交给框架完成只为了测一个Controller。启动一次容器动辄几十秒跑一个微服务全量测试的时间成本完全失控。用构造器注入测试不需要容器、不需要反射纯Mock和多参数构造器就能组装出被测对象这才是真正可独立验证的单元测试。6.4 如果再被问到三种注入方式这么答最后给个面试参考框架按照这个顺序答基本是完整的先定义三种注入方式分别给出代码形态再对比差异核心差异是依赖入口不同导致装配时机和对象状态不同然后讲选型逻辑强制依赖选构造器可选依赖选Setter业务代码避免字段注入理由是不利于不可变性、测试和依赖可见性最后补一个加分项构造器注入的循环依赖无法被三级缓存解决Spring Boot 2.6之后默认禁止循环依赖所以这一点现在反而成了推动代码重构的正面特性。只要这几个点串起来讲清楚这道题不管是初级还是高级面试都能站稳。我个人现在写Spring Boot项目看到字段注入第一反应不是别人水平差而是想为什么会走到这一步——大概率是历史代码、赶进度或者纯粹没被它坑过。依赖注入方式说白了是一个设计反馈工具构造器参数变多了提醒你职责扛太重出现循环依赖了提醒你有模块绕圈了。与其在注入注解和方式的名词上纠结不如借这个规范把代码的依赖关系从一开始就理得干干净净。