ARTICLE DETAIL

资讯详情

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

Spring依赖注入机制详解:构造器注入、@Autowired与循环依赖避坑

Spring依赖注入机制详解:构造器注入、@Autowired与循环依赖避坑 写这篇文章之前我先说个现象团队里做技术分享时我让大家把Spring中Bean的注入方式写一写。结果有人写了一条Autowired有人写了构造函数注入还有人写了Resource但几乎没人能把几种方式的区别、匹配规则、适用场景讲完整。这个问题看着基础却是整个Spring容器最核心的逻辑也是后来排查问题、理解循环依赖和三级缓存的起点。这篇我把Spring中Bean的注入方式从头到尾拆一遍——从XML时期的经典注入到注解驱动的Autowired和Resource再到生产项目里怎么选型、哪些坑最常见一次讲透。适合刚接触Spring的初学者也适合想在代码评审或面试里把注入机制讲明白的老手。1. 先搞清楚一件事注入解决的是“谁来找谁”的问题很多教程上来就列各种写法但没讲清楚为什么需要注入。我习惯用一个场景引入如果写一个用户下单功能OrderService要调用UserService查询用户信息再调用OrderRepository保存订单。不用Spring的典型写法是这样的public class OrderService { private UserService userService new UserService(); private OrderRepository orderRepository new OrderRepository(); public void createOrder(Order order) { User user userService.getUser(order.getUserId()); orderRepository.save(order); } }问题马上来了OrderService和UserService、OrderRepository被硬编码绑定在一起。哪天UserService的构造函数加了参数或者换成接口实现类OrderService的代码就得跟着改。再往后如果UserService内部还需要OrderRepository对象的创建顺序也会变成一场噩梦。这就是耦合而Spring的IoC控制反转之所以出现就是为了解决这个“谁来找谁”的问题。1.1 Bean和容器先理清现在项目里到处是Component、Service、Repository这些注解标记的类就是Spring帮你管理的Bean。所谓Bean简单理解就是“由Spring容器创建并维护生命周期”的Java对象。容器负责两件事把一个Bean实例化出来再把它依赖的其他Bean“喂”给它。这个“喂”的动作就是依赖注入DI。顺着这个思路你会发现IoC和DI其实是同一件事的两个视角IoC说的是控制权反过来了——以前对象自己new自己依赖的东西现在由容器统一创建和分配DI说的是容器具体怎么把依赖交给对象——通过构造函数参数、Setter方法或属性字段。两者指的是同一个机制只是站的角度不同。1.2 注入这个动作背后有几层工作容器创建一个Bean并不是“new完就结束”。以注解配置为例完整流程大致是扫描类路径找到带Component这类注解的类注册成BeanDefinition。根据BeanDefinition里的信息实例化对象对应构造器。对所有被Autowired、Resource标记的字段或方法去容器里找对应的依赖执行注入。调用BeanPostProcessor相关回调执行PostConstruct这类初始化逻辑。把完整的Bean放入一级缓存单例池供后续使用。在第三步里如果依赖找不到、找到多个或者依赖本身没准备好就会抛异常。所以理解和排查注入问题本质上就是理解容器“按什么规则找依赖”。1.3 和直接new相比注入带来的直接收益用注入替代直接new最直观的好处是可以随时换实现而不改调用方代码。比如OrderRepository是个接口只要容器里有对应的实现Bean注入的地方不用动。还有一点容易被忽略通过容器管理Bean默认生成的是单例对象同一个实例可以被多处注入共享避免每个类各自new一份带来的内存浪费和状态不一致。这也是后面讲循环依赖时会反复用到的基础——单例缓存和三级缓存正是针对单例Bean设计的。2. 从XML时代走来的三种经典配置注入构造器、Setter与工厂方法Spring 3.0注解流行之前XML文件是配置Bean的主要手段。现在虽然大多数项目已经是全注解了但理解XML时代的三种注入方式对你读懂老项目、理解框架底层都很有帮助。这三种方式分别是构造器注入、Setter注入和工厂方法注入。2.1 构造器注入依赖在对象出生时就位构造器注入的理念很朴素对象不能没有依赖才能干活那就把依赖作为构造函数参数传进去。XML里这样配置bean iduserService classcom.example.UserService/ bean idorderService classcom.example.OrderService constructor-arg refuserService/ /bean对应的Java类是public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } }这里有个关键点因为依赖是通过构造器传入的userService字段可以声明为final。final字段意味着对象一旦创建依赖关系就不可变不存在“对象已经创建了但依赖还没赋值”的中间状态。这是构造器注入最大的优势也是我后来在代码评审里坚持推荐它的主要原因。2.2 Setter注入允许Bean先出生、再装配Setter注入则允许容器先用无参构造函数实例化Bean再通过setter方法把依赖设进去。XML写法bean idorderService classcom.example.OrderService property nameuserService refuserService/ /beanJava类里对应提供一个setterComponent public class OrderService { private UserService userService; Autowired public void setUserService(UserService userService) { this.userService userService; } }Setter注入的灵活性在于依赖可以是可选的Bean即使有个别依赖没配上也能创建出来只是功能上会缺东西同时它允许你在运行中通过调用setter重新设置依赖这在写测试代码或者做特殊场景替换时比较方便。代价是字段无法声明为final对象可能长时间处于“不完整”状态。因为Spring的XML和注解都支持这种写法早期EJB风格的代码里经常能看到它。2.3 工厂方法注入让Spring与旧代码握手工厂方法注入分静态工厂和实例工厂这种方式的出现场景大多是“某个类不是你自己写的构造函数不对外开放只有工厂方法能拿到实例”。比如老项目里常见的bean idconnection classcom.example.ConnectionFactory factory-methodgetInstance/Spring会调用ConnectionFactory.getInstance()静态方法把返回的对象作为Bean注册到容器。实例工厂类似区别是工厂本身也是容器里的一个Beanbean idmyFactory classcom.example.MyFactory/ bean idservice factory-beanmyFactory factory-methodcreateService/这种注入方式现在用得少但你在研究框架源码时经常会看到类似的模式。框架内部的Bean方法本质上也可以看作一个实例工厂——Spring通过调用Configuration类里的Bean方法把方法返回值的对象交给容器管理。这么一想你对Bean的理解就能提升一个层次。三种方式的核心价值对比我整理成一张表注入方式依赖赋值时机是否支持final字段灵活性适用场景构造器注入对象创建时支持较低强制依赖、依赖关系稳定的核心类Setter注入创建后通过setter不支持较高可选依赖、测试中需要替换实现的类工厂方法注入工厂方法返回时视情况高非自己编写的类、遗留代码、框架集成3. Autowired和Resource两种注解注入的匹配逻辑与对冲方式注解普及之后写注入配置确实轻松了但这也带来一个后果很多人只知其然不知道注解背后是按什么规则去找Bean的。这个规则不掌握遇到“明明有两个实现类Autowired却报错”或者“字段是null但又没报错”的情况时排查思路就容易乱。3.1 Autowired默认按类型匹配再按名称兜底先说Autowired它由Spring提供核心匹配策略是三步Component public class OrderService { Autowired private OrderRepository orderRepository; }第一步容器先去类型池里找OrderRepository类型的Bean。如果容器里只有一个直接注入如果类型找不到报NoSuchBeanDefinitionException如果找到多个同类型Bean进入第二步——Autowired会尝试根据字段名去找。比如字段名叫orderRepository容器里正好有一个Bean的name也是orderRepository那就用这个如果按名称还是找不到就会抛出NoUniqueBeanDefinitionException提示你有多个候选无法确定。所以Autowired不是“无脑按字段声明去取”它是按类型优先、名称兜底的策略。写代码时让字段名和Bean名保持一致能省掉大量Qualifier。3.2 Qualifier和Primary多实现类场景的两套解法多实现类时有两个控制方向。如果你只是想让某个实现成为“默认选择另一个”在定义Bean的地方加PrimaryComponent Primary public class OracleDao implements Dao { ... } Component public class MysqlDao implements Dao { ... }这样Autowired Dao dao默认注入OracleDao。如果某个特殊场景你想明确选择MysqlDao就在注入点用Qualifier(mysqlDao)Service public class ReportService { private final Dao dao; Autowired public ReportService(Qualifier(mysqlDao) Dao dao) { this.dao dao; } }Primary和Qualifier可以理解为两套开关Primary是“在不特别指定时默认选我”Qualifier是“在某个具体的注入点我就是指定要我”。两者配合可以做到既省心又精确。在实际开发中我建议尽量用Qualifier把你的意图写明白Primary只用于“这个Bean确实是最常用默认实现”的场景避免隐式规则太多导致后来人看不懂注入关系。3.3 Resource按名称优先JSR-250标准下的另一套规则Resource来自JSR-250是Java EE时代就有的注解。它的匹配逻辑和Autowired正好相反先按Bean名称找找不到再按类型找。看这个例子Component public class OrderService { Resource private OrderRepository orderRepository; }容器先找name为orderRepository的Bean找到就用如果容器里没有这个名字再回退到按类型匹配。如果连类型也没有才报错。你也可以直接指定名称Resource(name specialOrderRepository) private OrderRepository orderRepository;使用Resource的好处在于它不依赖Spring独有的注解理论上在别的实现了JSR-250规范的容器里也能用。不过在纯Spring项目中Autowired的支持更完整能配合Qualifier、构造器注入等多种场景所以Spring社区的主流建议还是Autowired。但读懂Resource非常重要——它在按名称注入的场景下比Autowired更直接也更容易排查问题。3.4 不只是对象Value和ConfigurationProperties把配置也注入进来注入的不仅是Bean还可以是配置值。最基础的是ValueComponent public class DataSourceConfig { Value(${spring.datasource.url}) private String url; Value(${spring.datasource.username}) private String username; }${...}语法会从application.properties或application.yml中读取对应key的值这对少量配置字段很方便。如果是一组有结构的配置更推荐用ConfigurationProperties一次性绑定到一个类上Component ConfigurationProperties(prefix spring.datasource) public class DataSourceProperties { private String url; private String username; private String password; // getter/setter... }这样Spring会按照前缀批量完成字段映射。你知道prefix、user、password和配置项的对应关系配置文件里多了什么、少什么一眼就能看出来。这两种方式本质上也是“向Bean里注入外部数据”和注入其他Bean是同一个思路只是数据来源变成了配置中心。4. 生产项目里我为什么偏向构造器注入前面几代方案各有特点但落到现在的Spring Boot项目里我的建议非常明确能用构造器注入就优先用构造器注入。这不是说其他方式不能用而是构造器注入解决了我最关心的几个问题——不可变性、可测试性和依赖完整性。4.1 构造器注入的几个硬优势先说不可变性。字段可以声明为final这意味着一旦对象创建完成依赖就固定了不可能出现某个地方通过setter把依赖替换掉然后影响全局。在多线程环境下一个构造完成后依赖不再变化的对象状态更安全也更好推理。其次是依赖完整性。如果依赖是构造器的必要参数你想创建一个OrderService却少传一个依赖编译器就会直接报错。而字段注入或Setter注入依赖缺失不会被编译器发现只会在运行时NullPointerException。测试的时候这个差别更明显用构造函数直接new一个对象缺什么一目了然用字段注入还得借助反射或者Spring容器才能把依赖塞进去。第三是能更早暴露设计问题。当一个类的构造函数参数超过四五个时你立刻会感觉到“这个类承担了太多职责”。而字段注入把这种坏味道隐藏得很深代码看起来“清爽”其实维护起来头大。我一般要求依赖超过5个的类强制拆分构造器注入就是帮我们及早发现这个信号的工具。4.2 字段注入为什么在代码评审里经常被挑战字段注入是迭代最快、写起来最省事的方式Service public class OrderService { Autowired private UserService userService; }但它的问题恰恰出在这个“省事”上单元测试时不启动Spring容器字段就是null你只能借助Mockito的InjectMocks这类工具还得靠反射找字段类层面依赖关系不清晰别人看一眼代码无法马上知道这个类到底需要哪些Bean也没有办法声明final。这些都降低了代码的可维护性。所以现在很多团队的代码规范里字段注入是默认禁止的只允许构造器注入或少数特殊的Setter注入。4.3 多实现类怎么优雅注入集合注入与泛型注入多实现类场景下如果注入的不是单个对象而是所有实现可以用集合注入Service public class ReportService { private final ListReportGenerator generators; public ReportService(ListReportGenerator generators) { this.generators generators; } }Spring会把容器中所有ReportGenerator类型的Bean按顺序注入成一个List。新加一个实现类不需要改动ReportService只要符合接口定义就会被自动发现这比硬编码一个“实现类列表”优雅得多。泛型注入也很有意思假设有一个通用的仓库接口public interface BaseRepositoryT { ... } Repository public class OrderRepository implements BaseRepositoryOrder { ... } Repository public class UserRepository implements BaseRepositoryUser { ... }你在某个类里写Autowired private BaseRepositoryOrder orderRepository;Spring的ResolvableType机制会根据泛型参数去匹配对应的实现。这算是注入中比较高级的玩法掌握它对理解复杂框架的装配逻辑很有帮助。5. 循环依赖与三级缓存注入机制里的“紧急预案”聊注入必然绕不开循环依赖因为它是注入机制中最典型的“异常剧本”。所谓循环依赖就是A依赖BB又依赖A。没有容器帮你兜底的话这几乎是个无解的死锁问题——创建A要B创建B又要A。Spring在单例Bean场景下用三级缓存解决了大部分循环依赖但注意不是所有循环依赖都能解。5.1 三级缓存到底是哪三级Spring维护了三个Map分别对应三种状态一级缓存singletonObjects存放已经完整创建的单例Bean所有正常注入的Bean最终都在这。二级缓存earlySingletonObjects存放已经被提前暴露、但还没完成完整初始化的早期Bean引用注意这里的引用是不完整的。三级缓存singletonFactories存放ObjectFactory工厂对象用来在需要“提前暴露”时生成早期的Bean引用。为什么需要三级而不是两级核心在于三级缓存里存的是ObjectFactory不只是对象实例。这个工厂可以在生成引用前后执行一些扩展逻辑比如AOP代理。如果一个Bean最终需要被代理三级缓存可以在提前暴露引用时就生成正确的代理对象或者延后处理。如果只有二级缓存相当于把早期引用提前定死后面再做AOP增强就不好办了。Spring把这个问题拆成三级的本质是把“何时生成引用”和“引用长什么样”分开控制。5.2 哪些注入方式能通过三级缓存走到“山重水复”默认情况下Spring创建A的大致流程是这样的创建A的实例构造函数执行完但字段还没注入。把A对应的ObjectFactory放入三级缓存。开始填充A的属性发现需要B于是去创建B。B的执行流程一样先实例化再填充属性发现需要A。B去容器里找A发现一级、二级都没有但三级里有A的ObjectFactory于是调用工厂生成一个A的早期引用放入二级缓存并把这个早期引用注入给B。B顺利填充完自身其他属性走完初始化流程最终放入一级缓存。A继续执行自己的属性填充这次可以从一级缓存拿到完整的B后续初始化完成也放入一级缓存。这个链条成立的关键是A在填充属性之前已经被放入三级缓存“提前暴露”了。而这一步依赖的是“实例化A”和“填充A的属性”两个阶段分离。字段注入和Setter注入天然满足这个分离条件。5.3 构造器注入为什么解不开循环依赖如果你用构造器注入循环依赖就解不开了。因为构造器注入是在“实例化阶段”就要把依赖传进去A还没被放入三级缓存就需要B参与构造而B同样在构造时需要A此时A根本还没有被创建出来三级缓存里自然也找不到它。死锁发生在“出生”阶段双方都要求对方先出生谁都无法让一步。Spring Boot 2.6之后默认禁止了循环依赖项目里有循环依赖会直接启动失败。如果确实碰到了老项目中的循环依赖要么重构把A和B之间互相调用的部分拆到第三个类去要么用Lazy打破循环Service public class A { private final B b; public A(Lazy B b) { this.b b; } }Lazy在这里的含义不是“B是懒加载”而是告诉容器给A注入一个B的代理对象等到真正调用B的方法时再去容器里拿真正的B。这样就把“必须现在拥有B”的硬约束变为了“将来能够拿到B”的软约束。这是我在老项目里最常用的应急手段但能规避就规避不要把Lazy当成常态方案。实话说我排查过不少因为循环依赖引发的启动失败十有八九都指向设计问题两个类职责边界不清互相依赖太深。要么抽层出来要么把公共部分下沉最终都能通过合理拆分解决而不是靠开关配置硬撑。5.4 如果启动了循环依赖开关兜底不代表解脱Spring Boot 2.6之前循环依赖默认是允许的。很多人可能没意识到项目里能启动成功可能正依赖着三级缓存这套“紧急预案”。一旦依赖被允许后面做AOP增强、事务代理时就可能出现“注入的引用和实际代理不一致”这种极难排查的奇怪现象。所以即使老版本能跑建议还是逐步清理循环依赖关系并有意识在配置里开启spring.main.allow-circular-referencesfalse做校验让启动期直接暴露问题而不是把隐患埋在运行期。6. 写代码时最容易踩的注入坑排查链路与实战对策注入机制理论讲完最后落到实际开发。这里我挑几个出现频率极高的坑它们都是“代码看着没问题但运行时就是不对”的代表。每个坑我都会给出排查链路和具体对策这些才是我在日常支持团队时最常写进文档里的部分。6.1 用new创建出来的对象里面的Autowired为什么是null这个坑新手遇到的最多。场景很典型某个工具类里写了一个方法方法里new了一个OrderService然后调用orderService.createOrder(...)结果userService是null。原因不复杂Autowired之所以能把Bean注入进来前提是这个对象本身是由Spring创建和维护的。你用new新造出来的对象完全脱离了容器管理里面的依赖标注自然不会被填充。这个对象是“孤儿Bean”容器对它一无所知。排查思路也直接先确认这个类有没有被Spring扫描到比如它有没有Service、Component这类注解或者有没有在配置类中Bean注册再确认调用方是不是被容器正常注入的Bean最后才是确认注入字段名、类型有没有歧义。如果只是想在某个工具方法里临时用一下某个Bean正确姿势是把工具类本身也交给Spring管理或者通过构造器把依赖传进来而不是在普通Java类里new一个带注入注解的Bean。6.2 静态工具类、工具方法怎么注入依赖静态方法里要使用Spring管理的Bean是另一个高频场景。比如一个ExcelExportUtil.export(...)是静态方法里面想调用ExportConfigService。直接写字段注入肯定不行静态字段不能通过实例Bean注入。我常用的方案是把这个工具类本身做成Bean保留静态方法但使用一个被注入的实例成员做“桥接”。多说一句代码这种实现至少要交代清楚“为什么不能用常规注入”静态方法不属于任何实例Spring容器只能给实例字段注入静态字段不在管理范围内。代码可以这样写Component public class ExcelExportUtil { private static ExportConfigService exportConfigService; Autowired public void setExportConfigService(ExportConfigService service) { ExcelExportUtil.exportConfigService service; } public static void export(Order order) { ExportConfig config exportConfigService.getConfig(order.getType()); // 导出逻辑 } }注意这里用的是构造器注入之外的方法在Autowired的Setter方法里把实例字段赋给静态字段这样静态方法就能拿到容器管理的Bean了。不过这个方案有个副作用静态字段是全局状态Spring容器销毁或重新初始化时这个值可能残留。如果要写单元测试也得先把这个静态字段重置掉。所以我通常建议能改造的就不要用静态方法把ExcelExportUtil做成普通的Service类用实例方法依赖关系干净清爽。6.3 注入接口还是注入实现类影响的不只是可替换性很多关于注入的报错根子不在注入语法而在“你注入的是接口还是类”。如果只有一个实现类注入接口和注入实现类都能跑一旦出现第二个实现类差别立刻显现。注入接口时你写的是Autowired private Dao dao容器需要确定用哪个实现注入实现类时写的是Autowired private MysqlDao mysqlDao没有歧义但将来想切换到Oracle实现这里就得改代码。我的经验是对外接口明确依赖侧就注入接口配合Qualifier做切换如果一个类是内部私有的实现细节并不需要被替换那直接注入具体类也可以。只是要理解注入具体类会把一个内部实现细节暴露给依赖方将来改动范围会变大。回到代码评审如果看到某个Service里注入了另一个Service的具体实现类我会多问一句“这个具体类未来可能被替换或扩展吗”如果可能建议改成接口。6.4 泛型注入、集合注入的排序问题与常见误用集合注入时多个实现类的顺序默认不保证如果有顺序要求可以给Bean加Order注解或者在实现类上实现Ordered接口。Spring会按order值从小到大排序后注入List。这个细节在策略类、过滤器链、消息处理器这类场景中非常关键。Component Order(1) public class FirstHandler implements Handler { ... } Component Order(2) public class SecondHandler implements Handler { ... }Order值小的排在前面不写默认是最低优先级。踩过的坑是忘了加Order导致依赖链上某个校验逻辑在总流程里位置不对线上数据格式校验顺序变了问题还不好定位。另外泛型注入在多个泛型参数极其接近时也会出现匹配失败比如BaseRepositoryOrder和BaseRepositoryUser如果Order和User存在继承关系Spring的泛型匹配机制就可能遇到一些边界情况这时用Qualifier直接指定更稳妥。6.5 排查注入问题的固定套路最后分享一个我自己的排查方法论。注入报错或注入为null时按这个顺序检查基本能覆盖90%的问题确认目标Bean是否存在查启动日志、Component注解、Bean方法有没有被执行。确认Bean的类型和名字多个同类型Bean时看Primary、Qualifier、Resource(name...)是否正确。确认注入点是否在容器管理的Bean里new出来的对象里所有注入都是无效的。确认是否涉及循环依赖如果启动日志里有BeanCurrentlyInCreationException说明A和B互相依赖需要拆分或Lazy。确认配置文件是否正确Value注入的key是否存在ConfigurationProperties的prefix是否正确配置值类型是否匹配。按这个顺序做大部分注入问题都能定位到具体层面。我个人在实际项目里的体会是注入方式本身没有绝对的对错但生产项目优先构造器注入、代码评审重点看依赖数量、遇到特殊场景静态方法、多实现类、泛型时能明确匹配意图这三点做到了工程的注入体系基本不会出大乱子。如果你正准备升级Spring Boot 2.6以上的版本建议顺便把循环依赖的开关关掉用启动报错倒逼一次全面的依赖梳理——虽然过程中会有阵痛但梳理完的项目结构清楚不只是“能跑”这一个优点。
返回列表