Spring循环依赖:三级缓存到底解决了什么,没解决什么?

Spring循环依赖:三级缓存到底解决了什么,没解决什么?


一、开篇一问:为什么这道面试题能考倒90%的人?

不是因为难,而是因为大多数人只背了"三级缓存解决循环依赖"这句话,却从没想过------它到底解决了哪种循环依赖?为什么有的循环依赖它偏偏不管?

今天,把这件事一次性说透。


二、先搞清楚:什么才算"循环依赖"?

Spring里的循环依赖,远不止"A依赖B,B依赖A"这一种。按注入方式和作用域,至少有三种:

类型 表现 Spring能否解决
构造器注入循环依赖 A的构造函数要B,B的构造函数要A ❌ 直接抛异常
字段/Setter注入循环依赖(单例) @Autowired private B b; 同理B里有A ✅ 三级缓存完美解决
原型Bean循环依赖 @Scope("prototype") + 字段注入 ❌ 无法解决

一句话结论:三级缓存只解决一种情况------单例Bean + 字段/Setter注入的循环依赖。其余的,一概不管。

典型报错长这样:

vbnet 复制代码
org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'albumInfoServiceImpl':
Requested bean is currently in creation: Is there an unresolvable circular reference?

看到这个异常,先别慌------这恰恰说明Spring在保护你,它检测到了自己解决不了的死循环。


三、三级缓存的"三级"到底是什么?

DefaultSingletonBeanRegistry类中,Spring定义了三个Map:

typescript 复制代码
java
// 一级缓存:成品仓库------完全初始化好的单例Bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// 二级缓存:半成品暂存区------已实例化,但未完成属性注入和初始化
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);

// 三级缓存:原型制造工单------存的是工厂对象,用于按需生成早期引用
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

用一个工厂生产的比喻:

  • 一级缓存 = 质检合格、包装完毕的成品,随时可以发货
  • 二级缓存 = 骨架已搭好、零件还没装的半成品,先放这里备着
  • 三级缓存 = 一张电子工单,上面写着"按这个图纸可以造出半成品",真需要时才动手造

关键洞察:三级缓存存的不是Bean本身,而是"造Bean的工厂"。 这就是它比二级缓存多出来的那一级的全部意义。


四、三级缓存到底解决了什么?------两个核心问题

✅ 解决了问题一:循环依赖的"死锁"

经典场景:A依赖B,B依赖A,都是单例+字段注入。

没有缓存时的死锁过程:

css 复制代码
创建A → 实例化A(空壳)→ 需要注入B → 创建B → 实例化B(空壳)
→ 需要注入A → A还没创建完 → 死循环 → 栈溢出

三级缓存的破局之道:

css 复制代码
1. 创建A → 实例化A → 把A的工厂放入三级缓存
2. A需要B → 创建B → 实例化B → 把B的工厂放入三级缓存
3. B需要A → 查一级缓存:没有
                → 查二级缓存:没有
                → 查三级缓存:找到A的工厂!
4. 调用工厂.getObject() → 拿到A的早期引用(半成品)
5. 把A从三级缓存升级到二级缓存
6. B注入A(半成品)→ B完成初始化 → 放入一级缓存
7. A注入B(成品)→ A完成初始化 → 放入一级缓存

死锁被打破的关键一步:第3步。 B不再傻等A创建完成,而是直接从三级缓存拿到了A的"早期引用"------虽然是半成品,但够用了。

这就是Spring官方文档说的 "提前暴露"(Early Exposure) :在Bean完全初始化之前,就把引用透露出去,让依赖链能够继续推进。

✅ 解决了问题二:AOP代理的一致性

这才是三级缓存存在的真正理由,也是最容易被忽略的点。

假设只有二级缓存,没有三级:

css 复制代码
A实例化后直接放入二级缓存(原始对象)
B需要A → 从二级缓存拿到A的原始对象 → 注入
A完成初始化 → 生成AOP代理对象 → 放入一级缓存

灾难来了:B手里持有的是A的原始对象,而一级缓存里放的是A的代理对象。 同一个Bean,两个版本!B调用A的方法时,事务切面、日志切面全部失效。

三级缓存怎么解决的?

scss 复制代码
三级缓存存的是ObjectFactory,它的getObject()方法会调用getEarlyBeanReference()
→ 这个方法会检查是否需要AOP增强
→ 如果需要,返回的是代理对象;如果不需要,返回原始对象
→ 确保B拿到的,和最终放入一级缓存的,是同一个对象!

源码佐证------AbstractAutowireCapableBeanFactory.getEarlyBeanReference()

typescript 复制代码
java
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    // 如果有AOP感知的BeanPostProcessor,允许它们修改早期暴露的Bean
    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
        for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
            exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
        }
    }
    return exposedObject;  // 这里可能已经是代理对象了
}

