Spring 循环依赖与三级缓存

Spring 循环依赖与三级缓存:一场"先有鸡还是先有蛋"的优雅破局

本文从一个真实的报错场景出发,带你一步步理解 Spring 如何用三级缓存化解循环依赖,并回答那个经典追问:为什么是三级,而不是二级?

引言:一个让人摸不着头脑的报错

设想你正在开发一个电商系统。OrderService(订单服务)处理订单时需要查询用户信息,于是注入了 UserService;而 UserService 在推送消息时又需要知道用户有哪些订单,于是注入了 OrderService

java 复制代码
@Service
public class OrderService {
    @Autowired
    private UserService userService;  // 订单要查用户

    public void createOrder(Long userId) {
        String userName = userService.getUserName(userId);
        System.out.println("为用户 " + userName + " 创建订单");
    }
}

@Service
public class UserService {
    @Autowired
    private OrderService orderService;  // 用户要查订单

    public void showUserOrders(Long userId) {
        var orders = orderService.listByUser(userId);
        System.out.println("用户订单:" + orders);
    }
}

看起来很合理,对吧?但如果你把其中一个改成构造器注入,启动时会直接炸出这样的异常:

css 复制代码
The dependencies of some of the beans in the application context
form a cycle:

┌─────┐
|  orderService
↑     ↓
|  userService
└─────┘

这就是循环依赖(Circular Dependency):创建 A 需要 B,创建 B 又需要 A,像极了"先有鸡还是先有蛋"。

有意思的是:上面的字段注入版本不会报错,而构造器注入版本会。为什么?答案藏在 Spring 的三级缓存里。读完本文,你不仅能解释这个现象,还能在面试中把源码级细节讲得明明白白。

一、问题本质:一个走不出去的死循环

先抛开 Spring,想想容器创建 Bean 的朴素逻辑:

flowchart TD A[创建 OrderService] --> B{需要 UserService?} B -- 是 --> C[去创建 UserService] C --> D{需要 OrderService?} D -- 是 --> E[去创建 OrderService...] E --> B style E fill:#f66,color:#fff

如果不做任何干预,这就是一个无限递归,最终栈溢出。Spring 破局的思路其实非常朴素:

把"创建"拆成三步------先实例化(new 出空壳)→ 再填充属性 → 最后初始化。实例化一完成,就把自己的引用"借"出去。

于是 B 来要 A 时,拿到的虽然是"半成品 A"(属性还是 null),但引用是同一个对象,等 A 后续填充、初始化完成后,B 手里的引用自然就是完整的 A 了。死循环就此打破。

那三级缓存在这中间扮演什么角色?别急,先看它们各自存的是什么。

二、三级缓存:成品仓库、半成品展示柜、配方工厂

核心代码位于 DefaultSingletonBeanRegistry

java 复制代码
public class DefaultSingletonBeanRegistry ... {
    // 一级缓存:成品 Bean(完整走完生命周期)
    private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

    // 二级缓存:半成品 Bean(已实例化、未完成属性填充/初始化),
    // 存的可能是提前生成的代理对象
    private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

    // 三级缓存:对象工厂 Lambda,用于决定是否提前生成代理
    private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
}

用生活化的比喻来理解:

缓存 比个比方 存的东西 什么时候放进去
一级 singletonObjects 成品仓库 完整走完生命周期的 Bean Bean 全部创建完成后
二级 earlySingletonObjects 半成品展示柜 提前曝光的早期对象(可能是 AOP 代理) 三级缓存中的工厂被调用后,结果升级到这里
三级 singletonFactories 配方/工厂 ObjectFactory Lambda,尚未执行 Bean 刚实例化(还是空壳)时

一句话总结:一级存成品,二级存半成品,三级存制造半成品的配方。

你可能会问:直接搞一个二级缓存(实例化后就放半成品)不就完了吗?为什么还要多此一举搞个"没执行的工厂"?这是本文最重要的追问,我们先把流程走完,最后揭晓答案。

三、完整流程:跟着源码走一遍

下面用时序图把整个流程串起来(A = OrderService,B = UserService):

