5

本篇记录学习 spring 三级缓存的起因,过程和总结。

一、问题起因

场景:

  • 系统配置表中,配置值统一存为 String
  • 业务多次需要 int 类型

于是我写了这样一个方法getInitConfig

/**
 * 从系统配置中获取整数类型配置,异常时返回默认值
 */
@Override
public int getInitConfig(String key, int defaultValue) {
    try {
        String value = this.getOriginValueByKey(key);
        return Integer.parseInt(value);
    } catch (Exception e) {
        return defaultValue;
    }
}

@Override
@Cacheable(value = "config", key = "#key")
public String getOriginValueByKey(String key) {
    return this.systemConfigRepository
        .findByKey(key)
        .orElseThrow(EntityNotFoundException::new)
        .getOriginValue();
}

逻辑很简单:

  • getOriginValueByKey 负责 读配置 + 缓存
  • getInitConfig 负责 类型转换 + 默认值兜底

结果,IDEA 给了我一个警告:

@Cacheable 自调用(实际上是目标对象内的方法调用目标对象的另一个方法)。在运行时会忽略缓存注解

二、@Cacheable 为什么会在类内部自调用时失效?

查阅资料和源码后才发现,这不是缓存的问题,而是 AOP(Aspect-Oriented Programming) 的基本原理问题

1. @Cacheable 的工作方式

@Cacheable 并不是很复杂的注解,它的本质是:

  • Spring 为目标 Bean 创建 代理对象
  • 外部调用 → 先经过代理
  • 代理逻辑:

    1. 查缓存
    2. 命中则直接返回
    3. 未命中才执行真实方法,并写入缓存

重点来了:

Spring AOP 的增强逻辑只会在通过代理对象调用目标方法时生效。

2. 为什么自调用必然失效?

在同一个类中:

this.getOriginValueByKey(key);

这一步调用的是:

  • 当前对象本身
  • 而不是 Spring 创建的代理对象

调用路径直接绕开了代理,自然也就:

  • 不会查缓存
  • 不会触发 @Cacheable
  • 注解形同虚设

三、一个看似正确的改法,引爆了循环依赖

既然问题是 this 调用绕过代理,那我的第一反应是:

那我不用 this,改成注入接口不就好了?

于是代码变成了这样:

private final SystemConfigService systemConfigService;

public SystemConfigServiceImpl(SystemConfigService systemConfigService) {
    this.systemConfigService = systemConfigService;
}

然后在同一个实现类中,通过 systemConfigService.getOriginValueByKey() 调用。

结果:
Spring 启动直接报循环依赖。


四、终于想明白的两个核心认知

这次事故让我补上了两个长期模糊、但极其关键的 Spring 基础概念。

1. Spring 依赖注入依赖的是 接口类型,但实例永远是实现类

事实上:

  • Spring 容器中 没有接口 Bean
  • 真正被管理的,是 实现类实例

也就是说:

  • SystemConfigService 只是一个类型标签
  • 容器里真正存在的是 SystemConfigServiceImpl
需要注意的是,Spring 注入的并不一定是实现类的原始对象,而是该实现类对应的 Bean 实例,这个实例可能是原始对象,也可能是代理对象。在启用了 AOP 的情况下,这个实例往往是一个 代理对象

一个直观类比:

可以用奶茶店来理解这个过程。

image.png

Spring 概念奶茶店类比
接口菜单上的「珍珠奶茶」
实现类店员实际做出来的奶茶
依赖注入你点单
AOP代理奶茶外面贴了一层贴纸

过程如下:

  1. 菜单上写着 珍珠奶茶(接口类型)
  2. 你点一杯 珍珠奶茶(依赖接口)
  3. 店员给你的不是菜单,而是 实际做好的奶茶(实现类实例)

如果奶茶店提供 贴纸服务(类似 AOP),那你拿到的可能是:

贴了贴纸的奶茶

本质仍然是那杯奶茶,只不过外面多了一层包装。


