启动日志里那行 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. 自测
- 二级缓存和三级缓存各存什么?为什么二级不够用?
- A、B 字段注入互依赖,B 在构造期调用 A 的方法,会看到什么?
- 构造器注入的循环为什么用三级缓存解不了?
- Boot 2.6 把哪个开关默认关了?打开它的代价是什么?
@Async场景下,为什么"解开了"反而可能出现行为不一致?@Lazy与ObjectProvider在语义上有什么不同?