sequenceDiagram participant App as 调用方 participant BC as AbstractBeanFactory participant A as 创建A流程 participant Reg as 三级缓存(Registry) participant B as 创建B流程 App->>BC: getBean(&#34;a&#34;) BC->>A: createBean(A) A->>A: ①反射实例化A(空壳) A->>Reg: ②addSingletonFactory(&#34;a&#34;, 工厂)<br/>放入三级缓存 A->>A: ③populateBean 填充属性 A->>BC: getBean(&#34;b&#34;) 发现需要B BC->>B: createBean(B) B->>B: ④反射实例化B(空壳) B->>Reg: ⑤addSingletonFactory(&#34;b&#34;, 工厂)<br/>放入三级缓存 B->>B: ⑥populateBean 填充属性 B->>BC: getBean(&#34;a&#34;) 发现需要A BC->>Reg: getSingleton(&#34;a&#34;) Reg->>Reg: 一级缓存未命中<br/>二级缓存未命中<br/>三级缓存命中工厂 Reg-->>B: 工厂.getObject() 返回A的早期引用<br/>(可能是AOP代理),并升级入二级缓存 B->>B: ⑦B.a = a(引用注入完成) B->>B: ⑧initializeBean 初始化B B-->>BC: 返回成品B,放入一级缓存 BC-->>A: B 就绪 A->>A: ⑨a.b = b(引用注入完成) A->>A: ⑩initializeBean 初始化A Note over A: 若二级缓存中A的早期引用<br/>与初始化后对象不一致<br/>(被代理过),则A最终<br/>以二级缓存的代理为准 A-->>BC: 返回成品A,放入一级缓存 BC-->>App: getBean(&#34;a&#34;) 完成

对应到源码,关键步骤有三处。

步骤 1:A 实例化后,把"配方"放进三级缓存

AbstractAutowireCapableBeanFactory#doCreateBean

java 复制代码
protected Object doCreateBean(...) {
    // ① 实例化(反射调用构造器),此时只是空壳对象
    //    对应 OrderService:内存里有了对象,但 userService 字段还是 null
    instanceWrapper = createBeanInstance(beanName, mbd, args);
    Object bean = instanceWrapper.getWrappedInstance();

    // ② 若允许循环依赖,把"工厂"丢进三级缓存
    //    注意:此时还没有生成代理,工厂里只是包了一层逻辑
    if (earlySingletonExposure) {
        addSingletonFactory(beanName,
            () -> getEarlyBeanReference(beanName, mbd, bean));  // ③ Lambda 惰性执行
    }

    // ④ 属性填充:这里会触发 getBean("userService"),从而递归创建 B
    populateBean(beanName, mbd, instanceWrapper);

    // ⑤ 初始化(Aware → BeanPostProcessor 前置 → init → 后置)
    exposedObject = initializeBean(beanName, exposedObject, mbd);
    ...
}

注意第 ② 步放进去的只是一个 Lambda(配方),此时并没有执行它,也没有生成任何代理。这一点是回答"为什么三级"的关键伏笔。

步骤 2:B 需要 A 时,三级查找取回早期引用

DefaultSingletonBeanRegistry#getSingleton,这是整个机制的核心查找逻辑:

java 复制代码
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) {
                // 双重检查...
                ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); // 查三级
                if (singletonFactory != null) {
                    singletonObject = singletonFactory.getObject();    // 触发工厂:可能返回代理
                    this.earlySingletonObjects.put(beanName, singletonObject); // 升级到二级
                    this.singletonFactories.remove(beanName);                // 从三级移除
                }
            }
        }
    }
    return singletonObject;
}

查找的决策流程如下:

flowchart TD Start[getBean 请求] --> S1{一级缓存<br/>singletonObjects 有?} S1 -- 有 --> Done[直接返回成品] S1 -- 无 --> S2{该 Bean 正在创建中?<br/>isSingletonCurrentlyInCreation} S2 -- 否 --> Create[正常走 createBean 流程] S2 -- 是 --> S3{二级缓存<br/>earlySingletonObjects 有?} S3 -- 有 --> Ret2[返回早期引用] S3 -- 无 --> S4{允许早期引用?<br/>allowEarlyReference} S4 -- 否 --> Null[返回 null, 继续创建] S4 -- 是 --> S5{三级缓存<br/>singletonFactories 有?} S5 -- 有 --> S6[工厂.getObject<br/>可能生成AOP代理] S6 --> S7[结果放入二级缓存<br/>从三级缓存删除] S7 --> Ret2 S5 -- 无 --> Null