2. 为什么接口上不需要 @Service,而实现类必须写?

这个我以前只是模仿和接受了,但没理解。

现在可以一句话说清楚:

接口不能被实例化,所以不可能成为 Bean

因此:

  • @Service@Component 永远标在 实现类
  • Spring 扫描到的是实现类
  • 接口只是注入时用来匹配类型的工具

五、所以循环依赖到底是怎么发生的?

当我在 SystemConfigServiceImpl 中:

private final SystemConfigService systemConfigService;

public SystemConfigServiceImpl(SystemConfigService systemConfigService) {
    this.systemConfigService = systemConfigService;
}

Spring 的视角是:

  • 我要一个 SystemConfigService
  • 在当前上下文中,SystemConfigService 只有一个候选 Bean :即正在创建的 SystemConfigServiceImpl
  • 因此类型匹配会命中到当前 Bean 本身
  • 于是,相当于:
在 SystemConfigServiceImpl 里注入了它自己

六、怎么解决循环依赖?

1. 第一选择:重新设计,而不是缝缝补补

这是最重要的一点。

循环依赖,九成是设计问题,不是技术问题。

在这个案例里:

  • getInitConfig 本质只是:

    • 读取字符串
    • 调用 Integer.parseInt
    • 做一次兜底
  • 却为此引入了:

    • 自调用 AOP 失效
    • 接口自注入
    • 循环依赖

最终我选择的方案是:

  • 删除这层封装
  • 只在需要的地方完成类型转换

2. 如果非要解,那才轮到这些手段

下面这些方法只是为了学习如何使用其他方法解决循环依赖。

方案一:@Lazy 懒加载代理

public SystemConfigServiceImpl(@Lazy SystemConfigService systemConfigService) {
    this.systemConfigService = systemConfigService;
}
@Lazy 的本质并不是简单地延迟注入,而是向依赖方注入一个延迟初始化的代理对象。该代理在第一次方法调用时,才会向容器获取真实 Bean,从而绕过构造阶段的循环依赖问题。

优点是解决快,缺点也明显:

  • 只是拖延
  • 没有消除结构问题
  • 容易让依赖关系变得隐晦不清

方案二:基于 Setter 的依赖注入

Spring 官方文档明确指出:

One possible solution is to edit the source code of some classes to be configured by setters rather than constructors. Alternatively, avoid constructor injection and use setter injection only.

原因很简单:

  • 构造函数注入要求 创建时依赖必须齐全
  • Setter 注入允许:

    • new
    • 再慢慢填依赖

配合 Lombok,代码也很简洁:

@Setter
private SystemConfigService systemConfigService;

七、Spring 解决循环依赖的真正武器:三级缓存

首先:什么是三级缓存?

源码:

/** Cache of singleton objects: bean name to bean instance. */
// 一级缓存:完整 Bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

/** Cache of early singleton objects: bean name to bean instance. */
// 二级缓存:早期 Bean(已暴露引用,但未初始化(半成品))
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

/** Creation-time registry of singleton factories: bean name to ObjectFactory. */
// 三级缓存:Bean 工厂(用于生成早期引用或代理)
private final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(16);

其实,真正的关键不是名字,而是缓存时机


八、Bean 创建的流程

Spring 创建一个singleton Bean,本质上分三步:

  1. 实例化new 一个空 Bean 对象
  2. 属性注入:给 Bean 的属性赋值
  3. 初始化:执行 Aware 接口,初始化前后置处理器,执行初始化方法,初始化后后置处理器

循环依赖能被解决,靠的是一个非常关键的事实:

对象在没初始化完的时候,就已经能真实地存在并且被引用。

九、举个例子:字段注入的 A ↔ B 循环依赖

A 依赖 B
B 依赖 A

Step 1:Spring 创建 A

  • new A()
  • A 还没注入任何依赖
  • 还没初始化

此时,Spring 做了一件非常关键的事:

