ARTICLE DETAIL

资讯详情

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

Spring依赖注入源码全解析:从@Autowired到三级缓存

Spring依赖注入源码全解析:从@Autowired到三级缓存 最近后台接到不少读者问同一个问题“大厂高频注入源码全可见”这类标题到底值不值得花时间跟一遍说实话现在网上搜“源码注入”相关的内容要么是零散的片段解读要么是目录式复述真正能把一条注入链路从头讲到尾、还能告诉你大厂里为什么这么用的并不多。作为一个常年带后端团队、自己动手翻过Spring IoC源码的老码农这篇我想把这件事说透。文章以Java生态里最主流的依赖注入框架为主线从启动入口讲到Bean的创建从Autowired的幕后推手讲到循环依赖的三级缓存最后再分享几个读完源码之后才有的实战心得。适合三类人看一是还在“给字段加个注解就跑”阶段的初级开发二是准备面试、经常被问到“Spring怎么解决循环依赖”的求职者三是已经用Spring多年、想从黑盒使用走向白盒理解的工程师。1. 标题拆解大厂高频的“注入”到底在注入什么1.1 “源码注入”背后的技术语义先打破一个可能的误解。你搜“源码注入”时会看到两类完全不同的东西一类是安全领域的代码注入那个不在本文讨论范围另一类也就是“大厂高频注入源码全可见”指向的是**依赖注入Dependency InjectionDI**框架的源码。这里的关键词是“注入”全称是依赖注入。依赖注入的核心思想不复杂一个类需要的协作对象不在自己内部new出来而是由外部容器创建好之后再给进去。比如你写一个订单服务它要调用库存服务传统写法是在构造函数里“new InventoryClient()”依赖注入的写法是声明一个构造函数参数让Spring容器在创建Bean时把现成的InventoryClient实例传进来。“源码全可见”这四个字说的是这类框架全是开源实现的。Spring的spring-beans、spring-contextAndroid开发里高频出现的Dagger、Hilt它们的源码就摆在GitHub上没有任何黑盒。这也就意味着只要你会读Java代码就能把“注入”的底层看清楚。很多人用了几年Spring却说不清容器到底在哪个环节把依赖塞进对象的这其实是挺可惜的一件事。1.2 为什么大厂高频使用依赖注入大厂项目的高频使用不是跟风是被几个硬需求推着走的。首先是可测试性。一个服务依赖数据库、消息队列、第三方HTTP接口如果这些对象都是在类内部new出来的单元测试根本没法做——你没法在测试环境偷偷换掉它们。有了依赖注入测试时可以通过容器或手动构造传入Mock对象代码的可测试性直接上一个台阶。其次是模块解耦。大厂的服务动辄几百上千个类类与类之间的依赖关系如果靠代码里硬编码new来维系重构一次就像拆炸弹。依赖注入天然鼓励面向接口编程你依赖的是一个接口容器在运行时给你装配具体实现。换实现、加中间层调用方几乎不用改代码。还有生命周期管理。容器统一管理单例对象什么时候创建、什么时候销毁、实例是单例还是每次新建都由容器说了算。这在大规模服务里很重要避免每个类各自管理共享资源也避免一不小心new出几百份浪费内存的对象。1.3 黑盒使用和源码阅读的差距在哪里我面试过不少候选人问“Autowired是怎么工作的”很多人只能回答“Spring会自动帮我把依赖注进来”。再问“为什么会有循环依赖报错”“为什么两个同类型Bean会启动失败”基本就卡住了。这不是他们不努力而是只停留在API使用层面。黑盒使用也能写出能跑的业务代码但出了问题的时候你只能靠搜报错信息、加日志、重启试错而读过源码的人看一眼堆栈就知道是哪个环节挂了。比如NoUniqueBeanDefinitionException读过源码的人知道这是依赖解析阶段按类型找到了多个候选Bean、最终也没能唯一确定而没读过源码的人可能连“候选Bean”这个概念都没有。“源码全可见”真正的价值就在这里它把排查问题的能力提前写进了你的脑子里。2. 源码入口一个Spring应用启动时注入究竟发生在哪一步2.1 第一行代码AnnotationConfigApplicationContext几乎所有基于注解的Spring应用都是从这一行开始的AnnotationConfigApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class);很多人没注意到这个构造方法内部干了两件事注册配置类、刷新容器。配置类里通常有ComponentScan它会告诉Spring去哪几个包底下扫描带有Component、Service、Repository等注解的类。扫描到的每个类都会被封装成一个叫BeanDefinition的东西。读源码的时候我建议你先从AnnotationConfigApplicationContext的构造函数入手一步一步点进去。你会发现它构造时做了两件事创建一个AnnotatedBeanDefinitionReader专门处理配置类上的Bean方法再创建一个ClassPathBeanDefinitionScanner专门扫包路径下的组件。这俩出来之后才轮到refresh()登场。2.2 BeanDefinition注入的前置说明书BeanDefinition这个名字值得多念两遍因为它是整个注入流程的地基。你可以把它理解成一张**“Bean说明书”**上面记录了Bean的完整类名是单例还是原型scope是否懒加载lazyInit构造器参数有哪些属性依赖哪些其他Bean初始化方法和销毁方法是谁扫描器找到Component后会生成一个ScannedGenericBeanDefinition塞进容器。容器启动到后面阶段会根据这张“说明书”来决定怎么实例化、怎么注入。明白了这一点你就知道为什么Spring能实现“约定优于配置”——它本质上是在启动期把散落在代码里的注解信息全部汇总成了一份份结构化数据。2.3 refresh()主线容器初始化的完整链路真正复杂的是refresh()方法它是整个容器初始化的总指挥。不同Spring版本步骤略有差异但核心链路是稳定的准备工作记录容器启动时间、标记容器已激活调用BeanFactoryPostProcessor此时可以修改BeanDefinition注册BeanPostProcessor这一步决定了后面谁来处理Autowired那类注解触发所有非懒加载单例Bean的实例化这里就是依赖注入真正发生的地方实际代码比这复杂得多但你先抓住这条主线就够了。依赖注入不是散落在各处的魔法而是集中在“单例Bean实例化”这个阶段完成的。Spring拿到每个BeanDefinition一步步创建对象、填充属性、执行初始化最后放进缓存。我们平时在字段上写一个Autowired就是被填充属性这个环节处理的。把入口和主线理清之后你才算真正踏上“源码全可见”的路。接下来要做的是钻进去看最核心的那个注解背后站着谁。3. Autowired的幕后推手一条从注解到实例的完整链路3.1 处理注解的“打工人”AutowiredAnnotationBeanPostProcessor如果你去看Spring源码里谁在维护Autowired会找到一个类AutowiredAnnotationBeanPostProcessor。它的名字已经说明一切它是一个BeanPostProcessor专门处理自动装配注解。BeanPostProcessor是什么你可以理解成Spring给每个Bean提供的“装修队”。Bean实例化之后、正式使用之前容器会依次调用所有BeanPostProcessor的回调方法让它们对Bean进行加工。AutowiredAnnotationBeanPostProcessor就是在这一刻上场的它遍历Bean的所有字段和方法找出带Autowired或Value的存成一组“注入点”。这个过程有两个值得注意的细节。第一它不光处理字段构造函数上的Autowired也会被它识别——这就是构造器注入的入口。第二它对字段的访问权限不敏感private字段也能注入因为它用的是反射。所以那些“Spring为什么能注入private字段”的疑问在这里就有了解答它拿着反射直接set。3.2 依赖解析byType优先byName兜底找注入点只是第一步真正决定“把哪个Bean塞进去”的是后面更关键的依赖解析环节。核心逻辑在DefaultListableBeanFactory的doResolveDependency方法里流程大致如下拿到注入点的类型比如InventoryClient在容器里按类型搜索所有匹配的Bean得到候选集合候选集合为空且字段有requiredtrue直接抛NoSuchBeanDefinitionException候选集合只有一个直接返回候选集合有多个先用字段名byName过滤还是多个就看Primary、Priority注解最终仍然无法唯一确定抛NoUniqueBeanDefinitionException这里有个容易忽略的细节先按类型找再用名字过滤。很多人只记得“byType和byName”但不知道顺序是type-first。实战里最典型的报错场景是接口有两个实现类你在字段上写接口类型Spring按类型找到了两个候选再按字段名找也没找到完全匹配的名字这时候报错就来了。解决方式通常是三个把字段名改成和某个实现类的Bean名称一致或者在某一个实现类上加Primary或者在注入点上加Qualifier(xxx)。理解了源码流程这三个办法就不是“背答案”而是顺着解析逻辑去干预每一步。3.3 两种典型启动失败场景的源码级解释场景一NoSuchBeanDefinitionException。启动日志会提示“No qualifying bean of type xxx available”。读到源码后你会明白这一般是候选集合为空。常见原因有忘了加Component扫描路径没覆盖到目标包类上用了接口但实现类没被Spring管理。排查思路也清晰先确认BeanDefinition里有没有这类再确认扫描路径。场景二NoUniqueBeanDefinitionException。日志会说“expected single matching bean but found 2”。这在源码里对应的是候选集合多于一个、最终也没能收敛。处理方式前面已经说了Primary、Qualifier、改字段名。读懂这条链路后你对Autowired的理解就和以前完全不一样了。它不是一个“自动”的魔术而是一套有明确顺序、有明确报错策略的解析逻辑。面试时能把这个过程讲清楚基本就超越了大多数人。4. 大厂高频场景循环依赖和三级缓存源码走读4.1 循环依赖是什么为什么单例默认能解决大厂项目里循环依赖是绕不开的话题。典型场景是A依赖BB依赖A。如果用构造器注入两者都要在创建时拿到对方直接死锁。但如果你用的是字段注入或setter注入Spring单例模式下默认能处理。为什么能处理因为Spring允许一个Bean在“还没完全初始化完成”时先把它的早期引用暴露出去。A在填充属性时发现需要B于是先去创建BB创建时又需要A这时Spring把A的早期引用给了BB拿到A完成自己返回给AA再继续完成剩余工作。这一来一回在单例默认开启“允许循环引用”的配置下是可以跑通的。这个机制在源码里的名字叫三级缓存。4.2 getSingleton和addSingletonFactory三级缓存怎么流转三级缓存是三个Map定义在DefaultSingletonBeanRegistry里一级缓存 singletonObjects存完全创建好的成品Bean二级缓存 earlySingletonObjects存提前暴露的半成品三级缓存 singletonFactories存ObjectFactory工厂用来生成早期引用核心读取逻辑在getSingleton方法里我贴一段关键代码精简过protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 第一级成品缓存 Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { // 第二级早期引用缓存 singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { // 第三级从工厂获取早期引用 ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }注意这段代码的读取顺序先一级再二级最后三级。三级缓存里存的是ObjectFactory调用它的getObject()才会真正生成被提前暴露的Bean引用。那么三级缓存是什么时候放进去的答案是doCreateBean方法里。一个Bean完成实例化对象new出来了但属性还没填充后Spring会判断如果是单例、且允许循环引用、且这个Bean正在创建中就执行addSingletonFactory把一个能返回当前Bean早期引用的工厂放进去。关键代码长这样boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }这里的getEarlyBeanReference会经过SmartInstantiationAwareBeanPostProcessor可能返回一个代理对象这也解释了为什么AOP代理在某些循环依赖场景下会有诡异的额外情况。我可以给你一个具体的执行时序方便你对照代码理解容器开始创建Anew出A对象A尚未填充属性A走addSingletonFactory把A的ObjectFactory放入三级缓存A开始填充属性发现需要B于是转去创建BB重复A的过程实例化后也放入三级缓存然后填充属性时发现需要AB调用getSingleton(A)一级没有、二级没有、三级有于是通过ObjectFactory拿到A的早期引用放入二级缓存B拿到A继续完成创建放入一级缓存A拿到B的成品继续完成自己的属性和初始化放入一级缓存整个过程里二级缓存是关键的中转站三级缓存是“生成早期引用”的加工厂。用完三级直接移除是为了防止同一个Bean被多次提前生成代理。4.3 构造器注入为什么解决不了循环依赖这是个高频面试题源码里有非常明确的答案构造器注入在实例化阶段就需要传入依赖。而Spring处理构造器注入时调用的是createBeanInstance它在Bean还要被填充属性之前就必须把所有构造参数都拿到。此时A还没能执行到addSingletonFactory那一步三级缓存里自然没有A的早期引用B想取也取不到。一句话总结循环依赖能被解决前提是Bean先完成“实例化”并暴露早期引用构造器注入把依赖需求提前到了实例化之前这个前提就不成立了。所以你会看到大厂规范里通常推荐用构造器注入但它对循环依赖是一种强制约束——逼你重构出没有环的依赖结构。如果一个团队用了构造器注入还遇到循环依赖报错那不是Spring的问题是代码结构该治理了。4.4 实战避坑让循环依赖消失的三个办法读源码最大的收获之一是你会主动减少对循环依赖的依赖。常规手段按优先级排重构依赖关系抽出一个新对象C让A和B分别依赖C打破环。这是治本方案大厂里普遍推这个。改注入方式把某一方的构造器注入改成字段注入或setter注入。能绕过问题但会牺牲构造器注入的不可变性和测试友好性属于过渡方案。Lazy打破环在其中一个注入点加LazySpring会注入一个代理对象真正调用时才去解析依赖。它能解决问题但会改变Bean初始化的语义不建议大面积使用。另外要注意Spring Boot 2.6之后默认把allowCircularReferences设为false了。也就是说楼主想要“默认兼容循环依赖”的老体验在新版本里已经没有了。怎么办可以去application.properties里设置spring.main.allow-circular-referencestrue。但我个人经验是与其开开关兼容坏味道不如花半天把环拆了。5. 读源码之后的进阶能力四个别人不会告诉你的源码级经验5.1 Lazy不是灵丹妙药它改变了Bean的生命周期很多人遇到依赖报错就加Lazy但读过源码后你会发现Lazy的本质是给这个注入点生成一个代理对象而不是真正创建目标Bean。效果是什么呢延迟了依赖解析的时间点——本来容器启动时就该创建的Bean变成了第一次使用时才创建。这就带来两个副作用。第一启动阶段的问题被掩盖了可能上线后第一个请求进来才暴露到时候排查压力更大。第二代理对象在某些场景下对final类、私有方法不友好切面配合时容易出意外。所以我的建议是Lazy可以偶尔应急但一定写好注释说明为什么加并且列入技术债跟踪。大厂里最忌讳的是“没人知道这里为什么有个Lazy”。5.2 同类型多Bean的决胜规则从源码看优先级顺序前面说依赖解析有多个候选时处理顺序是byName过滤 → Primary → Priority。很多人会问Qualifier排在第几位准确地说Qualifier不是拿来“决定优先级”的而是在查找候选时直接按限定名去匹配。它在resolveDependency深处被处理优先级高于后面的Primary判断——因为你已经明确指名了要哪个Bean。这就不存在“名字对不上再看Primary”的问题。如果你要在一个团队里定规范我建议默认用Primary标注主实现个别场景用Qualifier显式指定。尽量避免依赖字段名这种隐式约定字段名一改代码看似没编译错行为却变了这是最隐蔽的坑。5.3 大厂里配置组件扫描路径的源码级建议扫描路径这问题用起来很简单但调优却藏得深。ClassPathBeanDefinitionScanner在扫描时遍历的是指定包及其子包。你如果为了省事把扫描路径设成根包比如com.example等于让Spring把整个项目都扫一遍。源码里能看到扫描器要为每个候选类读取注解元数据、生成BeanDefinition这一步的成本和候选类数量成正比。项目大了以后启动时间会被无谓拉长。大厂里的做法是把扫描路径收敛到具体的模块包配合Configuration和Bean显式声明一些关键Bean既能缩短启动时间又能让依赖关系显式可见。5.4 一份源码阅读自测清单读完这篇文章我建议你用下面十个问题自测一下能不能不看源码答出来AnnotationConfigApplicationContext构造时做了什么refresh()里触发单例实例化的关键方法叫什么BeanDefinition里至少存了哪五类信息Autowired是哪个BeanPostProcessor处理的依赖解析的顺序是什么byName和byType谁在前NoUniqueBeanDefinitionException在源码里对应哪个判断分支三级缓存分别叫什么各自存什么对象getSingleton查询时三级缓存的访问顺序是什么为什么构造器注入无法解决循环依赖Spring Boot 2.6默认循环依赖是开启还是关闭能顺畅答出前八题说明你已经把“注入”这条源码链路真正看明白了最后一题能答对说明你平时在“源码全可见”的基础上还留意了版本演进。这一套下来再遇到注入相关的启动报错你大概率能比其他人更快定位到根因。我个人带团队时有个习惯每个新人入职我都会让他先读三个类——AnnotationConfigApplicationContext、DefaultListableBeanFactory、AutowiredAnnotationBeanPostProcessor。读完之后再回来写业务代码整个人对框架的感觉会完全不一样。这篇文章算是把这三个类里和注入最相关的部分带你走了一遍剩下的细节就交给你自己去源码里对照着看吧。
返回列表