回到我们的例子:UserService 填充属性时来找 OrderService,一级没有(还没造完)、二级没有(还没人借过)、三级命中工厂 → 执行工厂 → 拿到 A 的早期引用 → 放入二级缓存。B 顺利拿到引用,继续走完自己的生命周期,进入一级缓存。

步骤 3:工厂里到底做了什么

java 复制代码
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    // SmartInstantiationAwareBeanPostProcessor(如 AOP 的 AbstractAutoProxyCreator)
    // 会在这里判断:如果该 Bean 需要被代理,就**提前**生成代理对象返回
    for (SmartInstantiationAwareBeanPostProcessor bp : ...) {
        exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
    }
    return exposedObject;
}

举个例子:假设 OrderService 上有 @Transactional 注解,那它正常情况下应该在初始化完成后 被 AOP 包成代理对象。但此刻 B 急着要 A 的引用,等不了那么久------于是工厂在这里现场判断"这个 Bean 要不要代理",要的话就提前生成。B 拿到的是代理后的 OrderService,这保证了事务增强不丢失。

四、关键细节:升级入一级缓存的时机与"对象一致性"

时机:该 Bean 自己的生命周期全部走完时 。由外层 doGetBeangetSingleton(beanName, singletonFactory) Lambda 的 finally 块完成升级:

java 复制代码
// AbstractBeanFactory#doGetBean 中
singletonObject = getSingleton(beanName, () -> createBean(beanName, mbd, args));

// DefaultSingletonBeanRegistry#getSingleton(String, ObjectFactory)
try {
    return singletonFactory.getObject();  // 内部走 doCreateBean 完整生命周期
} finally {
    if (newSingleton) {
        addSingleton(beanName, singletonObject);  // ①放入一级 ②从二级、三级移除
    }
}

protected void addSingleton(String beanName, Object singletonObject) {
    synchronized (this.singletonObjects) {
        this.singletonObjects.put(beanName, singletonObject);      // 升级到一级
        this.singletonFactories.remove(beanName);                  // 清理三级
        this.earlySingletonObjects.remove(beanName);               // 清理二级
    }
}

这里有个容易被忽略的细节:放入一级的对象不一定是 initializeBean 之后的原对象 。如果二级缓存里的早期引用是提前生成的 AOP 代理,doCreateBean 末尾会做一致性检查:

java 复制代码
Object earlySingletonReference = getSingleton(beanName, false);  // 只查一、二级
if (earlySingletonReference != null) {
    if (exposedObject == bean) {
        exposedObject = earlySingletonReference;  // 最终以二级缓存的代理为准
    }
}

为什么?想象一下:如果 A 最终放入一级缓存的是原对象 ,而 B 手里拿着的是提前生成的代理 ,容器里就会出现两个"版本"的 OrderService------一个有事务增强,一个没有。Spring 用这个检查保证:一旦提前暴露了代理,全世界看到的就都是这个代理

结合时序图再看一遍:B 是在第 ⑧ 步 initializeBean(B) 之后整体完成时升级入一级;A 是在回来走完自己的填充和初始化后升级入一级。二级缓存只是"借出引用期间的临时存放点",创建一结束就被清掉。

五、灵魂拷问:为什么是三级而不是二级?

这是面试官最爱追问的点,也是理解整个设计的钥匙。

先说结论:二级缓存其实能解决循环依赖本身,缺的那一级是为了保住 AOP 的设计原则。

推演一下如果只用二级缓存会怎样:

  • Spring 的正常设计:代理应在 Bean 初始化完成之后BeanPostProcessor#postProcessAfterInitialization 生成(此时 @PostConstruct 等逻辑已在原对象上执行完毕)。
  • 若只有二级缓存:Spring 就必须在 Bean 刚实例化时立刻决定"给原对象还是代理对象"并放进二级缓存。
  • 后果一:所有 Bean 一实例化就要提前做 AOP 判断,哪怕 99% 的 Bean 根本没有循环依赖,也被迫破坏了"代理在初始化后生成"的设计原则。
  • 后果二:提前代理可能让 @PostConstruct 等初始化逻辑作用在错误的对象上(代理未生成/生成方式不对)。

而三级缓存存的 Lambda 是惰性的:只有真的发生循环依赖、别人来取我时才执行,现场决定要不要提前生成代理。没有循环依赖就永远不触发,原有设计完全不受影响。

