Bean 的加载机制、生命周期与三级缓存见启动原理。本文只回答工程问题:Bean 怎么定义、怎么注入、什么时候偏离单例,以及每个决策对应的排错线索。代码基于 Spring Boot 4.x(Spring Framework 7、Jakarta EE 11、Java 17+)。

Bean 定义:按来源选方式

来源方式要点
自己的业务类@Service / @Repository / @Controller语义即文档,扫描注册
第三方库组件@Configuration + @Bean需要定制创建过程
需强制加载的配置@Import组合配置类,替代包扫描
需编程批量注册BeanRegistrar(4.x 新增)循环/条件注册,替代单方法多 Bean 的反模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 业务组件:语义注解,不写 @Component 泛化标记
@Service
public class OrderServiceImpl
implements OrderService { }

// 第三方组件:@Bean 接管创建过程
@Configuration(proxyBeanMethods = false)
public class HttpClientConfig {

@Bean
RestClient restClient(RestClient.Builder builder,
HttpClientProperties props) {
return builder
.baseUrl(props.baseUrl())
.build();
}
}

一个 @Bean 方法只注册一个 Bean、返回最具体类型,这是容器推断类型的依据。需要按循环或条件批量注册时,4.x 提供 BeanRegistrar 编程式注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// @Import 引入,AOT 原生镜像同样支持
@Configuration(proxyBeanMethods = false)
@Import(ChannelRegistrar.class)
public class ChannelConfig { }

class ChannelRegistrar
implements BeanRegistrar {

@Override
public void register(BeanRegistry registry,
Environment env) {
for (String code : List.of("alipay",
"wechat")) {
registry.registerBean("channel." + code,
PayChannel.class,
spec -> spec.supplier(ctx ->
new PayChannel(code)));
}
if (env.matchesProfiles("preprod")) {
registry.registerBean(MonitorChannel.class);
}
}
}

两个决策点:

  • proxyBeanMethods = false 关闭配置类代理。类内 @Bean 方法互相调用时不再被拦截、不保证单例——只要不在配置类内部互调方法,就该关掉,省去 CGLIB 代理开销。
  • Bean 默认名是类名首字母小写,但前两个字母都大写时原名保留:URLClient 的 Bean 名仍是 URLClient@Qualifier 写错大小写是常见注入失败原因。

Bean 的来源决定注册路径:业务类由组件扫描发现;第三方或需要参数化创建的对象由 @Bean 工厂方法注册;配置组合使用 @Import。关闭配置类代理前,需要确认配置类内部不再直接互调 @Bean 方法。

依赖注入:构造器为默认

强制依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class OrderService {

private final PaymentService paymentService;
private final OrderRepository orderRepository;

// 单构造器可省略 @Autowired(Spring 4.3+)
public OrderService(PaymentService paymentService,
OrderRepository orderRepository) {
this.paymentService = paymentService;
this.orderRepository = orderRepository;
}
}

字段注入的问题不在语法,在于它把依赖藏起来:构造器签名就是依赖清单,一眼看清协作对象,测试时直接 new。保持全部构造器注入,"这个类依赖太多"的坏味道会在构造器参数上一行暴露出来,而不是散落在字段间。

可选依赖:ObjectProvider

@Autowired(required = false) 把"可能缺失"固化成 null 判断,散落在各处。ObjectProvider 把缺失处理收敛到使用点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class ReportService {

private final ObjectProvider<MetricsExporter>
metrics;

public ReportService(
ObjectProvider<MetricsExporter> metrics) {
this.metrics = metrics;
}

public void report() {
// 缺失时降级,不在启动期失败
MetricsExporter exporter =
metrics.getIfAvailable(
NoopExporter::new);
exporter.export(this);
}
}

多实现:从 @Primary 到 Map 路由

容器里同类型 Bean 不止一个且未消歧时,启动报 NoUniqueBeanDefinitionException。按需求选解法:

需求写法
有明确默认实现目标类标 @Primary
注入点显式选择构造器参数加 @Qualifier("beanName")
使用全部实现注入 List<T>Map<String, T>
1
2
3
4
5
6
7
8
9
10
11
12
// 默认实现:其余注入点不写限定符
@Service
@Primary
public class AlipayService
implements PaymentService { }

