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

资讯详情

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

Spring依赖注入全解析:从@Autowired到循环依赖的底层原理

Spring依赖注入全解析:从@Autowired到循环依赖的底层原理 1. 搞懂注入先搞懂IoCBean管理这件事的本质是什么很多人一聊Spring的注入方式第一反应是不就是Autowired嘛。但如果你只是停留在会用注解的层面遇到循环依赖、Bean初始化顺序、多实例选择这些问题时就会一头雾水。我在刚工作的头两年也是这个状态直到把手里的Spring源码翻了几遍才真正理解了注入的本质。先说一个最基础的问题为什么要用注入在没有Spring的年代Java程序里对象之间的依赖关系全靠new。A类要用B类就在A类里new B()C类要用B类又在C类里new B()。这个逻辑放到小项目里没啥问题但项目一大了你就会发现B类的构造参数变了、B类的实现换成了另一个版本、B类的单例状态需要全局唯一——这时候所有new过B类的地方全都要改。而且最麻烦的是你没法在不改动A类和C类代码的前提下往B类里插入中间逻辑比如缓存、代理、日志记录。Spring的IoC控制反转容器做的事情就是把这个谁来创建对象、谁来管理对象的权力从业务代码手里拿走。业务类不再主动new依赖而是声明我需要什么——容器来负责把对应的实例送进来。这个送进来的动作就是我们说的注入Dependency InjectionDI。容器通过读取配置或扫描注解把所有需要管理的类实例化为Bean然后按照依赖关系把合适的Bean注入到需要它的地方。换句话说Bean的生命周期归容器管而注入是容器组装Bean之间关系的方式。这就像剧组拍戏。演员业务类不自己去街上招灯光师和化妆师依赖而是跟导演容器说我需要一个灯光师导演根据排期表配置/注解把合适的灯光师安排过来。演员只负责演戏谁来做灯光、灯光师什么时候到场、灯光师是不是让助手代理干活——这些演员都不用操心。理解了这一层你再看Spring里五花八门的注入方式就会明白它们本质上只有一个区别业务类是用什么姿势告诉容器我需要什么的——是写在构造方法里是写在Setter方法上还是直接写在字段上。这个姿势的差异直接决定了你的代码能不能被测试、会不会遇到循环依赖、类型不匹配时能不能早点暴露。而且近几年的Spring Boot项目里注入姿势也一直在演化从XML配置到注解驱动从字段注入到官方推荐构造器注入这套演进背后是有明确逻辑的不是随便改改风格。接下来的篇幅我们把几种主流注入方式掰开揉碎来讲顺便把Autowired、Resource、构造器注入这几个高频面试点一并说透。2. 三大经典注入方式逐个拆解构造器、Setter、字段注入Spring的Bean注入从实现路径上看其实就是三条路构造器注入Constructor Injection、Setter注入Setter Injection、字段注入Field Injection。这三种方式至今仍是Spring面试题里的常客也是日常代码里最常出现的写法。2.1 构造器注入最严谨也最推荐先看代码Service public class OrderService { private final UserService userService; private final ProductService productService; public OrderService(UserService userService, ProductService productService) { this.userService userService; this.productService productService; } }构造器注入的核心特征有三个第一依赖是final的。字段用final修饰意味着这个Bean一旦创建它的依赖关系就完全固定下来了中途不可能被替换。这在业务语义上非常干净——一个订单服务必须要用户服务和产品服务才能工作这依赖是硬性要求不是在特殊情况下才需要的可选依赖。第二创建时强制校验。容器在实例化OrderService时就必须找到UserService和ProductService这两个Bean找不到就直接报错。这种早失败机制比运行半天才发现注入失败要友好得多——启动时抛出的异常比运行时NullPointerException好排查一百倍。第三天然免疫循环依赖中的一部分问题。这里留个钩子后面专门用一节讲循环依赖你会明白为什么构造器注入在Spring里是红灯区。Spring官方在《Spring Framework Documentation》中明确推荐使用构造器注入理由是保证依赖不可变、保证依赖不为null、保证Bean完全初始化后才被使用。这也是很多规范团队把构造器注入定为唯一允许的注入方式的原因。不过构造器注入也有个被人吐槽的点当依赖特别多的时候构造方法的参数列表会变得很长。如果你发现一个类需要七八个依赖先别急着用Autowired而是应该停下来想想这个类是不是职责太杂了——这通常是设计问题的信号不是注入方式的问题。2.2 Setter注入灵活但容易踩漏Setter注入长这样Service public class ReportService { private DataSource dataSource; Autowired public void setDataSource(DataSource dataSource) { this.dataSource dataSource; } }或者配合配置使用bean idreportService classcom.example.ReportService property namedataSource refdataSource/ /beanSetter注入的核心特征是依赖可以在对象创建之后再设置。这带来几个特性依赖可以是可选的。你可以在Setter上不加Autowired那么这个依赖就是有就注入没有就null。这在某些默认值场景下有一定的灵活性。支持动态修改。因为Setter是普通方法理论上运行期可以再次调用修改依赖对象。当然实际项目里很少这么干但确实比构造器注入灵活。对循环依赖更宽容因为对象先创建出来了Setter在后续阶段再补上依赖。但Setter注入的坑也在这里。你没法用final修饰字段依赖非空需要你自己保证如果漏配了SetterSpring不会在创建时报错业务运行到某个分支时才冒出空指针。我接手过的老项目里就有过因为某个Setter没有加Autowired、Bean一直注了个null进去线上跑了半年才被发现的情况。所以Setter注入不是不能用而是适合可选依赖和默认值可覆盖的场景。如果你一个类有一半依赖是硬性的那还是老老实实用构造器。2.3 字段注入写起来最爽代价也最隐蔽字段注入就是大家最熟悉的写法Service public class OrderService { Autowired private UserService userService; Autowired private ProductService productService; }代码最简洁、最直观几乎零模板代码。Spring在创建Bean后通过反射直接把依赖写进字段里。但字段注入的问题在代码规模上来以后会越来越明显第一可测试性差。如果你在单元测试里想单独测OrderService不走Spring容器你会发现userService和productService都是null因为字段注入没法通过外部代码赋值除非加额外的setter或反射。你不得不为了测试去引入Spring的测试框架或者给字段注入的类额外写setter——那这字段注入就没意义了。第二对IntelliJ IDEA等IDE不友好。IDEA会对字段注入报出Field injection is not recommended的警告因为这种写法会掩盖依赖关系让依赖管理变得隐晦。第三难发现循环依赖的隐患。字段注入是先创建对象后注入字段它在处理循环依赖上反而比构造器表现好——这个表现好其实是Spring通过三级缓存为你兜底了不是你的代码设计好。它会让循环依赖的坏味道在代码里潜伏得更深。我的个人经验是字段注入只适合写代码片段、快速原型和Controller层的场景生产环境里我基本不推荐。不是为了装样子而是字段注入会在不知不觉中让你的类变成隐形依赖收集器——你往类里加字段是一行代码的事但类到底依赖了什么、能不能独立工作、测试难度有多大全部被隐藏了。为了更直观地对比这三种方式我把关键差异整理了一张表对比维度构造器注入Setter注入字段注入依赖是否为final是推荐否否创建时是否强制校验依赖是找不到直接报错否依赖可缺省否运行期才暴露单元测试友好度高直接new传入高new后调用setter低需额外处理循环依赖支持不支持报错支持支持靠三级缓存代码简洁度较长中等最简洁依赖不可变保证强无无Spring官方推荐度推荐一般不推荐3. Autowired与Resource的细节差异注解注入躲不开的选择题注解注入是现在Spring Boot项目的主流其中Autowired和Resource是出场率最高的两个注解。很多新手以为它们可以随便替换实际上两者背后的注入逻辑完全不同——面试官最爱问的Autowired和Resource的区别要是答不清楚基本就凉了。3.1 AutowiredSpring原生的按类型注入Autowired是Spring框架自己提供的注解它的注入逻辑可以概括为三个步骤先按类型byType寻找Bean。容器中如果只有一个匹配类型的Bean直接注入。如果有多个同类型Bean按名称byName找。不指定名称时默认使用字段名作为候选Bean名字。比如字段叫userService就去找名为userService的Bean。如果按名称也找不到就报错。除非你加了Qualifier指定要哪个名字的Bean。来看个典型场景public interface MessageSender { void send(String msg); } Component(smsSender) public class SmsSender implements MessageSender { // ... } Component(emailSender) public class EmailSender implements MessageSender { // ... }如果注入时这样写Autowired private MessageSender sender;因为MessageSender有两个实现类Spring找不到唯一的类型匹配此时它会看字段名sender去找名为sender的Bean——但容器里只有smsSender和emailSender找不到sender直接报错。那怎么办Autowired Qualifier(emailSender) private MessageSender sender;加Qualifier明确指定名字Spring就会按名字去容器里找emailSender这个Bean。这里有个很多人容易忽略的点Autowired除了能注入普通Bean还能注入BeanFactory、ApplicationContext、Environment这些Spring基础设施对象。因为后面这几个都注册在容器里Spring会直接把这些基础设施对象注入进来。比如Autowired private ApplicationContext applicationContext;这在某些需要手动获取Bean的场景里非常有用也算Autowired的一个隐蔽功能点。3.2 ResourceJSR-250标准注解按名称优先Resource是Java规范里的注解JSR-250Spring只是实现了这个规范而已。它的注入逻辑和Autowired刚好相反先按名称byName查找Bean。默认使用字段名作为Bean的名字。如果没找到再按类型byType查找。也可以显式指定name属性。比如Resource(name emailSender)。从使用体验上说Resource在很多场景下会比Autowired简单——因为它是先按名称找所以多个同类型实现类的情况下只要字段名恰好和某个Bean的id一致Resource就能直接注入成功不需要额外的Qualifier。下面这个代码在Spring里没有任何问题Component public class NotificationService { Resource(name smsSender) private MessageSender sender; }但要注意的是Resource是Java EE标准注解它不局限于Spring生态。如果你的项目以后可能脱离Spring、迁移到别的框架体系用Resource会比Autowired更具通用性。3.3 还有一个隐蔽玩家InjectInject是JSR-330标准的注解和Autowired功能基本一致也和Spring内部有很好的集成但是实际项目里见到的并不多。原因是Spring框架在国内的普及率太高绝大多数开发者习惯了Autowired而且Inject需要额外引入javax.inject依赖用起来没Autowired顺手。我做一个总结性的对比对比维度AutowiredResource所属标准Spring专属Java标准JSR-250查找顺序先类型后名称先名称后类型多实现类时的指定方法Qualifiername属性是否支持required属性支持不支持通用性仅在Spring环境Java EE各框架通用3.4 Autowired的required属性可选中依赖补充一个容易被忽略但很重要的知识点——Autowired有一个required属性用来声明依赖是否是强制的Autowired(required false) private DataSourceConfig dataSourceConfig;required false的含义是容器里如果找不到这个类型的Bean也允许创建当前Bean字段保持null。这在某些适配类、插件类、可选功能模块里很实用。但用的时候要非常克制。一旦你给一个依赖加上required false就相当于放弃了Spring对依赖完整性的保障——它不会在启动时报错而是把这个隐藏炸弹留到运行期。我见过一个项目用了大面积的required false最终的结果就是启动全部成功上线后每个模块都在不同时间点爆空指针。所以如果你是团队里负责代码规范的人我会建议把这个required false列入代码审查重点。4. 注入为什么会失败从三级缓存看循环依赖的完整链路说起注入循环依赖是绕不开的话题。Spring面试题里的三级缓存基本就是为循环依赖准备的。这一节我们把它彻底讲透。4.1 什么是循环依赖循环依赖指的是A类依赖B类B类同时又依赖A类。比如订单服务依赖用户服务用户服务为了查积分又依赖订单服务——虽然代码上这样写通常是设计问题但现实中这种关联确实存在。用代码表达就是这样Service public class AService { Autowired private BService bService; } Service public class BService { Autowired private AService aService; }4.2 如果没有缓存机制注入会怎么死循环我们要理解Spring解决循环依赖的前提得先看Bean的创建流程。Spring创建一个Bean大致分两步实例化instantiate调用构造函数创建出对象此时对象是半成品依赖还没填进去。初始化initialize填充属性/调用setter注入依赖以及执行各种初始化方法比如afterPropertiesSet、PostConstruct。如果是AService实例化 → 初始化时需要BService → BService实例化 → 初始化时需要AService → 但AService还没初始化完——如果Spring没做处理这里就死循环了。你依赖我我依赖你谁都等不到对方完工。4.3 三级缓存的完整流程Spring解决循环依赖靠的是三个缓存容器源码中对应三个Map一级缓存singletonObjects存放完整创建好的单例Bean完全初始化完成。二级缓存earlySingletonObjects存放提前暴露的早期Bean引用还没走完初始化但构造已执行完的对象。三级缓存singletonFactories存放ObjectFactory类型的工厂对象用于生成对象的早期引用。核心逻辑是这样的AService开始创建容器执行AService的构造函数拿到一个AService的半成品对象。Spring把这半成品对象封装成一个ObjectFactory放进三级缓存singletonFactories。AService初始化时发现需要BService于是去容器里找BService。容器发现BService还没创建于是开始创建BService。BService执行构造函数得到半成品后同样放入三级缓存。BService初始化时需要AService这时去容器找AService——在三级缓存中找到了AService对应的ObjectFactory通过它的getObject()方法得到AService的早期引用放入二级缓存。BService拿到AService的早期引用完成自己的初始化BService成为一个完整Bean存入一级缓存。回到AService的创建流程此时BService已经创建完成AService顺利注入BService自己也完成初始化存入一级缓存。整个过程中最关键的就是第三步提前把半成品对象暴露出去。这个提前暴露动作让BService在创建时能找到AService的引用哪怕是还没完全初始化的引用也足以让流程继续走下去。画成文字就是三级缓存不只是缓存它是Spring在先给一个未完成的引用和保证最终得到完整对象之间做的妥协方案。4.4 为什么构造器注入解决不了循环依赖现在可以解释为什么构造器注入会让循环依赖直接报错了。构造器注入必须在构造阶段就获取依赖。AService的构造器需要BService可AService自己还没构造完连半成品对象都还没有根本没法放入三级缓存。BService的构造器又需要AService两边都要在对方还不存在的时候就拿到对方——没有任何一个对象可以被提前暴露循环依赖彻底无解。所以Spring遇到构造器循环依赖时会直接抛出BeanCurrentlyInCreationException异常提示你存在循环依赖问题。我工作中遇到过几次类似的错误排查思路一般是这样启动日志里定位到循环依赖涉及哪两个Bean。分别看这两个类是不是用了构造器注入。如果是把其中一个改成字段注入或Setter注入——但注意这只是绕过机制不是真正解决问题。最终还是会挪一下依赖关系把循环依赖的结构彻底打破通常的做法是抽取一个中间层或者把其中一个依赖改成延迟获取。4.5 关于三级缓存的两个容易被问倒的细节为什么是三级缓存不是二级其实二级缓存就能解决循环依赖时找到早期引用的问题。三级缓存的真正作用是支持AOP代理的延迟创建。如果一个类需要被AOP增强比如事务代理Spring要在对象创建过程中生成代理对象。三级缓存里存放的ObjectFactory就是等到确定需要代理时再生成代理对象而不是在一开始就生成。这样能避免没被循环依赖的Bean也白白经历一次代理创建过程——算是一种懒加载的优化。如果你对源码看得多会发现三级缓存里放的不是BeanPostProcessor处理后的完整代理而是一个等到需要时再生成早期引用的工厂方法。为什么Spring Boot 2.6以后默认关闭循环依赖很简单——循环依赖本身是代码坏味道的体现Spring团队选择在默认配置上不允许这种写法存在让开发者被迫重构。如果你项目里大量使用了循环依赖启动时出现The dependencies of some of the beans in the application context form a cycle说明代码结构已经需要调整了而不是想在配置里把allow-circular-references打开。我在实际项目里的态度是循环依赖可以用但只是短期解决线上故障的临时手段。长期看循环依赖会让代码的依赖关系图变成蜘蛛网团队里任何一个新人都很难理清系统全貌。慢慢把循环依赖拆掉靠第三方的协调者来剥离互相调用的关系才是正路。5. 从XML到Java Config注入配置的演进与选择前面讲的都是注解方式但Spring的源码里注入的入口其实是更底层的BeanDefinition和BeanFactory。XML和Java Config只是不同年代的定义Bean方式。搞懂这个演进你对各种注入配置的兼容性问题会豁然开朗。5.1 基于XML的注入老项目的常态Spring早期的项目清一色是XML配置类似这样bean iduserService classcom.example.UserService constructor-arg refuserDao/ property namedataSource refdataSource/ /bean标签含义非常直观constructor-arg注入构造器参数对应构造器注入。property注入属性值或依赖Bean对应Setter注入。ref引用另一个Bean的id。value注入字面值字符串、数字等。XML方式的优点是配置和代码完全分离改依赖不用改代码。缺点也明显内容繁琐、配置量巨大一旦Bean数量上百XML文件冗长到你根本不愿意翻而且在老项目里经常出现代码里写了AutowiredXML里也配了一份两边不同步导致诡异问题的情况。5.2 注解驱动的时代扫描 自动注入Spring 2.5开始引入注解Spring 3.0以后注解配置全面铺开。核心就是两个开关ComponentScan(com.example)或XML版context:component-scan base-packagecom.example/开启了组件扫描之后凡是标了Component及其派生注解Service、Repository、Controller的类Spring会自动注册成Bean。配合Autowired、Resource在字段/Setter/构造器上完成注入。在这个模式下Configuration类的作用是补充一些无法通过扫描搞定的Bean定义Configuration public class DataSourceConfig { Bean public DataSource dataSource() { HikariDataSource source new HikariDataSource(); source.setJdbcUrl(jdbc:mysql://localhost:3306/demo); source.setUsername(root); return source; } }这里的Bean方法本质上就是在Java代码里手工构造一个Bean实例然后交给容器管理。这个方法本身可以接收参数——Spring会把参数当作依赖自动注入Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); }注意这里有个很隐蔽的细节Bean方法的参数注入是按类型匹配的如果有多个DataSource类型的Bean需要配合Qualifier指定。注解参数和字段注入在这个层面的机制完全一致。5.3 XML、注解、Java Config怎么兼容现在Spring Boot项目默认用Java Config 注解但很多老项目的代码里还带着XML配置。Spring完全支持两种方式共存Configuration ImportResource(classpath:old-config.xml) public class MixedConfig { // Java Config 可以引用XML里定义的Bean }XMLbean定义出来的Bean是一个已存在的bean定义Java Config里可以通过Autowired直接注入这个XML Bean——Spring容器本身是一个统一的BeanFactory它不关心你定义Bean时使用了哪种风格。我曾经接手过一个注解 XML混搭的项目里面最让人头疼的坑是同一个Bean被重复定义。你在Configuration里定义了一个BeanXML里又定义了一个同名的bean启动时Spring直接报BeanDefinitionStoreException提示名称冲突。排查这类问题的思路很简单找到IDE里Bean的声明来源全局搜类名或Bean名称看看到底是在注解配置里还是在XML里定义的然后把其中一个删掉。5.4 Java Config里的方法调用陷阱这里插一条实际开发中很容易踩的坑。在Configuration类里你可能会这样写Bean public AService aService() { return new AService(bService()); } Bean public BService bService() { return new BService(); }如果这个类标了ConfigurationSpring会通过CGLIB代理拦截bService()的调用返回的始终是容器里的单例Bean——不会创建新实例。这是Spring比较精妙的设计让方法调用看起来像普通Java代码实际的行为被代理接管了。但如果你把类标注为Component而不是Configuration或者类的Spring代理模式被改成非代理模式比如Configuration(proxyBeanMethods false)bService()就变成普通的Java方法调用每次都会走一遍方法逻辑——在非代理模式下会创建出新的BService实例而不是复用容器中的单例。这个差别在容器较大的项目里很难一眼看出来排查方式通常是检查日志里的Bean初始化次数如果某个Bean被初始化了多次、但你又没写过重复初始化逻辑就往proxyBeanMethods设置上查一下。6. 我的注入选型心得不同场景下的最佳实践说了这么多理论最后讲点实际的选型判断。这部分没有绝对的对错但有一些原则是踩过坑之后总结出来的。6.1 基础准则能用构造器注入就用构造器注入我的团队规范里写得很死硬性依赖必须用构造器注入可选依赖才考虑Setter注入字段注入不允许出现在新代码里。这么定不是为了跟风而是实测下来的收益非常明确代码评审时一眼就能看到类的依赖清单。测试代码写起来极其舒服new一个被测类把mock对象传进去就完事。启动报错率低因为构造器注入的强依赖校验让问题在容器加载早期就暴露了。依赖是final的彻底杜绝了运行到一半依赖被切走这种诡异情况。构造器注入唯一的难看就是参数多。我见过一个Service的构造方法有9个参数这类类往往肩负了太多职责应该拆分成多个小而专的类。所以构造器注入实质上在逼你进行更好的设计——这反而成了优点。6.2 什么时候破例用Setter注入有两类场景我会破例场景一Bean的依赖需要动态修改或可覆盖。典型例子是测试环境允许替换数据源、切换缓存实现。用Setter暴露一个重新配置的入口配合Value或者自定义的通知机制能做到运行期刷新配置。这属于比较进阶的操作但确实存在。场景二为了兼容老代码中的循环依赖。如果你在改造一个老项目代码里已经到处都是循环依赖短期又来不及重构把某个环上的注入改成SetSet或字段注入能让项目先跑起来。但务必给这个破例打上TODO标记后续一定整改。6.3 Autowired还是Resource我的判断标准这两者没有绝对优劣但我的判断标准很简单团队内定一种全项目统一。混用最容易出岔子。需要按类型注入时用Autowired需要按名称优先注入时用Resource。需要required false时只能选Autowired。泛型注入时优先选Autowired。泛型注入这里再说一句。Spring对泛型类型是支持的下面的写法能正确匹配Component public class JsonConverter implements ConverterJsonData { // ... } Component public class XmlConverter implements ConverterXmlData { // ... }注入时Autowired private ConverterJsonData jsonConverter;Spring会通过泛型类型匹配找到对应的Bean。如果类型擦除导致匹配困难某些继承场景下会出现再用Qualifier手动指定。6.4 多实例下的注入策略最后聊一个很容易被忽略的问题如果一个Bean被注册了多个实例注入时怎么选典型的场景是不同环境有不同的实现。比如开发环境用MockPaymentService生产环境用RealPaymentService。除了用Qualifier硬编码指定之外更好的做法是用条件装配Configuration public class PaymentConfig { Bean ConditionalOnProperty(name app.payment.mock, havingValue true) public PaymentService mockPaymentService() { return new MockPaymentService(); } Bean ConditionalOnProperty(name app.payment.mock, havingValue false) public PaymentService realPaymentService() { return new RealPaymentService(); } }这样在配置文件中改一个值就能切换整个注入方案完全不用动代码。还有一种是延迟注入。比如某个Bean很重只在特定请求里用到不希望每次创建时都初始化它。Spring提供了ObjectProvider的注入方式Service public class HeavyServiceConsumer { private final ObjectProviderHeavyService heavyServiceProvider; public HeavyServiceConsumer(ObjectProviderHeavyService heavyServiceProvider) { this.heavyServiceProvider heavyServiceProvider; } public void useHeavyService() { HeavyService service heavyServiceProvider.getIfAvailable(); if (service ! null) { service.doSomething(); } } }ObjectProvider不会在Bean创建时直接注入实例而是在调用getIfAvailable()时才去容器里找。这个机制在解决选择性依赖延迟加载循环依赖重构时很有用算是Spring提供的官方高级玩法。6.5 关于注入方式我的最后一点体会Spring的注入方式不是一个非此即彼的单选题。我在不同规模的项目里都试过各种组合最终的体会是注入方式本身不重要重要的是团队是否达成一致以及依赖关系是否清晰可查。选一种方案把它变成团队规范写进Checkstyle或者代码评审规则里比纠结哪个更高级重要得多。代码是给人看的注入方式如果三天两头变风格再好的容器设计也救不了项目的可维护性。这么多年用下来我最喜欢的一个组合就是构造器注入写清依赖 Qualifier应对多实现 ObjectProvider处理延迟场景 条件装配管理环境切换。这套搭配从单体应用到微服务都扛得住。你拿着这套去面试或者回去改造项目基本不会踩大坑。
返回列表