把一个能返回 A 的工厂放进三级缓存

注意,是工厂,不是对象。


Step 2:A 注入依赖时,发现需要 B

Spring 暂停创建 A,转而创建 B。


Step 3:创建 B,流程相同

  • new B()
  • 同样把一个 ObjectFactory<B> 放进三级缓存

Step 4:B 注入依赖时,发现需要 A

此时,Spring 依次查缓存:

  1. 一级缓存:没有
  2. 二级缓存:没有
  3. 三级缓存:有 A 的工厂

于是:

  • 执行工厂生成早期 Bean
  • 拿到 A 的早期引用
  • 移到二级缓存
  • 移除三级缓存的工厂

Step 5:B 完成创建

  • B 初始化完成
  • 放入一级缓存

Step 6:回到 A

  • A 此时可以从一级缓存拿到 B
  • A 完成初始化
  • 放入一级缓存

循环依赖,至此被解开。


十、总结

问题一:Spring 是如何解决单例 Bean 的循环依赖?

Spring 通过 三级缓存机制 解决单例 Bean 的循环依赖问题。

在 Bean 创建过程中,Spring 会先完成 实例化(即 new 对象),但此时 Bean 尚未进行属性注入和初始化。如果 Spring 判断该 Bean 可能参与循环依赖,并且当前配置允许循环引用(默认允许),就会将一个用于返回该 Bean 早期引用的 ObjectFactory 提前暴露到 三级缓存(singletonFactories 中。

当其他 Bean 在创建过程中依赖该 Bean 时,Spring 会通过 getSingleton 按顺序从 一级缓存(完整 Bean)二级缓存(早期 Bean)三级缓存(Bean 工厂) 中查找依赖。如果命中三级缓存,Spring 会调用 ObjectFactory#getObject() 获取 Bean 的 早期引用,并将该引用提升到二级缓存中,以保证后续依赖方获取的是同一个对象实例。

待 Bean 完成属性注入、初始化以及可能的 AOP 增强后,最终对象会被放入一级缓存中,从而在不破坏单例语义的前提下成功解决循环依赖问题。


问题二:为什么 Spring 需要三级缓存?二级缓存是否足够?

二级缓存本身 可以解决不涉及 AOP 的循环依赖问题,但无法正确处理 涉及 AOP 代理的循环依赖场景,这正是 Spring 引入三级缓存的根本原因。

在 Bean 创建的早期阶段,Spring 尚无法确定该 Bean 是否需要被 AOP 代理。如果在此阶段就直接将 Bean 实例放入二级缓存,一旦后续发现该 Bean 需要创建代理对象,就可能导致:

  • 部分依赖方持有的是 原始对象
  • 另一部分依赖方持有的是 代理对象

这将造成同一个单例 Bean 在容器中出现多种形态,破坏 Spring 对单例一致性的核心保证。

因此,Spring 选择在 三级缓存中存放 ObjectFactory 而非具体 Bean 实例,将“返回原始对象还是代理对象”的决策 延迟到真正发生依赖注入的时刻。当依赖方首次需要该 Bean 时,Spring 才通过工厂方法生成正确形态的早期引用,并将其缓存到二级缓存中,避免重复创建或多次代理。

综上,三级缓存的核心价值在于配合 AOP,保证循环依赖场景下单例 Bean 形态的一致性,这是仅靠二级缓存无法完成的。


问题三:为什么 prototype Bean 不支持循环依赖?

Spring 无法也不会解决 prototype Bean 的循环依赖问题,其根本原因在于:
prototype Bean 不具备单例语义,既不进入单例缓存体系,也不存在可被提前暴露并复用的同一实例,从机制上无法应用三级缓存模型。

在 Spring 中,循环依赖之所以能够被解决,前提是同一个 Bean 实例在创建过程中可以被提前暴露引用,并在整个容器生命周期内保持实例唯一性。这一能力完全依赖于单例 Bean 所使用的三级缓存体系(singletonObjectsearlySingletonObjectssingletonFactories)。

而 prototype Bean 的创建策略是:每次依赖解析都会创建一个全新的实例,既不会被缓存,也不具备提前暴露后复用的语义。如果 Spring尝试对 prototype Bean 进行循环依赖修复,将不可避免地导致多个不一致实例在依赖链中相互注入,从而破坏对象一致性与依赖确定性。

因此,Spring 在检测到 prototype Bean 发生循环依赖时,选择在创建阶段直接抛出异常,而不是像单例 Bean 那样尝试通过提前暴露引用来修复。


十一、部分关键源码

protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable Object @Nullable [] args)
            throws BeanCreationException {
    // 省略实例化 Bean 和允许后置处理器修改合成 Bean 定义操作

    // Eagerly cache singletons to be able to resolve circular references
    // 提前缓存单例 Bean,以便解决循环依赖
    // even when triggered by lifecycle interfaces like BeanFactoryAware.
    // 即使循环依赖由 BeanFactoryAware 等生命周期接口触发也能处理。
    // 这三个条件同时满足,才会启用三级缓存:
    // 1. 单例:原型 Bean 不支持循环依赖
    // 2. 配置中允许循环依赖(默认允许):如果手动关闭(setAllowCircularReferences (false)),则不处理
    // 3. Bean 正在创建:避免重复暴露
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
            isSingletonCurrentlyInCreation(beanName));
    if (earlySingletonExposure) {
        // 如果日志级别是 Trace(最细粒度)
        if (logger.isTraceEnabled()) {
            logger.trace("Eagerly caching bean '" + beanName +
                    "' to allow for resolving potential circular references");
        }
        // 把 Bean 名称 + 工厂 Lambda 存入三级缓存,不是直接存 Bean 对象,而是存造 Bean 的逻辑
        addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
    }

    // 省略初始化 Bean 实例等操作
}

