🔥不止于三级缓存:Spring循环依赖的全面解决方案

🔄 什么是循环依赖?

循环依赖指的是当两个或多个Spring的bean相互依赖,而这些bean都需要被实例化来满足依赖,导致Spring容器无法顺利完成Bean的初始化过程。具体来说,A依赖B,B又依赖A,导致一个死循环。Spring必须处理这种依赖关系,避免造成死锁或失败。

🛠️ Spring的解决方案

Spring容器如何解决这个问题呢?Spring采用了两种策略来解决循环依赖问题:

🗂️ Setter注入 + 三级缓存

1️⃣ 什么是三级缓存?

在 Spring 中,Bean 的创建分为三个阶段:实例化、属性注入、初始化

  • 一级缓存(singletonObjects) :存储完全初始化完成的 Bean。
  • 二级缓存(earlySingletonObjects) :存储已实例化但未完成属性注入的"半成品"对象。
  • 三级缓存(singletonFactories) :存储创建 Bean 的工厂,当需要时生成"半成品"。

三级缓存的目的是通过延迟暴露"半成品"对象,打破循环依赖。

2️⃣ 解决循环依赖的完整流程

假设有 A 依赖 B,B 又依赖 A 的场景

🔍 创建过程分为以下几个关键步骤:
  1. 实例化 Bean A

    • Spring 首先实例化 A(通过构造函数创建对象)。
    • 然后,将 A 的工厂(ObjectFactory)放入三级缓存(singletonFactories)。此时 A 是个"半成品"。
  2. 处理 A 的依赖:注入 B

    • A 需要注入 B,Spring 开始创建 B。
    • B 还没初始化,于是 B 的工厂也被放入三级缓存。
  3. 处理 B 的依赖:注入 A

    • B 需要注入 A。此时 Spring 先检查一级缓存(完整对象),没找到。
    • 检查二级缓存(半成品),还是没找到。
    • 最后到三级缓存,通过 A 的工厂生成一个"半成品 A"。
    • 将生成的"半成品 A"存入二级缓存(earlySingletonObjects),并注入到 B 中。
  4. B 初始化完成

    • 完成 B 的属性注入和初始化,将 B 移入一级缓存。
  5. A 初始化完成

    • A 获取到完整的 B 后,完成自身的属性注入和初始化,移入一级缓存。
整个流程逻辑图如下:
css 复制代码
1. 实例化 A,放入三级缓存(工厂)。
2. A -> 依赖 B,开始创建 B。
3. 实例化 B,放入三级缓存(工厂)。
4. B -> 依赖 A,从三级缓存生成"半成品 A"。
5. 完成 B 的初始化,放入一级缓存。
6. A 注入完整 B,完成初始化,放入一级缓存。
3️⃣ 核心代码解析

以下是 Spring 内部处理缓存的简化逻辑:

java 复制代码
// 获取单例 Bean 的方法
public Object getSingleton(String beanName) {
    // 1. 从一级缓存获取完整的 Bean
    Object singleton = singletonObjects.get(beanName);
    if (singleton == null) {
        // 2. 从二级缓存获取"半成品" Bean
        singleton = earlySingletonObjects.get(beanName);
        if (singleton == null) {
            // 3. 从三级缓存获取工厂并生成 Bean
            ObjectFactory<?> factory = singletonFactories.get(beanName);
            if (factory != null) {
                singleton = factory.getObject();
                earlySingletonObjects.put(beanName, singleton);
                singletonFactories.remove(beanName);
            }
        }
    }
    return singleton;
}

🌟注意

1️⃣ 为什么只支持单例循环依赖?

因为原型(Prototype)模式每次都创建新对象,不能复用"半成品"。而单例对象可以复用同一个"半成品",这才是循环依赖能解决的关键!

2️⃣为什么需要三级缓存,二级不够吗?关键在于AOP代理!
java 复制代码
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;
}Copy to clipboardErrorCopied
场景1:没有AOP代理(只有普通循环依赖)

在这种情况下,理论上二级缓存是够用的💪。

  • 创建A,实例化后直接把原始对象A放入二级缓存➡️。
  • 创建B,需要A,从二级缓存拿到A的原始对象🙋‍♂️。
  • B创建完成,A用B创建自己,最终大家都创建成功🎉。

但Spring的设计是统一的,不能因为某些场景简单就采用不同的架构。

🔥场景2:有AOP代理(这才是关键)

假设A需要被AOP代理(比如有@Transactional注解)。

问题在于:代理对象生成的时机。💥

Spring AOP代理对象的创建最佳时机是在Bean的初始化之后(在BeanPostProcessor的后置处理中)。但如果存在循环依赖,B在创建过程中就需要A,而此时A还没有走到初始化那一步😭。

如果只有二级缓存:

  1. 创建A,实例化后,我们只能把A的原始对象放入二级缓存。
  2. 创建B,需要A,从二级缓存拿到A的原始对象并注入给B。
  3. B创建完成后,A继续后续流程。当A走到初始化后,Spring发现A需要被代理🚀,于是为A的原始对象生成一个代理对象
  4. 最终,A的代理对象被放入一级缓存。

🚨这就导致了严重的不一致问题🚨:

  • B里面持有的A,是A的原始对象。
  • 而Spring容器一级缓存里和业务代码实际拿到的是A的代理对象。

