2613 字
约 8 分钟
14
S07-X07 · 循环依赖与三级缓存:Spring 到底怎么解的

启动日志里那行 The dependencies of some of the beans ... form a cycle,是升到 Boot 2.6 之后最常见的报错之一。直接原因是框架把"允许循环依赖"的开关默认关了;但真正值得弄清楚的是:开关打开时 Spring 靠什么把这个圈绕过去,以及哪些圈它压根绕不过去。

这篇按"缓存有什么 → 怎么一步步解开 → 哪些解不了 → 该不该解"的顺序推。


1. 三级缓存的确切内容

三个 Map 都在 DefaultSingletonBeanRegistry 里,层级是"成品 → 半成品 → 能生产半成品的工厂"。

缓存 字段名 存什么 何时写入
一级 singletonObjects 完整初始化好的 Bean(已代理) 初始化完成后
二级 earlySingletonObjects 早期引用(可能是代理) 从三级取出工厂并调用后
三级 singletonFactories ObjectFactory<?>,能产出早期引用 doCreateBean 实例化后、填充属性前
// 简化后的 getSingleton(Spring 6.x)
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    Object singleton = singletonObjects.get(beanName);
    if (singleton == null && isSingletonCurrentlyInCreation(beanName)) {
        singleton = earlySingletonObjects.get(beanName);
        if (singleton == null && allowEarlyReference) {
            ObjectFactory<?> factory = singletonFactories.get(beanName);
            if (factory != null) {
                singleton = factory.getObject();                 // 触发一次,拿到早期引用
                earlySingletonObjects.put(beanName, singleton);   // 升级到二级
                singletonFactories.remove(beanName);
            }
        }
    }
    return singleton;
}

还有一个看不见但关键的集合:singletonsCurrentlyInCreation。只有"创建中"的 Bean 才允许走早期引用,这是防止任意对象被提前暴露的闸门。


2. 为什么必须有第三级

如果只是为了打破引用环,二级缓存就够——把刚 newInstance 出来的对象直接放进二级,别人拿到引用填进去即可。难点在于这个引用可能不是最终对象:如果 A 需要被 AOP 代理,最终暴露到一级缓存的是代理对象,而这时候代理还没造出来。

于是三级缓存的工厂是延迟的:ObjectFactory.getObject() 里调的是 getEarlyBeanReference(),它遍历所有 SmartInstantiationAwareBeanPostProcessor,让 AbstractAutoProxyCreator 有机会在早期就生成代理。

方案 结果
只有二级、直接放原始对象 拿到的是原始对象,不是代理 → AOP 失效
只有二级、放代理对象 代理还没条件创建(属性未填充)
三级 + 工厂 需要时才提前生成代理,且只生成一次
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    for (BeanPostProcessor bp : getBeanPostProcessors()) {
        if (bp instanceof SmartInstantiationAwareBeanPostProcessor ibp) {
            exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
        }
    }
    return exposedObject;
}

工厂被调用后立刻从三级删除、写入二级,所以"提前生成代理"只发生一次。这是三级缓存存在的唯一理由——不是为了多一层缓存,是为了多一个延迟决策点。


3. A 与 B 互相依赖的完整推演

@Service
public class AService {
    @Autowired private BService b;      // 字段注入
}

@Service
public class BService {
    @Autowired private AService a;
}
步 动作 缓存状态变化
1 getBean("aService"),一级没有,开始创建 inCreation = {A}
2 实例化 A(newInstance),得到一个空对象 —
3 把 A 的 ObjectFactory 放进三级 三级 = {A: factory}
4 填充属性,发现依赖 B → getBean("bService") —
5 实例化 B,把 B 的工厂放进三级 三级 = {A, B}
6 填充 B 的属性,发现依赖 A → getBean("aService") —
7 一级没有,A 在创建中 → 查二级,没有 → 调三级工厂 二级 = {A: 早期引用},三级移除 A
8 B 拿到 A 的早期引用并完成初始化 一级 = {B}
9 回到 A 的属性填充,注入 B —
10 A 初始化完成,如果第 7 步已生成早期代理,则复用 一级 = {A, B}
11 从 inCreation 移除 清空

关键在第 7 步:B 拿到的是 A 的早期引用,此时 A 的字段还是 null。只要 B 在构造期不调用 A 的方法就没问题;一旦在 B 的 @PostConstruct 里调 a.doSomething(),就会读到未注入的字段。


4. 构造器注入为什么解不了

构造器注入要在 newInstance 时就把参数准备好,而此刻 A 对象尚不存在,没有任何东西可以放进缓存。

@Service
public class AService {
    private final BService b;
    public AService(BService b) { this.b = b; }   // 必须给参数才能造 A
}

流程是:要造 A → 需要 B → 造 B → 需要 A → 要造 A……inCreation 里已经有 A,直接抛 BeanCurrentlyInCreationException。三级缓存帮不上忙,因为它的写入时机太晚(必须实例化之后)。这是时序上的死结,不是缓存设计问题。


5. @Lazy 与 ObjectProvider 的破环

