1. 从一次诡异的Bean创建异常说起
那天下午,我正在调试一个看起来平平无奇的Spring Boot服务。服务启动时,控制台突然抛出了一个异常,不是常见的BeanCreationException,而是一个更底层的、关于BeanCurrentlyInCreationException的错误,错误信息里反复提到了“循环依赖”和“正在创建中”。这让我有点意外,因为项目结构并不复杂,理论上不应该出现这么明显的循环依赖问题。我检查了Bean的定义,发现是两个Service之间互相@Autowired了对方,这确实是教科书式的循环依赖场景。但奇怪的是,这个项目之前一直运行得好好的,直到我引入了一个@Async注解在其中一个Service的方法上,问题才暴露出来。
这个经历让我重新审视Spring处理循环依赖的机制。我们都知道Spring通过“三级缓存”解决了单例Bean的循环依赖问题,这几乎是面试八股文的必考题。但当你真的在复杂场景下(比如结合了AOP、@Async、@Transactional)遇到问题时,仅仅背出“一级缓存放成品Bean,二级缓存放早期暴露对象,三级缓存放ObjectFactory”是远远不够的。你需要真正理解每一级缓存存在的必要性、它们协同工作的时序,以及这个精巧设计背后的权衡与边界。这次,我们就抛开那些笼统的概念,直接深入到DefaultSingletonBeanRegistry的源码里,看看这三级缓存到底是如何在Bean的生命周期中“辗转腾挪”,化险为夷的。
2. 三级缓存的庐山真面目:源码中的三个Map
要理解三级缓存,首先得找到它们藏在哪。在Spring IoC容器的核心——DefaultSingletonBeanRegistry类中,定义了三个至关重要的Map,这就是我们常说的三级缓存。它们不是任何配置,而是Spring框架为解决单例Bean循环依赖而设计的内部数据结构。
2.1 一级缓存:singletonObjects– 成品的归宿
/** Cache of singleton objects: bean name to bean instance. */ private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);这是大家最熟悉的一级缓存,也叫“单例池”。它的角色非常明确:存放已经完全初始化好的、可供直接使用的成品Bean。当一个Bean走完了完整的生命周期(实例化、属性填充、初始化),它就会被放入这个singletonObjects中。之后,任何地方通过getBean()方法请求这个Bean时,容器会首先来这里查找。如果找到了,直接返回,这是性能最高、最直接的路径。你可以把它想象成一个餐厅的“出菜口”,做好的菜都放在这里,服务员直接从这里端给客人。
2.2 二级缓存:earlySingletonObjects– 半成品的临时驿站
/** Cache of early singleton objects: bean name to bean instance. */ private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);二级缓存的存在,是解决循环依赖的关键一环。它存放的是**“早期暴露”的Bean引用**。什么是早期暴露?在Bean的生命周期中,通常是在“实例化”(调用构造方法创建出对象)之后,但在“属性填充”和“初始化”之前,Spring会尝试将这个刚创建出来、还是个“空壳”的对象提前暴露出去。这个对象可能还没有注入依赖,也没有执行@PostConstruct方法,但它已经是一个Java对象了。
二级缓存就是这个提前暴露的对象的临时存放点。它的生命周期很短,主要服务于解决循环依赖的场景。一旦Bean完全初始化完毕,它会被从earlySingletonObjects移动到singletonObjects,然后在这里被清理掉。它就像一个餐厅的“备餐区”,菜只进行了一半的加工(比如肉切好了,但还没下锅炒),但因为下一道菜急需这个原料,所以先把它拿出来应急。
2.3 三级缓存:singletonFactories– 生产半成品的工厂
/** Cache of singleton factories: bean name to ObjectFactory. */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);三级缓存是最特殊、也最容易被误解的一级。它存放的不是Bean对象本身,而是一个ObjectFactory<?>(对象工厂)。这个工厂的作用是:当需要获取某个Bean的早期引用时,能够动态地创建或返回一个处理过的对象。
为什么需要工厂,而不是直接放对象?这涉及到Spring AOP(以及通过@Async、@Transactional等注解实现的代理)。如果一个Bean需要被代理(例如,被AOP切面增强),那么最终暴露给其他Bean使用的,不应该是原始对象,而应该是它的代理对象。这个代理对象何时创建?是在Bean初始化后,由BeanPostProcessor处理的。但在循环依赖的场景下,其他Bean在属性注入阶段就需要引用这个Bean,此时它的代理可能还没生成。
ObjectFactory就是为了解决这个“时机”问题。它封装了一段逻辑:当被调用时,它可以判断当前情况,决定是返回原始对象,还是返回一个提前创建好的代理对象(通过SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference方法)。这给了Spring极大的灵活性。你可以把它理解为一张“菜品加工券”,凭此券可以到后厨要求对半成品(早期对象)进行特定的加工(如代理),然后得到最终可用的形态。
注意:三级缓存(
singletonFactories)在Bean的早期引用被获取一次后,通常就会被移除。其对应的ObjectFactory是一次性的,执行完就失效了。这是为了确保Bean引用的唯一性和一致性。
3. 循环依赖的破局:三级缓存协同作战全流程
理论总是抽象的,我们通过一个经典的Setter注入循环依赖场景,来还原三级缓存是如何一步步配合,打破僵局的。假设有AService和BService互相依赖。
@Service public class AService { @Autowired private BService bService; // ... } @Service public class BService { @Autowired private AService aService; // ... }Spring容器启动,开始创建AService这个Bean。这个过程发生在AbstractAutowireCapableBeanFactory.doCreateBean()方法中。
3.1 第一步:创建A实例,并提前暴露工厂
- 实例化:Spring调用
AService的构造方法,创建出一个原始对象a。此时a里面的bService字段是null。 - 暴露工厂(关键操作):在属性填充之前,Spring会执行一个至关重要的操作——
addSingletonFactory。
此时,// AbstractAutowireCapableBeanFactory.doCreateBean boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { // 将BeanName和对应的ObjectFactory放入三级缓存 addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); }AService对应的ObjectFactory被放入了三级缓存(singletonFactories)。这个工厂封装了获取AService早期引用的逻辑,其中包含了处理AOP代理的可能性。注意,此时一级和二级缓存都没有AService。
3.2 第二步:填充A的属性,触发B的创建
- 属性填充:Spring开始为
AService的实例a填充属性@Autowired BService bService。 - 获取B:为了得到
BService,容器调用getBean(“bService”)。 - 创建B实例:和A一样,Spring开始创建
BService。先调用构造方法创建出原始对象b,然后将BService的ObjectFactory也放入三级缓存。
3.3 第三步:填充B的属性,向A求助
- B的属性填充:Spring开始为
BService的实例b填充属性@Autowired AService aService。 - 获取A(循环点):容器再次调用
getBean(“aService”)来获取AService。 - 三级缓存的第一次立功:这次
getBean不会从头创建A,而是会触发缓存查找逻辑。在DefaultSingletonBeanRegistry.getSingleton(String, boolean)方法中,查找顺序是:- 一级缓存
singletonObjects:没有(A还没初始化完)。 - 二级缓存
earlySingletonObjects:没有。 - 三级缓存
singletonFactories:找到了!之前存放的AService的ObjectFactory。
- 一级缓存
- 执行工厂:Spring调用这个
ObjectFactory.getObject()。这个方法会执行getEarlyBeanReference,如果AService需要被代理(比如有AOP切面),这里就会提前生成代理对象;如果不需要,则直接返回原始对象a。我们假设这里不需要代理,返回了原始对象a。 - 升级缓存:从三级缓存获取到对象
a后,Spring会做两件事:- 将对象
a放入二级缓存(earlySingletonObjects)。 - 将
AService对应的ObjectFactory从三级缓存中移除。 现在,AService的早期引用a存在于二级缓存中。这个a被成功注入到了BService的实例b中。
- 将对象
3.4 第四步:B完成初始化,回归一级缓存
- B完成生命周期:
BService的属性aService已经注入完毕(注入了A的早期引用a),接着Spring完成BService的后续初始化(执行@PostConstruct等),得到一个完全体b。 - B入驻一级缓存:完全体
b被放入一级缓存(singletonObjects)。同时,BService相关的ObjectFactory从三级缓存移除,早期引用从二级缓存移除(如果之前有的话)。
3.5 第五步:A完成初始化,闭环
- A继续未竟之事:此时,
AService的属性填充步骤(之前在等getBean(“bService”)返回)终于收到了返回值——刚刚创建好的完全体b。于是,b被注入到a的bService字段中。 - A完成生命周期:
AService接着完成自己的初始化过程。 - A入驻一级缓存,清理二级缓存:完全体的
a被放入一级缓存。同时,Spring会发现AService在二级缓存中还有一个早期引用,于是将其从二级缓存中移除。
至此,循环依赖完美解决。AService和BService的成品Bean都安静地待在一级缓存里,可供随时使用。整个过程中,三级缓存提供了获取早期引用的能力,二级缓存作为临时存储避免了工厂的重复执行,一级缓存则是最终的归宿。
实操心得:理解这个流程后,你就能解释很多现象。比如,为什么构造器注入无法解决循环依赖?因为构造器注入发生在实例化阶段,此时对象还没创建出来,更无法提前暴露
ObjectFactory到三级缓存,死锁必然发生。Setter/字段注入之所以可以,是因为注入发生在实例化之后、初始化之前,那时已经有对象和工厂可以提前暴露了。
4. 为什么必须是三级?两级或一级不行吗?
这是一个经典的面试题,也是理解Spring设计精妙之处的关键。我们分别来分析如果只有一级或两级缓存会怎样。
4.1 假设只有一级缓存(singletonObjects)
如果只有成品缓存,那么流程根本无法进行。当创建A到一半,需要B,而创建B又需要A时,由于A还没有成为“成品”(未完成属性填充和初始化),它不会被放入一级缓存。B在获取A时直接返回null或抛出异常,循环依赖无解。所以,必须有一个空间来存放“半成品”。
4.2 假设只有两级缓存(成品缓存+半成品缓存)
这是我们思考的重点。很多初学者会想,既然二级缓存(earlySingletonObjects)已经能存放早期对象了,为什么还要三级缓存(singletonFactories)这个工厂?直接把早期对象放进二级缓存不行吗?
理论上,对于没有AOP的普通Bean,是可行的。在A实例化后,直接把原始对象a扔进二级缓存。B创建时需要A,直接从二级缓存拿到a注入,然后B完成初始化,A再完成初始化。似乎也能跑通。
但一旦引入AOP(或任何需要创建代理的BeanPostProcessor),问题就来了。核心矛盾在于:代理对象创建的时机与循环依赖的需求时机存在冲突。
- 正常的、无循环依赖的Bean创建流程:
实例化 -> 属性填充 -> 初始化 -> (AOP)创建代理 -> 放入一级缓存。代理是在初始化之后才创建的。 - 循环依赖下的需求:B在属性填充阶段就需要A的引用。如果此时A还在创建中,它应该给B一个什么?给原始对象?但最终B注入的应该是A的代理对象,否则AOP增强会失效(例如
@Transactional注解的方法调用同类其他方法时,如果注入的是原始对象,事务会失效)。给代理对象?但A的初始化还没完成,代理对象按理说还无法生成。
三级缓存中的ObjectFactory正是为了解决这个“时机悖论”而设计的。它不是一个简单的对象存储,而是一个延迟决策和处理的机制。
当B通过getSingleton(“aService”)请求A时,会调用三级缓存中的ObjectFactory.getObject()。这个方法内部会调用SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference()。Spring内置的AOP代理创建器(AbstractAutoProxyCreator)就实现了这个接口。
- 如果A最终需要代理:在
getEarlyBeanReference()方法中,AOP框架会判断是否已经为这个Bean创建过早期代理。如果是第一次被索取早期引用,它会提前为A创建代理对象并返回。这个代理对象会被放入二级缓存,后续所有需要A早期引用的地方(虽然通常只有一个),都从这个二级缓存获取,保证了引用的一致性。 - 如果A最终不需要代理:
getEarlyBeanReference()方法直接返回原始对象。
所以,三级缓存的核心价值在于:将“早期引用”的生成逻辑封装成一个可延迟执行的工厂。它把“是否要创建代理”以及“如何创建代理”的决策,推迟到真正有其他Bean需要注入它的那一刻,从而完美适配了AOP代理的创建流程,保证了在循环依赖场景下,注入的引用与最终成品Bean(无论是原始对象还是代理对象)在类型和行为上的一致性。
踩坑记录:这就是为什么在我的开篇案例中,给一个Service方法加上
@Async会引发循环依赖异常。@Async也是通过BeanPostProcessor创建代理来实现的。在某些复杂的代理创建场景或代理处理器顺序问题下,三级缓存的协调过程可能出现意外,导致早期引用获取失败,从而抛出BeanCurrentlyInCreationException。解决方法通常是调整Bean的依赖关系,或者使用@Lazy注解进行延迟注入,打破即时的依赖索取。
5. 三级缓存的边界与失效场景
三级缓存机制虽然强大,但它不是万能的。理解它的边界,能帮助我们在设计时避免陷阱。
5.1 非单例Bean(Prototype)
三级缓存只针对单例(Singleton)Bean。对于原型(Prototype)作用域的Bean,Spring容器根本不会缓存它们,每次getBean()都会创建一个新的实例。因此,原型Bean之间的循环依赖,Spring会直接抛出BeanCurrentlyInCreationException,因为它无法也不应该去解决这种每次请求都产生新对象的依赖闭环。
5.2 构造器注入(Constructor Injection)
如前所述,这是三级缓存机制无法解决的硬伤。因为构造器调用发生在实例化阶段的第一步,此时Bean的实例尚未创建,更谈不上将ObjectFactory加入三级缓存。当两个Bean都通过构造器相互依赖时,Spring在启动时就会检测到并抛出异常。这是Spring官方明确声明不支持的情况。解决方法是改用Setter或字段注入,或者重构设计消除循环依赖。
5.3 某些特殊的BeanPostProcessor处理顺序
Spring允许我们自定义BeanPostProcessor,并且可以通过实现Ordered接口或使用@Order注解来指定执行顺序。如果某个BeanPostProcessor在SmartInstantiationAwareBeanPostProcessor(负责getEarlyBeanReference)之前执行,并且它试图去获取一个正在创建中的Bean的最终形态,可能会遇到问题。因为此时可能连早期引用都还没生成。
5.4allowCircularReferences配置
在AbstractApplicationContext中,有一个setAllowCircularReferences(boolean)方法,默认是true。如果将其设置为false,Spring将完全禁止循环依赖,三级缓存的循环依赖解决机制将被关闭。任何形式的循环依赖都会导致启动失败。这个配置在某些对代码质量要求极高、强制要求消除所有循环依赖的项目中可能会被使用。
6. 从源码角度验证:getSingleton方法逐行解析
让我们聚焦到最核心的DefaultSingletonBeanRegistry.getSingleton(String, boolean)方法,看看代码是如何实现上述逻辑的。这个方法清晰地展示了三级缓存的查询顺序和升级逻辑。
// DefaultSingletonBeanRegistry.java protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 1. 首先,尝试从一级缓存(成品缓存)获取 Object singletonObject = this.singletonObjects.get(beanName); // 如果没找到,并且该Bean正在创建中(说明可能存在循环依赖) if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { synchronized (this.singletonObjects) { // 2. 尝试从二级缓存(早期引用缓存)获取 singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { // 3. 尝试从三级缓存(工厂缓存)获取工厂 ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { // 4. 执行工厂,获取早期对象 singletonObject = singletonFactory.getObject(); // 5. 将获取到的对象放入二级缓存,并从三级缓存移除工厂 this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } return singletonObject; }这段代码是三级缓存协同工作的核心算法:
- 先查一级:最快路径,直接返回成品。
- 再查二级:如果Bean正在创建中,说明它可能是个“半成品”,去二级缓存找找看有没有提前放进去的早期引用。
- 最后查三级:如果二级也没有,但允许早期引用(
allowEarlyReference通常为true),就去三级缓存找工厂。 - 工厂生产与缓存升级:找到工厂后,调用
getObject()生产出早期对象(可能是原始对象,也可能是提前创建的代理)。然后将这个对象升级到二级缓存,同时删除三级缓存中的工厂。这个“升级”操作确保了同一个Bean的早期引用只会通过工厂创建一次,后续的索取都直接走二级缓存,保证了效率与一致性。
7. 设计启示与最佳实践
探究Spring三级缓存机制,不仅能帮助我们解决实际问题,更能获得一些深刻的设计启示。
1. 空间换时间与延迟决策三级缓存本质上是“空间换时间”和“延迟决策”的经典结合。通过引入额外的存储结构(二级、三级缓存),避免了循环依赖导致的死锁。更重要的是,通过ObjectFactory将代理对象的创建决策延迟到真正被需要的那一刻,优雅地解决了代理时机问题。这在软件设计中是一个常用思路:当面临不确定或成本较高的操作时,先提供一个轻量的“承诺”或“工厂”,等到必须执行时再兑现。
2. 状态迁移的清晰界定从三级缓存(工厂)-> 二级缓存(半成品)-> 一级缓存(成品),Bean的状态迁移路径非常清晰。每一级缓存都代表了Bean生命周期的不同阶段。这种明确的状态划分,使得代码逻辑(如getSingleton方法)可以有条不紊地处理各种边界情况。在我们的业务代码设计中,明确对象的状态并为之设计相应的处理逻辑,同样能减少Bug。
3. 对Spring使用者的建议
- 优先使用构造器注入:虽然它不能解决循环依赖,但它是一种更安全的依赖注入方式,能明确声明Bean的必需依赖,并使Bean在构造完成后就处于完全初始化的状态。很多现代Spring实践(如Spring官方指南)都推荐构造器注入作为首选。
- 警惕循环依赖:尽管Spring提供了三级缓存机制来救场,但循环依赖本身是代码结构上的一个“坏味道”(Code Smell),它通常意味着类的职责边界不清晰,耦合度过高。应当将其视为重构的提示,考虑使用“引入第三方”、“事件驱动”、“方法参数传递”等方式来解耦。
- 理解
@Lazy注解:@Lazy注解是解决某些复杂循环依赖场景的利器。它告诉Spring延迟初始化Bean,或者在注入时先注入一个代理,等到第一次真正调用时再初始化真实对象。这相当于在依赖链中插入了一个“缓冲”,打破了即时的循环。但滥用@Lazy可能会掩盖设计问题,并带来运行时性能开销和调试复杂性。 - 复杂AOP场景下的测试:当你的服务层大量使用
@Transactional,@Async,@Cacheable等基于AOP的注解时,在涉及循环依赖的Bean上进行集成测试尤为重要,以确保代理被正确创建且行为符合预期。
回过头看最初那个因@Async引发的异常,根本原因是在那个特定场景下,代理创建链与三级缓存的交互出现了预期之外的情况。最终的解决方案并不是调整缓存,而是通过代码重构,将AService中一个独立的方法抽离到一个新的TaskService中,由这个新Service来承载@Async方法,从而彻底消除了AService和BService之间的直接循环依赖。这再次印证了那句话:框架提供的复杂机制是我们的安全网,但清晰简洁的代码设计才是最好的保障。