// 显式选择:Bean 名来自类名规则
public OrderService(
@Qualifier("wechatPayService")
PaymentService paymentService) {
this.paymentService = paymentService;
}

字段名匹配(private PaymentService alipayService 自动命中同名 Bean)是 Spring 消歧的最后回退,不是设计手段——重命名字段就静默换实现,禁止依赖。

实现会持续新增的场景(支付渠道、通知渠道),用 Map 收集全部实现,以开闭原则路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class PaymentRouter {

// key 为 Bean 名:alipayService、wechat…
private final Map<String, PaymentService>
servicesByCode;

public PaymentRouter(
Map<String, PaymentService> services) {
this.servicesByCode = services;
}

public PaymentService of(String payCode) {
PaymentService service =
servicesByCode.get(payCode);
if (service == null) {
throw new IllegalArgumentException(
"unsupported pay code: " + payCode);
}
return service;
}
}

新增渠道只需新增一个 @Service 实现,路由器零改动。

泛型接口不需要任何消歧:Spring 按泛型参数匹配,Repository<User>Repository<Order> 互不冲突。

注入注解的选择一条规则说清:统一 @Autowired(按类型)+ @Qualifier(消歧)。@Resource 按名称注入,是 Jakarta 标准注解,与 Spring API 混用只会让团队在两套语义间摇摆。

依赖注入先按类型找候选:零个候选是缺失,单个候选直接注入,多个候选要按 Qualifier、Primary 或集合注入的意图消歧;字段名匹配只是最后回退。

注入失败的排错线索

注入失败在启动期暴露,全部快于运行期。看异常链最底层的 caused-by:

异常含义排查方向
NoSuchBeanDefinitionException找不到候选实现类缺注解、包不在扫描范围、条件注解未命中
NoUniqueBeanDefinitionException候选多个未消歧@Primary@Qualifier
UnsatisfiedDependencyException上两者的包装看底层层因,栈顶即注入点

作用域:默认单例,谨慎偏离

Bean 作用域的决策树:无状态对象保留 singleton;可变状态必须按生命周期选择 request、session 或 prototype。把短生命周期 Bean 注入 singleton 时,request 与 session 依赖代理,prototype 则经 ObjectProvider 在使用点获取。

单例的契约是无状态。违反它不报错,只在并发下出错:

1
2
3
4
5
6
7
// 反例:单例持有请求级可变状态
@Component
public class RequestContextHolder {
private String currentUserId;
}
// 并发请求交叉写同一字段,
// 用户 A 读到用户 B 的数据

正确做法是无状态 + 参数传递;确需上下文时用 request 作用域。

prototype 注入陷阱

单例里注入 prototype,注入只发生一次——拿到的永远是同一个实例,作用域声明形同虚设:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
@Scope("prototype")
public class ReportTask { }

@Service
public class ReportService {

private final ObjectProvider<ReportTask> tasks;

public ReportService(
ObjectProvider<ReportTask> tasks) {
this.tasks = tasks;
}

public void run() {
ReportTask task = tasks.getObject();
// 每次调用新实例,符合预期
}
}

Web 作用域必须开代理

