
前一阵我在本地起一个服务刚把Spring Boot跑起来控制台就刷下来一屏红色最刺眼的异常是BeanCurrentlyInCreationException直译过来就是“当前正在创建的Bean”。看到这个异常我第一反应是循环依赖接着去翻堆栈果然在构造器参数解析的位置卡住了A需要BB需要A两个Bean在创建起点互相锁死。这个问题很多同学都遇到过但大家真正卡壳的地方是一样的为什么同样都是循环依赖Setter注入和字段注入Spring能自动解决换成构造器注入就直接抛异常这篇文章我不打算停在“因为构造器无法提前暴露对象”这句话上直接翻Spring源码把三级缓存机制、Bean创建生命周期、异常抛出位置全部串起来讲清楚。如果你正在读Spring源码或者准备在面试里把这个点聊透这篇文章应该能省下你不少翻源码的时间。1. 循环依赖的本质与前置认知1.1 什么是循环依赖以及三种注入方式的不同命运循环依赖说白了就是两个或多个Bean在创建时需要彼此。最常见的是直接相互依赖A的构造器里要传BB的构造器里要传A也可能是间接形成环A依赖BB依赖CC又依赖A。Component public class A { private final B b; public A(B b) { this.b b; } } Component public class B { private final A a; public B(A a) { this.a a; } }上面这段代码启动时必然失败控制台会抛出BeanCurrentlyInCreationException。同样换成Setter注入Component public class A { private B b; Autowired public void setB(B b) { this.b b; } } Component public class B { private A a; Autowired public void setA(A a) { this.a a; } }Spring可以很轻松地完成创建谁先谁后都无所谓。字段注入和Setter注入本质上类似它们都属于“先创建对象、再填充属性”的路径而构造器注入属于“创建对象时就必须拿到依赖”的路径。命运不同的根源就藏在这三个字里时机。1.2 三种注入方式在Bean生命周期中的位置差异我当年理解这块绕了很久最后发现只要把Bean的生命周期画成一条时间线一切就清楚了。一个Bean从无到有核心经历四个阶段实例化调用构造器在堆上分配一块内存得到一个半成品对象。属性填充Spring通过populateBean方法处理Autowired字段、Setter方法把依赖塞进去。初始化执行InitializingBean、PostConstruct、自定义initMethod等。使用与销毁放入单例池singletonObjects供外界获取容器关闭时触发销毁。关键点在于Setter注入和字段注入发生在“属性填充”阶段那一刻对象已经通过构造器创建出来了是一个虽然属性为空、但引用可用的“半成品”。Spring可以利用这个时间窗把半成品的引用提前注册到缓存里用来打破循环。而构造器注入发生在“实例化”阶段也就是第1步。此时这个Bean连对象都还没有new出来自然不可能有“半成品”可供提前暴露。循环依赖的解决方案依赖一个前提得有一个“已经实例化但还没填属性”的对象引用能拿出来用。构造器阶段根本不存在这个对象所以构造器循环依赖在原理上就无解。一句话总结Spring解决循环依赖靠的是“先把半成品丢出来缓解尴尬”但构造器所处的阶段连半成品都还没诞生。2. 三级缓存是如何解决Setter循环依赖的2.1 三级缓存的结构与各自职责在聊构造器问题之前必须先弄清楚Spring到底用什么机制解决Setter循环依赖。这套机制的核心在Spring的DefaultSingletonBeanRegistry类中类里定义了三个Map也就是常说的三级缓存。/** 一级缓存存储完整的单例Bean */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存存储提前暴露的早期单例Bean尚未完成属性填充 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存存储创建单例Bean的工厂回调 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);三个缓存各管一段singletonObjects是一级缓存存放的是已经完整创建、属性填充完成、初始化完成的“成品Bean”。earlySingletonObjects是二级缓存存放的是“半成品Bean”也就是已经实例化、但还没走完属性填充和初始化的对象。singletonFactories是三级缓存存放的不是Bean本身而是一个ObjectFactory回调通过这个回调可以在合适的时机生成早期Bean引用。真正执行查找时顺序是固定的先查一级一级没有且当前Bean正在创建中再查二级二级也没有且允许提前引用再取三级回调生成对象并把生成结果放到二级缓存里。2.2 getSingleton的查找逻辑与加锁细节三级缓存的查找入口是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 ! NULL_OBJECT ? singletonObject : null); }这段逻辑有两个细节值得停下来多看两眼。第一个细节是synchronized锁。这里锁的是singletonObjects也就是一级缓存本体。为什么锁它因为三级缓存和二级缓存的写入、删除不是原子的如果多个线程同时创建不同Bean、又在对方创建过程中来取循环依赖引用很容易出现“一个线程从三级缓存取到了工厂另一个线程已经把这批工厂移除”的不一致状态。锁住一级缓存这个公共对象实际上是把“缓存转移”的操作串行化保证同一时刻只有一个线程在操作同一批缓存条目。第二个细节是三级缓存转到二级缓存之后会立刻把三级缓存里的工厂移除。这意味着getEarlyBeanReference只会执行一次。后续再有其他Bean来要这个早期引用直接命中二级缓存不会再调用工厂方法重新生成避免反复执行后置处理器导致代理对象不一致。2.3 doCreateBean中什么时候暴露早期引用真正把Bean放进三级缓存的时机在AbstractAutowireCapableBeanFactory的doCreateBean方法里。Spring在完成实例化之后、进入属性填充之前会判断是否需要提前暴露这个Bean的引用boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { if (logger.isTraceEnabled()) { logger.trace(Eagerly caching bean beanName to allow for resolving potential circular references); } addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }这三个条件缺一不可必须是单例Bean必须允许循环引用必须这个Bean确实正在创建流程中。方法体本身不复杂核心就是向singletonFactories注册一个lambda回调而这个回调指向getEarlyBeanReference。getEarlyBeanReference做的事情非常关键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; }默认情况下它返回的就是我们传入的原始Bean本身。但如果这个Bean需要被AOP增强比如存在切面、事务注解、异步注解等场景SmartInstantiationAwareBeanPostProcessor会在这一时刻提前生成代理对象并把代理对象暴露出去。这正好解释了一个常见的面试追问为什么Spring要用三级缓存而不是简单的一级缓存加二级缓存就够了如果只需要存储“原始对象的引用”那么二级缓存其实就足够了。但问题在于AOP。Spring希望循环依赖中其他Bean拿到的引用和最终完成创建后放到一级缓存里的对象是同一个也就是代理对象。如果只用二级缓存存原始对象那么被依赖方在属性填充时拿到的就是原始对象之后生成的代理和它就不是同一个引用整个程序的对象图就乱了。三级缓存存的是回调而不是对象理论上可以延迟到最后一刻再决定“要不要生成代理”。虽然没有这个三级缓存也可以改成“实例化之后立刻生成代理再存二级缓存”但这样做会让所有Bean在创建初期就被强制代理性能开销和额外的生命周期副作用都不划算。三级缓存的精妙之处在于只有真的出现了循环依赖才会去调用回调生成早期引用而正常Bean的创建路径上这个回调可能永远都不会被触发代理会按常规流程在初始化之后生成。所以所谓的“三级缓存”真正作用其实是“延迟决策”什么时候需要暴露引用暴露的是原始对象还是代理对象都由这个ObjectFactory回调在那一刻决定。2.4 一次完整的Setter循环依赖执行流程为了把上面的机制串起来我直接用A依赖B、B依赖A的场景推演一遍完整的执行顺序。假设Spring先创建AgetBean(A)进入AbstractBeanFactory调用getSingleton(A, singletonFactory)创建A。beforeSingletonCreation把A加入singletonsCurrentlyInCreation集合。doCreateBean(A)执行实例化A成功得到半成品A。代码走到addSingletonFactory把A的工厂回调放进三级缓存。populateBean(A)开始填充属性发现A依赖B触发getBean(B)。getBean(B)走同样的流程实例化B成功把B的工厂回调放进三级缓存然后populateBean(B)。B的属性填充阶段发现需要A调用getSingleton(A, true)。一级缓存没有A二级缓存没有A但三级缓存里有A的工厂回调。调用回调生成A的早期引用放入二级缓存移除三级缓存里的A工厂。半成品A被塞给B的属性B继续完成属性填充、初始化addSingleton把完整的B放进一级缓存。回到A的创建流程此时getBean(B)返回完整BA把B塞进自己的属性继续初始化最后放入一级缓存。这套流程中A在实例化之后就把自己“抵押”出去了换取B能够顺利完成创建然后B再反过来帮助A完成创建。前置条件是A必须已经是一个可被外部引用的对象。而构造器循环依赖连“抵押品”都没有。3. 构造器循环依赖死锁推演源码级故障现场3.1 构造器位于生命周期的哪个位置构造器注入发生在doCreateBean的createBeanInstance阶段而上一节我们提到addSingletonFactory暴露早期引用是在createBeanInstance之后。换句话说当A还在执行构造器时Spring压根还没有机会把A注册到三级缓存。这就形成了第一个断点A在构造时需要B但A自己的三级缓存工厂还没注册就算B想先拿A的早期引用也是拿不到的。B拿不到AB就无法继续B无法继续A也无法从B那边拿到依赖两个Bean在创建链条上互相等待死锁宣告形成。3.2 异常到底在哪里被抛出来很多人以为异常是在解析构造器参数时抛出的这句话说对了一半。真正抛出BeanCurrentlyInCreationException的位置在DefaultSingletonBeanRegistry的beforeSingletonCreation方法protected void beforeSingletonCreation(String beanName) { if (!this.inCreationCheckExclusions.contains(beanName) !this.singletonsCurrentlyInCreation.add(beanName)) { throw new BeanCurrentlyInCreationException(beanName); } }singletonsCurrentlyInCreation是一个Set集合用来记录当前正在创建中的Bean名字。add方法返回false说明集合里已经有这个名字了。A第一次创建时add(A)返回true正常通过当从B的创建流程递归回来再次尝试创建A时add(A)返回false说明“A已经在创建集合里了”Spring判定出现了不可解析的循环引用于是抛异常。所以异常的本质是“同一个Bean在创建过程中被递归请求了第二次”。Setter循环依赖之所以能绕开这个异常是因为在递归回来时Spring能通过三级缓存提前拿到A的引用从而不会真正触发A的第二次创建。而构造器循环依赖没有三级缓存兜底递归回来只会再次走进beforeSingletonCreation直接被add的失败结果拦住。3.3 完整推演A构造注入B、B构造注入A我按源码执行顺序走一遍你就会看到异常是在哪个精确位置爆出来的。第一次getBean(A)走进getSingleton的beforeSingletonCreationadd(A)成功A进入创建集合。doCreateBean(A)执行到createBeanInstanceSpring开始解析A的构造器参数发现需要B于是调用getBean(B)。第二次getBean(B)同样add(B)成功B进入创建集合。doCreateBean(B)执行到createBeanInstance开始解析B的构造器参数发现需要A于是再次调用getBean(A)。这一次getBean(A)会经历什么先执行getSingleton(A, true)查到一级缓存singletonObjects没有因为A还没创建完。查到二级缓存earlySingletonObjects没有。查到三级缓存singletonFactories也没有因为A的createBeanInstance还没结束addSingletonFactory根本没执行到。返回null。doGetBean拿到null之后确定A不是共享的早期引用于是走完整创建逻辑。由于A是单例进入getSingleton(A, singletonFactory)在方法内部又一次调用beforeSingletonCreation(A)。这时add(A)返回false因为A已经在创建集合里了。BeanCurrentlyInCreationException直接抛出。你会看到的堆栈大概长这样BeanCurrentlyInCreationException: Error creating bean with name a: Requested bean is currently in creation: Is there an unresolvable circular reference? at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:355)对应源码里那个add返回false的分支。整条链路下来你会发现Spring不是没有做出努力而是它努力的方式依赖“提前暴露的引用”但构造器阶段不产生任何可暴露的引用。而且A在递归回来前甚至还没走完createBeanInstance它连一个可用的对象都快照都没有。3.4 为什么官方选择不解决构造器循环依赖官方为什么不在这儿也设计一个缓存机制这是面试里最值得展开的部分。我的理解是构造器注入承载着“强依赖”的语义它要求对象在被创建时就具备所有必要条件作为整体参与后续逻辑。如果让一个还没走完构造器的对象提前暴露给别的Bean那么这个对象处于“构造未完成”的状态内部状态完全没有建立起来任何方法调用都可能是危险的。你可以类比租房Setter循环依赖相当于先拿钥匙住进去家具后面慢慢添构造器循环依赖相当于房东要求“你把家具全部搬进来才给你钥匙”可你搬家具又需要先进门。Spring选择了一条妥协路径对于Setter场景它可以容忍半成品状态对于构造器场景它选择直接拒绝用启动失败来强制你修改设计。另外一点是prototype作用域的循环依赖。Spring对原型Bean没有缓存每次getBean都是新实例即便属性注入也无法通过缓存打破循环会抛出isPrototypeCurrentlyInCreation异常。这说明Spring的循环依赖解决能力天生就是和单例缓存绑定的一旦脱离“缓存复用”这个前提问题立刻回到无解状态。4. 构造器循环依赖的实用解决方案4.1 用Lazy打破死锁最常见的解决手段是在构造器参数上加LazyComponent public class A { private final B b; public A(Lazy B b) { this.b b; } }它的原理并不神秘。Spring解析构造器参数时如果发现Lazy注解就不会去容器里真正获取B而是生成一个B的代理对象直接把代理传给A。A可以顺利完成构造之后真正调用B的方法时代理内部才触发getBean(B)去创建真实的B。运行起来之后如果B内部又依赖A那时A已经创建完成并躺在单例池里了B可以正常拿到A的完整引用。这样一来构造器循环被“延迟到使用期”化解而不是在创建期强行绕开。使用Lazy有几个注意点。第一代理类需要能够拦截目标方法调用如果B的方法是final或者私有方法代理方案会遇到麻烦第二不要在A的构造函数里调用B的方法因为此时B的真实对象可能还没创建调用会触发一个创建流程甚至可能在复杂场景下再次进入循环创建第三Lazy是治标手段它能解除启动的死锁但两个Bean之间纠缠不清的依赖关系依然存在后期维护还是会难受。4.2 重构设计拆公共模块与延迟获取比加注解更值得做的是先停下来想想这两个类为什么互相依赖。现实中我见过的大量构造器循环依赖其实都是职责边界划偏了。最典型的场景是两个业务Service互相调用A需要B查询某个数据B又需要A处理某个流程。这种双向依赖往往意味着二者之间存在公共职责没有提取出来比如公共的规则计算、公共的状态流转逻辑。把这些公共内容下沉到一个底层Service或者领域服务里让A和B都只依赖这个公共模块循环自然消失。另一种方式是使用ObjectProvider延迟获取Component public class A { private final ObjectProviderB bProvider; public A(ObjectProviderB bProvider) { this.bProvider bProvider; } public void doSomething() { B b bProvider.getIfAvailable(); // 使用b } }ObjectProvider本身是一个独立的BeanA的构造器只依赖ObjectProvider不依赖B不会触发循环。到实际使用的时候再通过Provider去容器里拿B如果B还没创建完成此时也会走正常的递归创建流程但因为A已经完整创建所以不会有问题。至于把构造器注入改成Setter或字段注入确实也能解决死锁但从设计角度我不太建议直接这么干。它会丢掉final修饰和不可变性让依赖关系变得隐蔽循环依赖只是被缓存机制掩盖了而不是被消除。我一般只在“确实需要快速让项目跑起来”的时候临时用它后续还是要回到重构公共模块的方向上。4.3 历史上的配置开关与版本差异Spring Boot 2.6之前Setter/字段注入的循环依赖默认是允许的。从Spring Boot 2.6开始默认禁止循环依赖启动时如果检测到循环引用会直接报错因为Spring觉得这种依赖关系本身就是设计上的坏味道。相关的配置项是spring.main.allow-circular-referencesfalse如果项目里确实有Setter循环依赖并且你确定短期内无法重构可以把这个属性改为true让项目回到旧版本行为。但注意这个开关只影响Setter/字段注入场景它管不到构造器循环依赖。构造器循环依赖在哪个版本都不会被这个开关放行原因还是那一条三级缓存里根本没有可用的早期引用。如果你用的是普通Spring也可以在AbstractAutowireCapableBeanFactory上设置allowCircularReferencesDefaultListableBeanFactory factory (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory(); factory.setAllowCircularReferences(false);设置为false之后正常条件下Setter循环依赖也会失败调用doCreateBean里addSingletonFactory会跳过不给任何Bean提前暴露的机会。这个设置在排查“到底有哪些Bean隐藏了循环依赖”时很有用它能让问题在启动期集中暴露出来。5. 实战排查与避坑清单5.1 快速定位循环依赖的三个步骤第一步看异常栈。BeanCurrentlyInCreationException出现时栈里面会标注当前正在创建的Bean名字顺着Caused by往上翻通常能看到是哪两个Bean在互相等待。我遇到过很诡异的间接循环A依赖BB依赖CC又依赖A异常栈里名字看起来不太相关这时候就顺着“参数解析→递归getBean”的链路一层层往下看。第二步断点观察创建集合。在DefaultSingletonRegistry的beforeSingletonCreation方法上加断点每次有Bean进入创建集合时都会停一次。此时IDEA的Variables面板里会显示singletonsCurrentlyInCreation这个Set的内容你会发现A和B的名字一个接一个被加进去当第二次出现同一个名字时异常就在眼前。这个方法特别适合理解间接循环看到的集合变化比堆栈更直观。第三步判断注入方式。看报错Bean的构造器、字段和Setter上的注解确定问题到底是不是构造器循环。如果项目里同时存在Setter循环和构造器循环异常可能会被混淆先把Setter能过的都过了剩下的聚在一起看。5.2 常见问题速查表现象可能原因处理方式启动报BeanCurrentlyInCreationException堆栈停在构造器参数解析构造器注入循环依赖拆构造器、Lazy、重构公共依赖启动报BeanCurrentlyInCreationException堆栈在Setter或字段注入高版本默认禁止循环引用或代码里关闭了allowCircularReferences评估重构确实无法改时开启allow-circular-references加了Lazy后启动成功运行时却出现NullPointer或代理异常代理对象在真实Bean创建完成前被调用检查是否在构造器或初始化方法里调用了被代理Bean的方法报isPrototypeCurrentlyInCreation异常prototype作用域无法通过缓存打破循环改为singleton或用ObjectProvider/ApplicationContext延迟获取添加allow-circular-referencestrue后构造器循环依赖仍然报错该开关对构造器循环无效依赖Lazy或重构设计才能解决5.3 我踩过的一些坑关于Lazy有一个隐藏比较深的问题它生成的是代理代理在第一次方法调用时才去获取真实Bean。如果这个代理对象在容器启动预热阶段就被提前调用而真实Bean又因为某种顺序原因还没创建完就会又绕回一个低层级的创建循环里。所以在加了Lazy之后我会习惯性地在启动日志里关注目标Bean的创建顺序确认没有在启动阶段触发代理方法。还有一个容易误判的情况字段注入也会出现循环依赖异常。有人以为只有构造器才会其实如果项目里显式设置allowCircularReferences为falseSetter和字段注入都会被直接拒绝。这时候不要怀疑Spring版本先检查配置里有没有把开关关掉。调试构造器循环时最有效的还是断点。有一次我在一个大型项目里定位间接循环光看日志翻了半天也没理清后来在DefaultSingletonBeanRegistry的getSingleton(String, ObjectFactory)方法入口加断点同时观察singletonsCurrentlyInCreation集合不到五分钟就找到了那个递归入口。源码调试对于理解Spring内部流程来说永远比看文档快。最后说一点我自己的体会。我在本地复现这个问题时特意把断点打在DefaultSingletonBeanRegistry的beforeSingletonCreation上看到A和B先后被写进singletonsCurrentlyInCreation集合第二次回到A的时候add方法返回false然后异常就产生了那一刻整个机制突然就通了。后来我养成了一个习惯看到构造器循环依赖的报告不急着加Lazy打补丁先想想这两个类为什么互相依赖。很多时候把公共逻辑下沉一层或者把调用方向理顺循环自然就消失了。Spring用三级缓存兜住了Setter场景但构造器场景它选择放手这本身就是设计上的一种提示依赖关系本来就应该是单向的。