/**
* Obtain a reference for early access to the specified bean,
* typically for the purpose of resolving a circular reference.
* 获取指定 Bean 的早期访问引用,主要目的是解决循环依赖。
* @param beanName the name of the bean (for error handling purposes)
* @param mbd the merged bean definition for the bean
* @param bean the raw bean instance
* @return the object to expose as bean reference
*/
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    // 初始化暴露的对象为原始 Bean
    Object exposedObject = bean;
    // 两个加工条件:
    // 1. 只处理用户自己定义的 Bean,Spring 内部的合成 Bean 不需要做早期引用处理
    // 2. 如果有需要加工 Bean 的 处理器,才需要继续处理;如果没有,直接返回原始 Bean
    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
        // 遍历每一个处理器,依次加工半成品 Bean
        for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
            // 如果 Bean 需要生成代理(比如事务、AOP),这里会提前生成代理对象作为早期引用,给依赖它的 Bean 使用(比如 B 依赖 A,B 拿到的是 A 的代理对象)
            exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
        }
    }
    return exposedObject;
}

十二、最后

首先,感谢 Spring 官方团队与官方技术文档,为本文核心原理的拆解提供了最权威的规范与源码依据,让所有底层逻辑的梳理都有迹可循。
同时,感谢 潘老师 提供宝贵的学习机会和专业的技术引导,让我能更深入地了解 Spring 的一部分底层逻辑。
也特别感谢团队中学长的优质文章为本文提供了重要参考与思路启发,其对Spring三级缓存解决循环依赖的清晰拆解,让我对该底层原理的理解更为透彻。
最后,感谢读到这里的。技术成长的本质,本就是在一次次踩坑、填坑、深挖底层的过程中完成的。如果这篇踩坑复盘能帮你避开同类问题,或是补全了 Spring 底层的模糊认知,便是这篇文章最大的价值。

文中若有疏漏之处,也欢迎大家在评论区交流指正,一起精进。

姜姜
36 声望9 粉丝

行百里者半九十