这违背了单例的原则!同一个Bean(A)在容器中有了两个不同的实例😱(一个原始对象在B里,一个代理对象在容器里),这会导致事务等AOP功能在B调用A时完全失效,是一个致命的Bug💀。

🦸‍♂️三级缓存如何优雅解决这个问题?

三级缓存的精髓在于:我不提前给你对象,我给你一个"造物工厂"🏭

流程如下:

  1. 创建A,实例化后,将一个ObjectFactory (lambda表达式,能创建A的早期引用)放入三级缓存
java 复制代码
 addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
  1. 创建B,发现依赖A → 调用getSingleton(A)

👉 Spring 查一级 ❌ → 查二级 ❌ → 查三级 ✅

👉 🏭 找到A的生产工厂!

  1. Spring发现A在三级缓存中有ObjectFactory → 调用它,执行getEarlyBeanReference()

    • 此时判断A是否需要代理:

      • 需要 → 返回代理对象
      • 不需要 → 返回原始对象
    • 将结果放入二级缓存,并从三级缓存移除。

  2. B拿到A的引用。

  3. A继续创建,完成后放入一级缓存中,并清理二级缓存中的早期引用 🧹。

🗂️构造器注入 + @Lazy注解

@Lazy 注解可以让 Spring 稍微等一等,延迟对象的创建。它的作用就是让 Spring 不急着立刻创建对象,而是等到真正需要的时候才去创建。

当你在一个对象上使用 @Lazy ,Spring 会选择不立刻创建它,而是等到这部分真正被调用的时候才去创建。这样,Spring 就能够打破这两个对象相互等待的死循环,最终成功地创建这两个对象。

注意事项:

虽然 @Lazy 注解能帮你解决循环依赖问题,但实际上,还是建议尽量避免循环依赖 。因为如果经常出现这种情况,代码的逻辑会变得 复杂,也不容易维护。所以,最好从设计上尽量避免这种问题。

@Lazy 的魔力在哪里?

@Lazy 是 Spring 提供的一种延迟加载机制,意思是"等到用我的时候再初始化,不着急"。这就像告诉 Spring:"别急着建这个 Bean,等真需要再搞定。"

实战演示:用 @Lazy 解决构造器循环依赖 🛠️

假设我们有两个类 AB,它们通过构造器互相依赖:

java 复制代码
@Component
public class A {
    private final B b;

    @Autowired
    public ClassA(@Lazy B b) { // 使用 @Lazy,让 classB 延迟初始化
        this.b = b;
    }
}

@Component
public class B {
    private final A a;

    @Autowired
    public B(A a) {
        this.a = a;
    }
}

🔍 关键点:

  • A 创建时,Spring 不会急着实例化 B
  • A 真正需要用到 B 时,Spring 才去初始化 B
  • 这样就避开了循环依赖的"死锁"问题。

使用 @Lazy 的注意事项 ⚠️

虽然 @Lazy 方便,但不能滥用!过度依赖延迟加载,可能会带来以下问题:

  1. 意外的初始化时机: Bean 的创建可能在某些逻辑中突然发生,难以预测。
  2. 性能开销: 如果延迟加载的 Bean 被频繁调用,性能可能受到影响。
  3. 设计隐患: 循环依赖本身是代码设计上的缺陷,建议优先优化代码结构。

@Lazy 的常见应用场景

  1. 类级别延迟: Bean 初始化比较耗时时,使用 @Lazy 提升系统启动速度。

    java 复制代码
    @Component
    @Lazy
    public class HeavyBean {
        public HeavyBean() {
            System.out.println("HeavyBean 初始化了!");
        }
    }
  2. 注入延迟: 某些依赖加载会导致性能瓶颈,可以使用 @Lazy 注解。

    java 复制代码
    @Component
    public class SomeService {
        private final HeavyBean heavyBean;
    
        @Autowired
        public SomeService(@Lazy HeavyBean heavyBean) {
            this.heavyBean = heavyBean;
        }
    }

🤹 小总结

  1. Spring 的三级缓存机制,通过提前暴露"半成品对象",巧妙地解决了单例循环依赖的问题,同时支持 AOP 等复杂场景。
  2. @Lazy 是解决构造器循环依赖的好工具,但别过度使用。
  3. 循环依赖本质是设计问题,优先通过优化代码结构来解决。
相关推荐
UCoding4 小时前
我们来学AI编程 -- vscode开发java
java·vscode·ai编程
一线大码4 小时前
开发 Java 项目时的命名规范
java·spring boot·后端
neoooo4 小时前
Apollo兜底口诀
java·后端·架构
程序员小假4 小时前
什么是线程池?它的工作原理?
java·后端
盖世英雄酱581364 小时前
java 深度调试【第一章:堆栈分析】
java·后端
lastHertz5 小时前
Golang 项目中使用 Swagger
开发语言·后端·golang
渣哥5 小时前
面试高频:Spring 事务传播行为的核心价值是什么?
javascript·后端·面试
调试人生的显微镜5 小时前
iOS 代上架实战指南,从账号管理到使用 开心上架 上传IPA的完整流程
后端
本就一无所有 何惧重新开始5 小时前
Redis技术应用
java·数据库·spring boot·redis·后端·缓存