1
2
3
4
5
@Component
@Scope(
value = WebApplicationContext.SCOPE_REQUEST,
proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestUserInfo { }

不开代理,容器会在启动期把真实实例注入单例,而此时不存在 HTTP 请求,直接报 No thread-bound request foundproxyMode 让注入的是代理,实际解析延迟到每次方法调用时。

生命周期回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class InventoryCache {

private final InventoryRepository repository;
private Map<Long, Integer> stock;

public InventoryCache(
InventoryRepository repository) {
this.repository = repository;
}

@PostConstruct
void load() {
stock = repository.loadAllStock();
}

@PreDestroy
void flush() {
repository.saveAll(stock);
}
}

执行顺序:

1
2
3
4
5
构造器 → 依赖注入 → @PostConstruct
afterPropertiesSet() → init-method
→ AOP 代理创建 → 就绪
→ (容器关闭)@PreDestroy
→ DisposableBean.destroy() → destroy-method

Bean 生命周期按创建和销毁两条路径展开:初始化回调发生在 AOP 代理生成前,因此其中的 this 自调用不会经过事务或异步切面;耗时预热应移到应用就绪后。

三个决策点:

  • @PostConstruct / @PreDestroy 是标准注解(jakarta.annotation 包),不耦合 Spring 接口,优先使用。注意 4.x 所基于的 Framework 7 已彻底移除 javax.annotation / javax.inject 注解支持,javax.* 老代码无法启动,必须完成 Jakarta 迁移。
  • @PostConstruct 在 AOP 代理创建之前执行,方法内 this.xxx() 自调用不经过代理,@Transactional@Async 全部失效。
  • InitializingBeanDisposableBean、各类 Aware 接口把代码绑死在 Spring API 上,只在库开发中使用。

初始化过重(全量预热、远程拉取)时不要堆进 @PostConstruct——它阻塞启动。改用 ApplicationRunner 或监听 ApplicationReadyEvent

条件化装配

@ConditionalOnProperty 匹配规则

属性状态havingValue 省略havingValue = “true”matchIfMissing = true
属性 = true匹配匹配
属性 = false不匹配不匹配
属性 = “abc”匹配不匹配
属性不存在不匹配不匹配匹配

规则归纳:省略 havingValue 时,属性存在且不为 false 即匹配;matchIfMissing 决定属性缺失时走默认开还是默认关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration(proxyBeanMethods = false)
public class PaymentProviderConfig {

@Bean
@Primary
@ConditionalOnProperty(
name = "payment.provider",
havingValue = "alipay",
matchIfMissing = true)
PaymentService alipay() {
return new AlipayService();
}

@Bean
@ConditionalOnProperty(
name = "payment.provider",
havingValue = "wechat")
PaymentService wechat() {
return new WechatPayService();
}
}

matchIfMissing = true 只加在默认实现上,保证不配置时恰好有一个 Bean 生效。属性名支持宽松绑定:payment.provider 能匹配环境变量 PAYMENT_PROVIDERname 传数组时必须全部满足才匹配。

以 payment.provider 为例梳理 ConditionalOnProperty:显式 alipay 或 wechat 分别只注册对应实现;属性缺失时由默认实现的 matchIfMissing 接管。它解决应用内互斥选择,而不是用户覆盖自动配置的问题。

@ConditionalOnMissingBean 的边界

它实现"用户配置优先于自动配置",但只在自动配置类里有这语义。普通应用里用它,Bean 是否生效取决于注册顺序,条件评估结果难以推断——应用代码需要互斥时,用 @ConditionalOnProperty 这种显式开关。

循环依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class OrderService {
private final PaymentService payments;
public OrderService(PaymentService p) {
this.payments = p;
}
}

@Service
public class PaymentService {
private final OrderService orders;
public PaymentService(OrderService o) {
this.orders = o;
}
}
// 启动失败:构造器环无解

Spring Boot 2.6 起默认拒绝循环依赖,构造器注入的环在 Spring Framework 层面本就无法成立。处理优先级:

  1. 重构(正解):循环依赖是两个类职责纠缠的信号。抽取第三个类承载公共逻辑,或用事件反转通知方向——OrderService 发布 OrderPaidEvent,另一方监听,依赖单向化。
  2. @Lazy(明确止血):注入的是代理,首次调用才解析目标。适用于确有环形领域模型且改动成本高的存量代码,新代码里出现就是坏味道。

三级缓存只救"singleton + setter/字段注入"的环,且自 Boot 2.6 起默认关闭;prototype 的环任何手段都无解。机制细节见启动原理

构造器循环依赖没有可提前暴露的实例,因此在创建阶段失败;应优先通过第三个协调者或领域事件把双向依赖改为单向。@Lazy 只是把一边替换为延迟解析的代理,用于存量止血。

检查清单

检查项标准
Bean 定义业务类用语义注解;第三方用 @BeanproxyBeanMethods = false;批量/条件注册用 BeanRegistrar
注入构造器 + final 字段;可选依赖用 ObjectProvider
作用域单例必须无状态;prototype 注入单例改用 ObjectProvider
Web 作用域显式 proxyMode = TARGET_CLASS
生命周期@PostConstruct / @PreDestroy;重初始化移入 ApplicationRunner
条件装配默认实现配 matchIfMissing = true@ConditionalOnMissingBean 只用于库
循环依赖默认拒绝;重构优先,@Lazy 仅止血