所以,三级缓存的本质不是"多存一层",而是"延迟决策"------把"要不要生成代理"这个问题,推迟到真正有人要用这个Bean的那一刻才回答。


五、三级缓存没解决什么?------三个硬伤

❌ 硬伤一:构造器注入的循环依赖

kotlin 复制代码
java
@Service
public class A {
    private final B b;
    public A(B b) { this.b = b; }  // 创建A的瞬间就必须有B
}
@Service
public class B {
    private final A a;
    public B(A a) { this.a = a; }  // 创建B的瞬间就必须有A
}

Spring直接抛异常,不尝试任何缓存机制。

原因很简单:构造器注入要求"实例化"和"依赖注入"合二为一,没有"先造空壳再填属性"的机会。Bean还没new出来就要依赖,这是逻辑上的悖论,三级缓存也无能为力。

❌ 硬伤二:原型Bean(prototype)的循环依赖

less 复制代码
java
@Service
@Scope("prototype")
public class A {
    @Autowired private B b;
}
@Service
@Scope("prototype")
public class B {
    @Autowired private A a;
}

直接抛BeanCurrentlyInCreationException

因为prototype每次请求都创建新实例,不存在"缓存"的前提。你存进去的是上一次的实例,下一次请求又要新的,缓存毫无意义。Spring的设计哲学很明确:三级缓存是为singleton服务的,prototype不参与这套机制。

❌ 硬伤三:多例循环依赖中的"同例"问题

即便是单例Bean,如果循环依赖链中混入了@Lazy@DependsOn等复杂注解,或者BeanPostProcessor的执行顺序出了问题,三级缓存照样可能翻车。

比如这个坑:

kotlin 复制代码
java
@Service
public class A {
    @Autowired private B b;
}
@Service
public class B {
    @Autowired private A a;  // 看起来是循环依赖
    
    @PostConstruct
    public void init() {
        // 如果这里抛异常,A的初始化会回滚
        // 但B已经从三级缓存拿到了A的早期引用
        // 导致A的状态不一致
    }
}

这不是三级缓存的bug,而是Bean生命周期的复杂性超出了缓存机制的覆盖范围


六、一张图总结:三级缓存的能力边界

css 复制代码
                    ┌─────────────────────────────┐
                    │     三级缓存能解决的          │
                    │  ✅ 单例Bean                  │
                    │  ✅ 字段注入 / Setter注入      │
                    │  ✅ 包含AOP代理的场景          │
                    │  ✅ 任意长度的依赖链           │
                    │    (A→B→C→A 都行)             │
                    └─────────────────────────────┘
                              │
                    ┌─────────┴─────────┐
                    │   三级缓存管不了的   │
                    │ ❌ 构造器注入        │
                    │ ❌ prototype作用域   │
                    │ ❌ 依赖链中有@Async  │
                    │ ❌ BeanPostProcessor │
                    │   执行顺序导致的竞态  │
                    └─────────────────────┘

七、实战建议:别等出了问题再查

场景 推荐方案
确有循环依赖(单例+字段注入) 不用管,Spring自动搞定
构造器注入的循环依赖 改为Setter注入,或抽取中间类打破环路
prototype的循环依赖 @Lazy延迟加载,或ObjectProvider<T>手动获取
复杂依赖链(3个以上) 定期用mvn dependency:tree分析,重构包结构
想验证三级缓存在工作 断点打在DefaultSingletonBeanRegistry.getSingleton(),看三级缓存的命中

八、写在最后

三级缓存不是银弹,它是Spring在"单例+字段注入"这个特定约束下,用空间换时间、用延迟换一致的精妙设计。

它解决的,是 "半成品能不能先借出去用" 的问题。

它没解决的,是 "借出去的半成品到底对不对" 的问题。

理解了这一点,你就理解了Spring IoC容器最深处的那行代码------

不是所有的循环都能被打破,但被打破的那个,一定是提前暴露了真相。

相关推荐
鱼人1 小时前
MyBatis的$和#区别:你以为防注入就够了?
后端
小强19881 小时前
Java程序员必知的4种引用类型:强、软、弱、虚——彻底告别内存泄漏
后端
鱼人1 小时前
Spring Boot启动过程中偷偷干了什么?手撕run方法源码
后端
长大19881 小时前
MySQL + Redis + Caffeine:Java后端通用三级缓存架构实战
后端
乘风破浪酱524361 小时前
别再乱用Redisson分布式锁了!这可能是你见过最标准的教程(附完整代码)
后端
兔子零10241 小时前
当 Codex 成为主力,软件工程的重心已经变了
前端·后端·架构
用户6757049885022 小时前
别再死记硬背了!一文扒光 I/O 多路复用的底裤(Epoll/Select/Poll)
后端
牛奶2 小时前
网关是怎么当"门卫"的?
前端·后端·负载均衡