手段 原理 适用
@Lazy 在注入点 注入一个代理,首次调用时才真正取 Bean 字段/构造器都可用
@Lazy 在 Bean 定义上 延迟整个 Bean 的初始化时机 全局视角
ObjectProvider<T> 注入一个"取用器",getObject() 时才解析 需多次获取或可选依赖
拆 Bean / 引入事件 消除依赖方向 根本上解决
@Service
public class AService {
    private final ObjectProvider<BService> bProvider;   // 破构造器环
    public AService(ObjectProvider<BService> p) { this.bProvider = p; }
    public void run() { bProvider.getObject().doIt(); }
}

@Service
public class BService {
    public BService(@Lazy AService a) { this.a = a; }   // 注入代理,破环
}

@Lazy 给的是一个 CGLIB 代理(或 JDK 代理),真实对象在第一次调用时才解析。代价是:错误从启动期推迟到了运行期,如果确实有循环逻辑,异常会在第一次调用时以别的方式出现。


6. Boot 2.6 之后默认关闭

版本 默认 说明
Boot < 2.6 允许 静默解环
Boot 2.6+ 禁止 spring.main.allow-circular-references=false
Boot 3.3.x 禁止 按官方 SpringApplication 属性核对
spring:
  main:
    allow-circular-references: true    # 只在存量系统里作为过渡开关

打开的后果不是"问题消失",而是把设计缺陷藏起来。存量代码暂时开、把日志里的环列出来、逐个改完再关,是唯一有意义的用法。


7. 哪些循环依赖仍然报错

形态 能否用三级缓存解 原因
字段/setter 注入的 singleton 能 有早期引用可用
构造器注入的 singleton 不能 实例化前就需要对方
prototype 作用域互相依赖 不能 原型 Bean 不进单例缓存
@Async 方法所在 Bean 视情况报错 提前暴露时拿到的是原始对象,后续代理不一致
@Transactional + 构造器注入 不能 同构造器场景
循环出现在 @Configuration 类的 @Bean 方法间 常见报错 工厂方法参数必须先求值

@Async 那条尤其隐蔽:它由 AsyncAnnotationBeanPostProcessor 在初始化后替换对象,如果这个 Bean 已经被当作早期引用交出去,别人手里就是旧的原始对象,异步行为对那一方不存在。


8. 该不该解

情形 判断
A 调 B、B 回调和 A 无关 可能是误报,看是否只是共用工具类
A 依赖 B,B 依赖 A 的某个方法 真问题,起码有一方职责错位
父子层级(ParentService ↔ ChildService) 通常该抽第三层
领域对象 ↕ 仓储 边界没划清
循环在 @Configuration 的 @Bean 间 工厂方法参数为先求值,换 ObjectProvider

判断标准很朴素:把 A、B 画到分层图上,如果箭头需要双向,就是设计问题。技术手段(@Lazy、allow-circular-references)只是把编译期/启动期的问题推后。

// 反例:靠 @Lazy 掩盖分层错误
@Service
public class OrderService {
    @Lazy @Autowired private PayService payService;   // 订单为什么要知道支付?
}

9. 排错表

现象 原因 处理
启动报 BeanCurrentlyInCreationException 构造器循环 @Lazy 参数或改字段注入后重构
升级后大面积报环 Boot 2.6 默认关闭 逐个消环,短期用开关过渡
解环后某字段读出来是 null 早期引用被提前使用 把使用点移到 @PostConstruct 之后
解环后 AOP/事务在某一方失效 早期引用绕过了代理 用 @Lazy 而非三级缓存硬绕
prototype Bean 报环 原型不进缓存 改 singleton,或运行期取 Bean
@Configuration 的 @Bean 互调报环 工厂方法参数强制先创建 用 ObjectProvider 参数

10. 边界

场景 建议
两层 Service 互相调用 抽公共服务或改事件驱动
事件监听器依赖发布者 用 @EventListener 天然解耦
框架内部(AOP、Transaction)出现的环 通常由自建切面引起,检查切面里注入的业务 Bean

一句话:三级缓存解决的是"引用可见性",不是"设计合理性"。启动能起来只说明这圈没挡住初始化,不代表它该存在。

// 自检:把环上所有 Bean 打印出来
Arrays.stream(ctx.getBeanDefinitionNames())
      .filter(n -> ctx.getBeanFactory().getBeanDefinition(n).isSingleton())
      .forEach(n -> log.debug("{} -> {}", n, ctx.getBean(n).getClass().getSimpleName()));

前提是这些 Bean 都能正常创建;若某一环本身报错,这段代码会在启动期失败,那就回第 7 节按形态判断。


11. 自测

  1. 二级缓存和三级缓存各存什么?为什么二级不够用?
  2. A、B 字段注入互依赖,B 在构造期调用 A 的方法,会看到什么?
  3. 构造器注入的循环为什么用三级缓存解不了?
  4. Boot 2.6 把哪个开关默认关了?打开它的代价是什么?
  5. @Async 场景下,为什么"解开了"反而可能出现行为不一致?
  6. @Lazy 与 ObjectProvider 在语义上有什么不同?
S07-X07 · 循环依赖与三级缓存:Spring 到底怎么解的
https://www.clxhxhhr.top/posts/5854/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。