同事的提问:改成构造器注入就启动失败了
11 月初,组里在推"统一用构造器注入",小杨改完一个服务发现起不来了:
org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'orderService': Requested bean is currently in
creation: Is there an unresolvable circular reference?
他的疑惑很合理:同样的两个类,用 @Autowired 字段注入能正常跑,换成构造器注入就报循环依赖。代码是这样的:
// 原来:字段注入,能启动
@Service
public class OrderService {
@Autowired
private CouponService couponService;
}
@Service
public class CouponService {
@Autowired
private OrderService orderService;
}
// 改后:构造器注入,启动失败
@Service
public class OrderService {
private final CouponService couponService;
public OrderService(CouponService couponService) {
this.couponService = couponService;
}
}
要回答这个问题,得先说清楚 Spring 是怎么"允许"循环依赖的。
三级缓存:循环依赖是怎么被解决的
DefaultSingletonBeanRegistry 里有三个 Map:
/** 一级缓存:完整的单例 Bean,初始化全部完成 */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/** 二级缓存:提前暴露的半成品 Bean,属性还没填完 */
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);
/** 三级缓存:Bean 工厂,用来生成早期引用 */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
两个 Bean 循环依赖时的完整流程:
- 容器要创建
orderService,先标记它为"创建中"(singletonsCurrentlyInCreation集合)。 - 实例化:调用
OrderService的构造器,得到一个还没有注入任何属性的空对象。 - 把这个空对象包装成
ObjectFactory放进三级缓存singletonFactories。 - 填充属性:发现需要
couponService,转去创建它。 - 创建
couponService,同样走实例化 → 三级缓存 → 填充属性。 - 填充时发现需要
orderService,于是去缓存里找:一级没有(还没创建完)、二级没有、三级有。调用ObjectFactory.getObject()拿到orderService的早期引用,放进二级缓存,并从三级缓存移除。 couponService拿到orderService的引用(是个半成品,但引用地址是对的),完成初始化,进一级缓存。- 回到第 4 步,
orderService拿到couponService,完成初始化,进一级缓存。
关键在于第 2 步和第 3 步的顺序:必须先实例化(调构造器),才能把引用暴露出去。
而构造器注入的问题就出在这里——OrderService 的构造器需要一个 CouponService 实例,但那时候 orderService 还没实例化完,couponService 也没创建。两边都在等对方的构造器先跑完,死锁。所以构造器注入的循环依赖,三级缓存救不了。
为什么三级缓存不是两级
这是个经典的追问。既然二级缓存存的是半成品对象,直接把实例化后的对象放进去不就行了?
答案是 AOP。如果 OrderService 被切面代理了,那注入给 CouponService 的应该是代理对象,而不是原始对象。但代理是在初始化完成后才生成的(AnnotationAwareAspectJAutoProxyCreator 的 postProcessAfterInitialization)。
三级缓存存的是工厂而不是对象,就是为了把这个决策往后推。看源码里那段 ObjectFactory:
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
for (BeanPostProcessor bp : getBeanPostProcessors()) {
if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
SmartInstantiationAwareBeanPostProcessor ibp =
(SmartInstantiationAwareBeanPostProcessor) bp;
exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
}
}
}
return exposedObject;
}
只有在真的发生了循环依赖时,才会调用这个工厂提前生成代理。没有循环依赖的话,代理还是按正常时机在初始化后生成。这个设计挺精巧,但也说明 Spring 为了兼容循环依赖付了不少复杂度。
允许循环依赖的代价
上面那段流程看着很完美,但有个细节很危险:第 6 步注入进 couponService 的 orderService,是一个 @PostConstruct 还没执行、属性还没填完的对象。
如果 CouponService 在构造器或者初始化方法里直接用了 orderService 的某个字段,拿到的就是 null。这种 bug 特别难查,因为字段名看起来明明注入了。
@Service
public class CouponService {
@Autowired
private OrderService orderService;
@PostConstruct
public void init() {
// 这里 orderService 可能还只是个半成品,它的内部字段可能还是 null
orderService.someMethod(); // 潜在的 NPE,而且和 Bean 创建顺序有关
}
}
更麻烦的是它和行为和 Bean 的创建顺序绑定:A 先创建和 B 先创建,结果可能不一样。改一个类名、加一个 Bean 都可能触发。
所以 Spring Boot 2.6 做了一个明确的决定。
Spring Boot 2.6:默认不允许循环依赖了
Spring Boot 2.6.0(2021 年 11 月 17 日发布)把 spring.main.allow-circular-references 的默认值改成了 false。升级之后,原来"能跑"的循环依赖会直接启动失败:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
orderController
↓
orderService
↓
couponService
↓
orderService
Action:
Relying upon circular references is discouraged and they are prohibited by default.
Update your application to remove the dependency cycle between beans. As a last
resort, it may be possible to break the cycle automatically by setting
spring.main.allow-circular-references to true.
报错信息比之前友好太多,直接把环给画出来了。
我的看法是这个默认值改得对。循环依赖几乎总是设计问题的信号:两个类互相需要对方的全部能力,说明职责划分有问题。
如果确实要临时放行(比如老系统一次性改不完):
spring:
main:
allow-circular-references: true
但这是缓兵之计,记个 TODO 尽快重构。
构造器注入 vs 字段注入
既然要改,顺便把这两种注入方式的争论说清楚。Spring 官方(包括 Spring Data 团队的 Oliver Drotbohm 在各种场合)推荐构造器注入,理由我觉得站得住脚:
| 维度 | 构造器注入 | 字段注入 |
|---|---|---|
| 不可变性 | 可以声明 final,依赖不会被改 | 字段必须是非 final,随时可改 |
| 依赖可见性 | 构造器签名就是依赖清单,一眼看全 | 扫一遍所有字段才知道 |
| 循环依赖 | 启动时立刻暴露 | 被三级缓存掩盖,可能埋雷 |
| 单元测试 | new OrderService(mockCouponService) | 必须靠 @MockBean 或者反射设字段 |
| NPE 风险 | 不可能注入失败(构造都过不了) | 绕过容器 new 出来的实例字段是 null |
| 代码量 | 依赖多时构造器很长,要写 Lombok 的 @RequiredArgsConstructor | 简洁 |
字段注入唯一的优势是写得少,而用 Lombok 的 @RequiredArgsConstructor 之后这个优势也没了:
@Service
@RequiredArgsConstructor
public class OrderService {
private final CouponService couponService;
private final InventoryService inventoryService;
private final OrderMapper orderMapper;
}
Lombok 会为所有 final 字段生成构造器,Spring 4.3 起单构造器场景不需要写 @Autowired。这段代码和字段注入一样短,但拿到了不可变性和明确依赖。
我自己的习惯:必需的依赖用构造器注入,可选的依赖(有默认值)用 setter 注入。字段注入基本不写了。
怎么重构掉循环依赖
我们那个 OrderService 和 CouponService 互相依赖,最后是这样拆的。先看清它们各自需要对方什么:
// 重构前
OrderService 需要 CouponService 的:计算优惠金额
CouponService 需要 OrderService 的:查询订单商品明细
方案一(我们用的):抽出第三方。两个类都需要的"订单金额计算"逻辑既不属于订单也不属于优惠券,抽成独立的服务。
@Service
@RequiredArgsConstructor
public class OrderPriceCalculator {
private final OrderItemRepository itemRepository; // 从 OrderService 里挪出来
private final CouponRuleRepository ruleRepository; // 从 CouponService 里挪出来
public PriceResult calculate(Long orderId) {
List<OrderItem> items = itemRepository.findByOrderId(orderId);
List<CouponRule> rules = ruleRepository.findActiveRules();
return PriceResult.of(items, rules);
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderPriceCalculator priceCalculator; // 只依赖计算器
}
@Service
@RequiredArgsConstructor
public class CouponService {
private final OrderPriceCalculator priceCalculator; // 只依赖计算器
}
依赖方向变成 OrderService → Calculator 和 CouponService → Calculator,环没了。
方案二:事件驱动。如果一方只是"通知"另一方,不需要返回值,用事件最干净。
// CouponService 不再依赖 OrderService,只发事件
@Service
@RequiredArgsConstructor
public class CouponService {
private final ApplicationEventPublisher publisher;
public void writeOff(Long couponId, Long orderId) {
// ... 核销逻辑
publisher.publishEvent(new CouponUsedEvent(couponId, orderId));
}
}
// OrderService 监听,单向依赖
@Service
public class OrderService implements ApplicationListener<CouponUsedEvent> {
@Override
public void onApplicationEvent(CouponUsedEvent event) {
// 更新订单的优惠金额
}
}
注意 @TransactionalEventListener 的坑:默认的 ApplicationEvent 是同步的,在发布者的事务里执行。如果需要事务提交后才处理,用 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)。
方案三:依赖倒置。定义一个接口,让依赖方向反过来。
public interface OrderQueryService {
List<OrderItem> getItems(Long orderId);
}
@Service
public class CouponService {
private final OrderQueryService orderQuery; // 依赖抽象,不依赖具体
}
@Service
public class OrderService implements OrderQueryService {
// 实现接口,不再需要 CouponService
}
方案四:@Lazy。这是最后的选择,本质上没有解决设计问题,只是把注入时机推迟。
@Service
@RequiredArgsConstructor
public class OrderService {
@Lazy
private final CouponService couponService; // 注入一个代理,第一次调用时才真正创建
}
@Lazy 会注入一个 CGLIB 代理,第一次调用方法时才去容器里拿真实的 Bean。能用,但把问题藏起来了,我不推荐,除非是改不动的遗留代码。
我们的改造结果
整个服务里有 5 处循环依赖(在 Spring Boot 2.6 升级时全部暴露出来):
| 位置 | 处理方式 |
|---|---|
| OrderService ↔ CouponService | 抽出 OrderPriceCalculator |
| UserService ↔ MemberLevelService | 抽出 MemberLevelCalculator |
| PayService → NotifyService → PayService | NotifyService 改成监听 PaySuccessEvent |
| ReportService ↔ ExportService | ExportService 依赖 ReportQuery 接口(依赖倒置) |
| SmsService ↔ TemplateService | TemplateService 不再需要 SmsService,删掉反向调用即可 |
5 处改完,代码行数净减少 180 行(抽出的类各有复用),单元测试里去掉了 12 个 @MockBean。启动时间没变化,但心里踏实多了。
小结
- 三级缓存的解决能力有限:只能处理单例 + 非构造器注入。prototype 和构造器注入的循环依赖直接报错。
- 三级缓存存
ObjectFactory而不是对象,是为了在发生循环依赖时才提前生成 AOP 代理。没有循环依赖时,代理仍在初始化后按正常时机生成。 - 允许循环依赖的隐患:注入的对方可能是
@PostConstruct还没跑的半成品,且行为依赖 Bean 创建顺序。这类 bug 极难复现。 - Spring Boot 2.6 起
spring.main.allow-circular-references默认false,升级会直接启动失败,报错信息会画出完整的环,照着改就行。 - 重构优先级:抽第三方 > 事件驱动 > 依赖倒置 >
@Lazy。@Lazy只是藏问题,别当常规手段。 - 构造器注入配合 Lombok 的
@RequiredArgsConstructor,代码量和字段注入差不多,还能拿到不可变性和明确的依赖清单。Spring 4.3 起单构造器不用写@Autowired。