flowchart LR subgraph L1[只有二级缓存的世界] A1[Bean 刚实例化] --> B1[立刻做 AOP 判断<br/>所有 Bean 都被迫提前] B1 --> C1[破坏代理在初始化后生成<br/>的设计原则] end subgraph L2[三级缓存的世界] A2[Bean 刚实例化] --> B2[只存一个未执行的 Lambda<br/>零成本] B2 --> C2{发生循环依赖<br/>有人来取?} C2 -- 否 --> D2[初始化后再正常生成代理] C2 -- 是 --> E2[现场执行工厂<br/>按需提前生成代理] end

简记:二级缓存保证"给得出引用",三级缓存保证"代理不用提前造"。 三级缓存 = 惰性化的二级缓存,把"要不要提前代理"这个决策延迟到真正需要的那一刻。

六、三级缓存也不是万能的

理解了原理,就能推出它的边界------引用必须能在"实例化之后"被暴露出来,机制才生效

场景 原因
构造器注入循环依赖 实例化阶段就互相要对方,而三级缓存在实例化之后才暴露,来不及
prototype 作用域 无缓存可提前暴露,每次都要新建,直接抛 BeanCurrentlyInCreationException
@Async 增强的 Bean 异步代理由 AsyncAnnotationBeanPostProcessor 在初始化后才生成,早期暴露对象与最终对象不一致,抛 BeanCurrentlyInCreationException

这也解释了引言中的现象:字段/setter 注入 发生在 populateBean 阶段(实例化之后),所以能被救;构造器注入发生在实例化阶段本身,三级缓存还没来得及放进去,救不了。

构造器注入的解法举例:

java 复制代码
// 方案 1:@Lazy 注入代理,首次真正调用时才去 getBean
public OrderService(@Lazy UserService userService) { ... }

// 方案 2:ObjectProvider 延迟获取
public OrderService(ObjectProvider<UserService> userServiceProvider) {
    this.userService = userServiceProvider.getObject();
}

// 方案 3(最推荐):重新设计依赖结构,把循环拆开
// 比如抽出 OrderQueryService,让 UserService 依赖它而不是 OrderService

七、总结

  1. 问题:A 依赖 B、B 依赖 A,创建过程形成死循环。
  2. 思路:把创建拆成"实例化 → 填充 → 初始化"三步,实例化后提前暴露引用,循环就断了。A 还没造完,就先把"自己的引用"借给了 B。
  3. 实现 :一级存成品、二级存半成品、三级存"制造半成品的配方"(未执行的 ObjectFactory)。别人来取时才执行配方,按需决定是否提前生成 AOP 代理,结果升级入二级缓存;Bean 走完生命周期后由 addSingleton 升级入一级。
  4. 为什么三级:二级缓存能解决死循环,但会让所有 Bean 在实例化时就被迫做 AOP 判断、提前生成代理,破坏"代理在初始化后生成"的设计原则。第三级是惰性的,只在真正发生循环依赖时才触发。
  5. 边界 :构造器注入、prototype 作用域、@Async 场景无解,需 @LazyObjectProvider 或重构依赖。
相关推荐
萧瑟余晖1 小时前
Hibernate 实体映射与关联关系详解
后端·hibernate
stars3691 小时前
实验四 JSP内置对象的应用
后端
对象存储与RustFS1 小时前
用 Restic 把本地备份存进 RustFS:S3 兼容仓库实战
后端·rust·开源
Zane19941 小时前
写Stream时踩过的坑:中间操作不会真正执行,直到你调用这一个方法
java·后端
明月_清风2 小时前
Foundry Invariant Testing 实战:ERC-4626 + Handler + Ghost Variable
后端·web3·solidity
明月_清风2 小时前
Foundry Invariant Testing:让测试自动寻找复杂状态下的 Solidity Bug
后端·web3·solidity
java porter2 小时前
我开源了一个 Spring AI Agent 项目
后端
136096757232 小时前
AgentScope 2.0 学习笔记:RAG 进阶——Top-K 调优与幻觉测试(让 Agent 敢说「不知道」)
后端
yume_sibai2 小时前
07-Rust 异步编程完全指南(async/await + Tokio + Future + 并发原语 + 异步流)
开发语言·后端·rust