ARTICLE DETAIL

资讯详情

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

【面朝大厂】Spring高频面试题:如何解决循环依赖问题2万字详解

【面朝大厂】Spring高频面试题:如何解决循环依赖问题2万字详解 一、开篇为什么面试官总爱问循环依赖在 Spring 面试中循环依赖几乎是一个绕不开的高频考点。尤其是面 Java 后端、互联网大厂或业务中台岗位时面试官往往会从一个简单的问题切入「Spring 如何解决循环依赖」。表面上看这只是一个关于“两个 Bean 互相引用”的工程问题但深入下去它能把面试官真正关心的知识点全部串起来Bean 的生命周期、BeanFactory 的缓存设计、依赖注入方式、Spring AOP 动态代理、单例与原型作用域、三级缓存机制以及你对 Spring 容器底层源码的理解程度。很多候选人背下了“三级缓存”这个名词也能说出singletonObjects、earlySingletonObjects、singletonFactories三个 Map但当面试官追问“为什么必须是三级两级为什么不行”“构造器注入的循环依赖能不能解决”“加入 AOP 代理之后会发生什么”“Async为什么会失效”时往往就露馅了。本文将以面试场景为主线从现象、原理、源码、设计权衡到实战踩坑系统地把 Spring 循环依赖讲透帮助你形成一套可以“组合回答”的知识体系。本文默认读者已经了解 Spring IoC 容器、依赖注入的基本概念并具备一定的 Spring Boot 使用经验。文章会结合源码片段、流程图和多套面试追问尽量做到“知其然也知其所以然”。建议阅读时重点关注三个层次第一层是结论即 Spring 能否解决循环依赖、在什么条件下解决第二层是原理即三级缓存各司其职的设计逻辑第三层是边界即哪些场景解决不了、哪些场景会踩坑、如何优雅规避。二、什么是循环依赖循环依赖Circular Dependency也叫循环引用指的是两个或多个 Bean 在创建过程中互相直接或间接地依赖对方形成一个引用闭环。最常见的形态如下Component public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } } Component public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService orderService; } }在上面的代码中OrderService依赖UserService而UserService又依赖OrderService形成了最简单的双向循环。如果把依赖关系放大还可能形成 A 依赖 B、B 依赖 C、C 依赖 A 这种间接循环有时还会牵涉三四个以上的 Bean排查起来会更加隐蔽。循环依赖本身并不是 Spring 独有的问题而是所有“由容器统一管理对象生命周期”的框架都会面临的经典难题。在传统new对象的世界里我们通常会手动拆解依赖或者先把对象创建出来、再把引用的另一端“塞回去”这其实就是 Spring 解决循环依赖的核心思路的朴素来源先完成实例化再完成属性填充在对象尚未完全初始化时先把它的引用“提前暴露”出去。为了理解循环依赖必须先分清 Spring Bean 创建过程中的两个阶段实例化Instantiation和初始化Initialization。实例化指通过构造器或工厂方法创建出 Java 对象调用构造函数初始化则是在对象实例创建完成之后进行属性填充依赖注入、执行PostConstruct、afterPropertiesSet等回调的过程。面试顺口溜“实例化只是造壳初始化才是填充。”Spring 解决循环依赖的窗口期恰恰就发生在“实例化完成、属性尚未全部填充”这一段时间内。三、Spring 循环依赖的典型场景与触发条件并不是所有的循环依赖 Spring 都能解决。能否解决取决于三个关键因素作用域、注入方式、是否涉及 AOP 代理。理解这些条件是回答面试题时不翻车的第一步。3.1 能解决的场景单例 Bean Setter 注入 / 字段注入这是最典型、最常见、也是 Spring 明确支持解决的场景。单例 Bean 构造器注入 Lazy通过懒加载代理打破实例化阶段的强依赖Spring 也能间接解决。单例 Bean 字段注入 AOP 代理Spring 通过三级缓存中的ObjectFactory提前暴露代理对象通常也能正确处理。3.2 不能解决的场景原型prototype作用域的循环依赖因为原型 Bean 每次获取都是新对象容器无法缓存其提前引用Spring 默认直接抛异常不负责解决。构造器注入且未加 Lazy 的循环依赖两个 Bean 都在构造阶段互相要求对方实例化完成形成死锁容器会抛出BeanCurrentlyInCreationException。单例 Bean 的构造器循环依赖本质上与上一条一致构造器阶段的强依赖无法用三级缓存化解。3.3 一张表快速对照作用域注入方式是否加 Lazy是否涉及 AOPSpring 能否解决单例 singleton字段 / Setter 注入否否能通过三级缓存解决单例 singleton字段 / Setter 注入否是通常能依赖代理提前暴露单例 singleton构造器注入否否不能抛 BeanCurrentlyInCreationException单例 singleton构造器注入是否能用代理对象打破强依赖原型 prototype任意注入否否不能Spring 不处理原型循环依赖这张表在面试中非常实用。当面试官问“Spring 一定都能解决循环依赖吗”你可以先给出“不是”的结论再用这张表的维度拆解作用域、注入方式、是否懒加载、是否代理。这样回答既完整又结构化。四、三级缓存的真面目三级缓存是 Spring 解决循环依赖的核心机制也是面试题最关键的得分点。所谓“缓存”本质上是三个定义在DefaultSingletonBeanRegistry中的 Map。我们先看它们的名称和注释/** 一级缓存存放完全初始化完成的单例 Bean */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存存放提前暴露的早期单例 Bean可能尚未完成属性填充 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存存放单例 Bean 的 ObjectFactory用于延迟生成早期引用/代理 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);为了便于记忆可以把三级缓存分别理解为一级缓存 singletonObjects成品仓库。里面存放的是已经完全创建、属性填充、初始化回调全部完成的 Bean。业务代码正常getBean时优先从这里拿。二级缓存 earlySingletonObjects半成品仓库。里面存放的是已经实例化、但可能还没有完成属性填充和初始化的早期 Bean 引用。三级缓存 singletonFactories半成品“加工工厂”。里面存放的不是 Bean 本身而是一个ObjectFactory函数式接口调用它可以拿到早期 Bean 引用必要时还会生成并缓存其 AOP 代理对象。为什么叫“三级”是因为获取 Bean 时的查找顺序是从一级到三级依次降级先从singletonObjects找成品找不到就去earlySingletonObjects找半成品再找不到就去singletonFactories找工厂并执行它。整个过程的核心逻辑在getSingleton(String beanName, boolean allowEarlyReference)中体现得淋漓尽致。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; }从源码可以看出一个关键点三级缓存只有在“当前 Bean 正在创建中”时才会启用。也就是说Spring 并不是对任意 Bean 都无脑暴露半成品而是通过singletonsCurrentlyInCreation这个 Set 记录正在创建的 Bean。只有当 A 已经进入创建流程、还没创建完而 B 又反过来要 A 时这个允许提前引用的逻辑才会触发。这段代码还体现了三个细节第一读取一级缓存是常规路径性能最高第二二级缓存和三级缓存的读取配合synchronized和双重检查保证并发环境下的正确性第三一旦从三级缓存中取出工厂并成功生成对象该对象会立即升级到二级缓存同时把三级缓存中的工厂移除避免重复生成。五、循环依赖的完整解决流程拆解光记住三个 Map 的名字远远不够面试官更希望听到你把这套机制“讲成一部电影”。我们以最常见的 A、B 单例 Bean、字段注入为例逐步推演 Spring 容器启动时的实际流程。5.1 假设模型Component public class A { Autowired private B b; } Component public class B { Autowired private A a; }5.2 逐步推演第一步容器准备创建 A。此时 A 不在任何缓存中。容器先将a加入singletonsCurrentlyInCreation标记它“正在创建”。第二步实例化 A。Spring 通过无参构造器创建出 A 的对象外壳此时 A 的b属性还是null。这个对象被称为“早期引用 early reference”。Spring 随即把 A 对应的ObjectFactory放入三级缓存singletonFactories以备其他 Bean 提前引用。第三步填充 A 的属性。容器发现 A 依赖 B于是需要从容器中获取 B。由于 B 尚不存在容器转入创建 B 的流程。第四步容器创建 B。同样先把b加入singletonsCurrentlyInCreation然后实例化出 B 的对象外壳并把 B 的ObjectFactory放入三级缓存。第五步填充 B 的属性。容器发现 B 依赖 A于是尝试获取 A。查一级缓存没有但发现 A 正在创建中于是查二级缓存二级缓存也没有最终允许早期引用便从三级缓存中取出 A 的ObjectFactory调用getObject()拿到 A 的早期引用把它升级放入二级缓存并移除三级缓存中的 A 工厂。第六步B 拿到 A 的早期引用后完成属性填充。随后执行 B 的初始化回调B 变成完整 Bean并被放入一级缓存singletonObjects。同时 B 的三级缓存、二级缓存相关记录被清理。第七步回到 A 的创建流程。A 拿到完整 B 并完成属性填充再执行自己的初始化回调A 也变成完整 Bean放入一级缓存。第八步整个过程结束。最终一级缓存中同时存在完好的 A 和 B。注意A 中持有的 B 是完整 Bean而 B 中持有的 A 是从三级缓存提前暴露出来的早期引用但两者最终指向的是同一个对象实例。面试金句Spring 不是“消除”了循环依赖而是利用“先实例化、后填充”的时间差把半成品对象提前放进缓存让后创建的一方先拿到引用从而打破创建顺序上的僵局。这个过程可以用一句话总结A 造壳进三级缓存发现缺 B 去建 BB 造壳后发现缺 A回头从三级缓存里把 A 的半成品拎出来先用B 完工回填给 AA 补齐属性后也完工。如果能向面试官流畅地讲完这一条链路基本已经超过大半候选人。六、为什么必须是三级缓存两级真的不行吗这是面试中极容易被追问、也极能拉开差距的问题。很多人能背出“三级缓存”却说不清“为什么不能删掉其中某一级”。下面我们从设计目标反推。6.1 一级缓存为什么不够如果只有一级缓存意味着容器只能存放完全创建完成的 Bean。当 A 实例化完成、正在填充属性时它还不能进入一级缓存因此 B 在依赖 A 时根本拿不到任何引用循环依赖自然无法解决。所以要支持循环依赖至少需要一种“能存放半成品”的机制也就是二级缓存或三级缓存。6.2 那为什么还需要三级缓存只保留一级和二级不行吗这是最关键的问题。要理解它必须引入一个看似与循环依赖无关、实则密不可分的概念AOP 动态代理。在 Spring 中如果一个 Bean 被Transactional、Async、Cacheable等切面增强或者通过AopConfig生成代理那么容器最终暴露出去的往往不是原始对象而是它的代理对象。如果没有三级缓存中的ObjectFactory只有二级缓存存放“早期原始对象”那么一旦 Bean 需要被代理就会出现严重问题B 从二级缓存拿到的 A 是原始对象而最终放入一级缓存的 A 却是代理对象导致同一个 A 在容器中出现了“原始对象”和“代理对象”两个不一致的版本B 持有的引用不是最终 Bean事务、异步等增强也会随之失效。三级缓存解决这个问题的思路是“把代理的时机延后把代理的生成封装成工厂”。当 B 需要提前获取 A 时Spring 调用 A 的ObjectFactory.getObject()在该工厂内部会执行getEarlyBeanReference。这个方法会检查 A 是否需要代理如果需要就提前创建并返回代理对象如果不需要就返回原始对象。这样无论 A 最终是否被代理B 拿到的早期引用和最终的一级缓存版本都能保持一致。protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) { exposedObject bp.getEarlyBeanReference(exposedObject, beanName); } } return exposedObject; }七、从源码角度再看一遍关键链路如果把前面的推演理解为「业务视角」这一节就从AbstractAutowireCapableBeanFactory的源码视角把链路再压一层。能在面试中自然带出doCreateBean、populateBean、addSingletonFactory这几个方法会明显比只背三个 Map 更耐打。7.1 doCreateBean 的主干逻辑单例 Bean 的创建主干集中在doCreateBean它可以拆成四个关键动作先createBeanInstance实例化再通过addSingletonFactory提前暴露工厂然后populateBean完成依赖注入最后initializeBean执行初始化回调和代理创建。循环依赖正是依赖第二步和第三步之间的时间差。protected Object doCreateBean(String beanName, RootBeanDefinition mbd, Nullable Object[] args) { BeanWrapper instanceWrapper createBeanInstance(beanName, mbd, args); Object bean instanceWrapper.getWrappedInstance(); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); } Object exposedObject bean; populateBean(beanName, mbd, instanceWrapper); exposedObject initializeBean(beanName, bean, mbd); return exposedObject; }精简后的代码省略了异常处理和不必要的分支但四段式结构非常清晰。注意addSingletonFactory发生在populateBean之前这个顺序本身就是循环依赖解决方案能够成立的前提。7.2 populateBean循环依赖在这里被触发populateBean负责把Autowired、Resource或 XML 配置的依赖注入到当前 Bean。当它发现 A 依赖 B 时会调用getBean(b)从而递归进入 B 的创建流程B 又会反过来找 A。正因为 A 的工厂已经在上一阶段写入三级缓存B 才能顺利拿到 A 的半成品引用。如果注入方式换成构造器注入那么依赖获取发生在createBeanInstance阶段。此时 A 还没有执行addSingletonFactory三级缓存里没有 A 的工厂B 自然无法提前拿到 A最终只能抛出BeanCurrentlyInCreationException。这也是构造器循环依赖无法被三级缓存解决的根本原因。7.3 addSingletonFactory三级缓存写入的时机addSingletonFactory内部会把一个ObjectFactory放入singletonFactories。该工厂通常包装了getEarlyBeanReference这意味着它在被真正调用前不会急于创建代理对象。正是这种「延迟到被需要时再加工」的设计让 Spring 既可以支持循环依赖又能保证最终 Bean 与提前暴露的引用一致。同时这一步会做完整性检查如果容器不允许循环引用例如通过setAllowCircularReferences(false)关闭了能力那么容器会在发现循环时直接抛异常帮助开发者尽早暴露设计问题。八、循环依赖的实战规避方案Spring 支持循环依赖并不代表我们应该把循环依赖当成正常架构。大多数时候循环依赖意味着两个类职责耦合过重值得重新审视。下面按照「优先重构、其次破解、最后容错」的顺序给出常用手段。8.1 优先重构消除循环依赖如果 A 和 B 互相依赖通常可以抽出一个更小的 C 来承载共同职责。比如订单服务与用户服务互相调用可以把「根据订单查用户」「根据用户查订单」这类查询逻辑抽到专门的查询组件或聚合服务中。重构方向有以下几种抽取中间类将共同依赖的行为下沉到新的 Service 或 DomainService。事件解耦A 完成后发布事件B 监听事件做出反应避免同步调用。接口隔离让 B 只依赖 A 实现的最小接口并把这个接口抽到公共模块。这样做不仅解决循环依赖通常还能降低单类复杂度让模块边界更清晰。8.2 用 Lazy 化解构造器循环依赖如果循环依赖来自构造器注入最直接的补救是给其中一个构造器参数加上Lazy。Spring 不会立即注入真实 Bean而是先注入一个代理对象当第一次真正调用代理方法时才从容器中获取目标 Bean。Component public class OrderService { private final UserService userService; public OrderService(Lazy UserService userService) { this.userService userService; } }这里的Lazy起作用是因为 Spring 在解析构造器参数时发现需要懒加载就会先生成UserService的代理不触发UserService的真实创建。等OrderService创建完成后再去完成另外一边的创建依赖环就被时间差打破了。不过Lazy也有副作用代理会引入额外开销且调试时栈信息会多出一层。它更适合治理历史遗留代码而不是作为新代码的默认写法。8.3 改用 Setter 注入代替构造器注入构造器注入对不可变性和依赖完整性更友好但如果确实存在难以拆解的循环可以把其中一边改为字段注入或 Setter 注入。因为 Setter 注入发生在实例化之后正好落在三级缓存能够覆盖的窗口期内。Component public class OrderService { private UserService userService; Autowired public void setUserService(UserService userService) { this.userService userService; } }需要提醒的是这只是「能用」不是「最佳」。字段注入会弱化对象不可变性也更容易在测试中漏掉依赖。凡是能重构消除的循环仍应优先重构。8.4 通过 ApplicationContext 延迟获取另一种常见做法是让 Bean 实现ApplicationContextAware在真正需要对方时才从容器中拿引用而不是在创建阶段强依赖。Component public class OrderService implements ApplicationContextAware { private ApplicationContext applicationContext; private UserService userService; Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext applicationContext; } public UserService getUserService() { if (this.userService null) { this.userService applicationContext.getBean(UserService.class); } return this.userService; } }这种方式思路简单但会直接把容器 API 侵入业务代码可测试性和可读性都会下降通常只作为应急方案不建议大范围使用。九、循环依赖与 AOP 代理的那些坑很多项目里循环依赖没有被察觉直到某天加上Async或事务注解后突然启动失败或者某个增强莫名失效。这里单独把 AOP 相关的问题拎出来讲。9.1 Async 为什么会和循环依赖一起翻车Async生效通常需要代理对象。如果 A 和 B 形成循环依赖且 A 同时被Async增强早期暴露阶段与最终初始化阶段生成的代理必须一致。Spring 通过getEarlyBeanReference尽量在早期就生成同一个代理但经历过一些自定义 BeanPostProcessor 顺序错乱的场景后仍可能出现启动期异常或增强失效。Component public class A { Autowired private B b; Async public void asyncJob() { System.out.println(async job running); } } Component public class B { Autowired private A a; }排查这类问题时首先要观察异常是否指向getEarlyBeanReference或代理创建阶段其次检查是否有多个增强同时生效导致生成代理的链路变得复杂。临时解法通常是削弱循环依赖例如改 Setter 注入、加Lazy或者把Async方法拆到独立组件中。9.2 Transactional 与循环依赖的增强一致性事务注解最终也是靠代理实现。如果 A 被事务增强B 依赖 ASpring 会尽量让 B 拿到的早期引用也是代理对象从而保证事务拦截器正常生效。这正是三级缓存ObjectFactory存在的核心价值之一它把代理对象的生成封装起来在需要时统一生成避免原始对象和代理对象并存。但要注意如果代理是在自定义 BeanPostProcessor 中手动生成的并且没有遵循 Spring 的早期引用规则就可能出现「一级缓存是代理对象但循环依赖方拿到的却是原始对象」的错位。结论很简单除非你非常清楚自己在做什么否则不要绕过 Spring 的 BeanPostProcessor 体系手搓代理。9.3 线上快速定位循环依赖启动阶段遇到循环依赖通常会抛出BeanCurrentlyInCreationException异常信息里会包含当前 Bean 名和正在创建的 Bean 名这是最快的线索。你可以按下面思路排查看异常堆栈业务代码位置往往就是触发循环的注入点。看 Bean 定义确认两个类之间的注入方式、作用域和切面配置。看缓存快照调试时查看singletonsCurrentlyInCreation判断到底哪些 Bean 正在创建。做依赖图对复杂项目可以使用依赖分析工具生成 Bean 依赖关系图直观看到环的位置。十、高频面试追问与回答思路下面这些追问都是循环依赖话题下常见的「送命题」。建议先把结论背熟再用前面的源码细节补充论据。10.1 Spring 一定能解决循环依赖吗回答要点不能。Spring 默认只解决单例 Bean 在实例化完成之后的依赖环构造器注入的强依赖必须借助Lazy原型 Bean 的循环依赖则直接不支持。先把作用域、注入方式、是否代理三个维度摆出来再给结论会显得非常完整。10.2 二级缓存到底还有没有用回答要点有用。二级缓存用于承接从三级缓存工厂中生成出来的早期引用避免同一个半成品被工厂重复加工。同时二级缓存可以把「三级缓存中已消费的工厂」和「真正的早期对象」分开让getSingleton的逻辑边界更清晰。10.3 为什么不直接取消循环依赖支持回答要点Spring 提供三级缓存解决循环依赖是容器为了兼容大量历史 Bean 设计和提高易用性所做的让步。取消支持会让大量传统项目直接无法启动。Spring 的做法是默认支持但保留关闭能力鼓励开发者在合适场景下主动发现并重构依赖环。十一、建议背诵的高分回答模板最后给出一套可以直接用的回答框架适合面试被问到「Spring 如何解决循环依赖」时使用。先给结论Spring 通过三级缓存解决单例 Bean 的循环依赖但构造器注入和原型作用域无法直接解决。再说前提核心是利用「实例化」和「初始化」之间的时间差提前暴露半成品引用。描述三级缓存一级存成品、二级存半成品、三级存生成半成品或代理的工厂。串联流程用 A 和 B 的例子讲清楚创建顺序和缓存升降级。点出难点为什么要三级而不是两级因为要解决 AOP 代理一致性。收尾给边界说明构造器循环依赖需要Lazy并延伸到工程上如何规避循环依赖。记忆句实例化造壳、工厂暴露、属性填充碰壁、半成品救场、成品回填、代理统一。十二、总结循环依赖看似只是一个面试八股实际上把 Spring Bean 生命周期、缓存设计、依赖注入和 AOP 代理全部串了起来。掌握它至少能让你在三个层面说清楚是什么多个 Bean 互相等待对方创建完成形成引用闭环。怎么解单例 Bean 借助三级缓存提前暴露半成品引用打破创建顺序僵局。有什么边界构造器强依赖、原型作用域、复杂 AOP 场景都可能成为例外。建议把本文的推演过程复述三遍先能讲清 A、B 缓存的升降级再补上 AOP 代理一致性的论证面试基本就能稳稳拿分。工程实战中与其沉迷于炫技式地让 Spring 解环不如把循环依赖当作一次架构体检信号出现环的地方往往也是职责边界需要优化的地方。
返回列表