BeanDefinition 详解:Bean 的户口本与注册表

bee2026-10-0856 分钟0 次阅读
容器管理的不是对象,而是「对象的定义」。逐个拆解 BeanDefinition 的核心字段、三种注册来源、父子合并与后置处理器的修改时机。
1 / 124
小节
〇、30 秒看懂
2 / 124

新手最容易把 Spring 想成「一个会批量帮我 new 对象的工厂」。真相是:容器在启动阶段压根不造对象,它先攒了一本「怎么做」的说明书——记类名、记作用域、记初始化方法名。这本说明书就叫 BeanDefinition;真正的对象(Bean)要等到 refresh() 后半段才照着它生产出来。所以本篇所有话题都围绕一句话:管的是定义,不是对象。

3 / 124

先把五个词说清楚(全文反复出现):

4 / 124
  • 容器:帮你创建和管理对象的那个「大管家」对象(ApplicationContext / BeanFactory),你向它要东西(getBean),它按名字找
  • Bean:交给容器创建和管理的那个对象,通常就是你写的一个普通 Java 类的实例
  • BeanDefinition:关于「怎么造某个 Bean」的一份数据(不是代码),字段包括用哪个类、单例还是原型、懒不懒加载、初始化/销毁方法叫什么
  • 注册表:存放所有定义的那张 Map——beanName → BeanDefinition,容器查谁都先翻这张表
  • 反射:程序在运行时「看着类名去调构造器」的技术;容器没编译期绑定你的类,全靠反射按定义里的 beanClass 把对象造出来
5 / 124
类比

把容器想成一家餐厅的后厨。BeanDefinition 是墙上贴的菜谱卡:菜名(beanName)、用料(class)、份量和火候(scope、lazyInit、initMethodName)。顾客点菜(getBean)时厨房才照卡做菜(Bean)。菜谱可以先贴好、可以互相抄改(父子继承)、也可以被行政总厨统一改写(BeanFactoryPostProcessor)——这些动作全部发生在任何一道菜做出来之前。这就是「定义先于对象」的全部含义。

6 / 124
架构图
图 · 本篇地图:BeanDefinition 里到底存了什么
图 · 本篇地图:BeanDefinition 里到底存了什么
7 / 124

学完这一篇,你应该能回答三个问题:

8 / 124
  • 我在类上写的 @Scope("prototype"),究竟落到了哪张表的哪个字段上?
  • 「XML 配置」和「注解配置」到底是两套机制,还是同一套机制的两个入口?
  • 为什么想改「有哪些 Bean」必须在 refresh() 第 5 步之前动手,而想改「对象长什么样」得用另一个接口?
9 / 124
小节
一、为什么需要 BeanDefinition:对象是结果,定义才是蓝图
10 / 124

如果问"容器里装的是什么",直觉回答是"装的是对象"。但这恰恰是个常见误解——容器启动过程中真正被登记、被检索、被修改的东西,是"对象的定义",而不是对象本身。

11 / 124

用户籍打个比方:Bean 是户籍系统里那个具体的"人",而 BeanDefinition 是 TA 的档案——姓名(beanName)、住址(class)、家庭成员(dependsOn)、备注(作用域、懒加载、初始化方法……)。你要查一个人,先翻的是档案;真正被复制、被合并、被改写的,也是档案。对象只在最后一刻才按档案"生产"出来。

12 / 124
架构图
图 1 · BeanDefinition:Bean 的户口本
图 1 · BeanDefinition:Bean 的户口本
13 / 124

这带来两个关键能力:

14 / 124
  • 延迟与条件:有了定义,容器就能先"看清全局再决定做什么"——合并父子定义、判断条件装配、提前为 AOP 建代理,全都发生在对象诞生之前
  • 可编程:只要往注册表里塞一份定义,容器就会像对待任何 Bean 一样对待它。这正是自动配置、@Bean、@Import 能"无中生有"造出 Bean 的底层原因
15 / 124
小节
二、核心字段逐个拆
16 / 124

BeanDefinition 是一个接口,真正的载体是 AbstractBeanDefinition 及其子类。核心字段如下:

17 / 124
对照表
字段一句人话
beanClass这个 Bean 要用哪个类来实例化(也可能是工厂方法所在类)
scopesingleton / prototype,决定是"一个实例"还是"一堆实例"
lazyInit为 true 时不参与启动预实例化,第一次用到才创建
primary同类型多个候选时,优先选它
dependsOn必须先于本 Bean 创建的名字列表(不参与注入,只保证顺序)
initMethodName初始化回调方法名(等价 XML 的 init-method)
destroyMethodName销毁回调方法名(等价 XML 的 destroy-method)
autowireMode自动装配模式(byName / byType / constructor / no)
abstract抽象定义,只作为父模板,永远不会被实例化
parentName父定义的 beanName,用于合并
18 / 124

代码里看它长什么样:

19 / 124
代码对照
代码java
// 用建造者风格创建一份定义(Spring 5.0+ 推荐方式)AbstractBeanDefinition bd = BeanDefinitionBuilder        .genericBeanDefinition(HelloServiceImpl.class)        .setScope(BeanDefinition.SCOPE_SINGLETON)        .setLazyInit(true)        .setDependsOn("dataSource")        .setInitMethodName("warmUp")        .setDestroyMethodName("clear")        .addConstructorArgValue("Hello")        .getBeanDefinition();System.out.println(bd.getBeanClassName());   // com.example.HelloServiceImplSystem.out.println(bd.getScope());           // singleton
解读
  • BeanDefinitionBuilder 是官方提供的建造者,避免直接 new 具体子类
  • addConstructorArgValue("Hello") 对应 XML 里的 <constructor-arg value="Hello"/>
  • 注意 setDependsOn 只保证创建顺序,并不会有对象被注入进来——这点极易混淆
  • 再补一个真实坑:上面那行 // singleton 是「你显式设置过」的情形。没设置过时 getScope() 返回的是空串而不是 "singleton"(默认单例靠 isSingleton() 判断),所以读定义请用 isSingleton() / isPrototype(),别比字符串——第十三节练习会让你亲眼看到这一格是空的

提示:dependsOn 与"依赖注入"是两码事。前者是"请你先把它建好我再用",后者是"请把它塞进我的构造器 / setter"。前者管顺序,后者管传值。

20 / 124

上面这十个字段只是「纸上信息」。它们要变成你手里那个对象,中间还隔着六步——扫描、生成定义、注册、被改写、实例化、注入初始化。先花十秒把这条路线看熟,后面每个小节都在这条线上找一个位置:

21 / 124
原理动画
动图 · 定义变对象的六步(本节速览)
动图 · 定义变对象的六步(本节速览)
22 / 124
小节
三、三种注册来源
23 / 124
原理动画
动图 · 从定义到实例的六步
动图 · 从定义到实例的六步
24 / 124

定义从哪里来?最常见的三条路径:

25 / 124

其一,XML:

26 / 124
java
// XmlBeanDefinitionReader:把 <bean> 标签读成定义DefaultListableBeanFactory factory = new DefaultListableBeanFactory();XmlBeanDefinitionReader reader = new XmlBeanDefinitionReader(factory);reader.loadBeanDefinitions(new ClassPathResource("applicationContext.xml"));
27 / 124

其二,注解扫描:

28 / 124
java
// ClassPathBeanDefinitionScanner:扫包 + 读注解,生成 ScannedGenericBeanDefinitionAnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();ctx.scan("com.example");   // 内部就是 ClassPathBeanDefinitionScannerctx.refresh();
29 / 124

其三,@Bean 方法:

30 / 124
代码对照
代码java
@Configurationpublic class AppConfig {    @Bean                       // 由 ConfigurationClassBeanDefinitionReader 解析    public HelloService helloService() {        return new HelloServiceImpl("Hello");    }}
解读
  • 三条路径产出的都是 BeanDefinition,只是实现子类不同,进入的都是同一个 BeanDefinitionRegistry
  • XML 走 XmlBeanDefinitionReader,注解扫描走 ClassPathBeanDefinitionScanner,@Bean 方法走 ConfigurationClassBeanDefinitionReader
  • 所以"用 XML 还是注解"只在读取阶段不同,之后的注册、合并、实例化流程完全统一
31 / 124

把「同一段配置分别用注解和 XML 写」并排画出来,两边字段一一对得上——这正是上面那句结论的可视化证据:

32 / 124
架构图
图 · 同一段配置:注解 vs XML
图 · 同一段配置:注解 vs XML
33 / 124
类比

像一家餐厅同时开了小程序点单和前台手写单两个入口。前厅收单方式完全不同,但两张单子最后都打印成后厨那台机器里的同一种工单(BeanDefinition)——后厨根本不知道也不关心客人是从哪个入口下单的。所以「XML 派 vs 注解派」的争论其实是个伪问题:争的是前厅,后厨只有一套流程。

34 / 124
小节
四、手写代码注册 BeanDefinition
35 / 124

既然定义才是核心,那我们完全可以在运行时手动塞一份进去——这就是"我自己也可以当容器":

36 / 124
java
package com.example;import org.springframework.beans.factory.support.RootBeanDefinition;import org.springframework.context.support.GenericApplicationContext;public class ManualRegistryDemo {    public static void main(String[] args) {        // 1. 空容器:不自动扫描、不自动加载 XML        GenericApplicationContext ctx = new GenericApplicationContext();        // 2. 手动登记一份定义:beanName = "dynamicHelloService"        RootBeanDefinition bd = new RootBeanDefinition(HelloServiceImpl.class);        bd.getConstructorArgumentValues().addIndexedArgumentValue(0, "Hi");        ctx.registerBeanDefinition("dynamicHelloService", bd);        // 3. 必须先 refresh,容器才会真正实例化注册过的定义        ctx.refresh();        // 4. 和任何 Bean 一样取用        HelloService hello = ctx.getBean("dynamicHelloService", HelloService.class);        System.out.println(hello.sayHello("Manual"));        ctx.close();    }}
37 / 124

控制台输出:

38 / 124
代码对照
代码text
Hi, Manual
解读
  • GenericApplicationContext 是一个"干净"的容器:不自动扫描、不自动加载 XML,一切由你显式登记
  • registerBeanDefinition(name, bd) 之后还需要 refresh()——注册只是"上户口",refresh 才是"开始生产"
  • 输出 Hi, Manual 说明构造器参数真的被注入生效了——整个过程你没写一行 XML 或注解

坑:在 refresh() 之后再调用 registerBeanDefinition 会抛 IllegalStateException(容器已激活)。运行时要动态加 Bean,应改用 BeanDefinitionRegistryPostProcessor,或在 refresh() 前完成登记;非要后置添加,可借助 DefaultListableBeanFactory 直接操作,但必须自己处理已实例化的单例缓存。

39 / 124
小节
五、BeanDefinition 家族表
40 / 124

六个子类名字长得劝退,背是背不住的——但其实只有一件事值得记:这份定义是从哪扇门进来的。玩一局,先点实现类,再点它的出身:

41 / 124
配对闯关
闯关六个定义子类,配它们的出身已配对 0/6 · 配错 0
左边是源码里的类名,右边是「它是从哪扇门进容器的」
先点左边一个
42 / 124
要点

这些子类的差异集中在"元数据怎么来、父定义怎么设",最终都会被规整进 AbstractBeanDefinition 的字段上。日常开发不必记名字,但读框架源码时,看到 ScannedGenericBeanDefinition 就知道"这是扫描来的"。

43 / 124
小节
六、合并与后置处理器:定义被"改造"的时机
44 / 124

父子定义在真正实例化前会合并成一份完整的 RootBeanDefinition,这个过程叫 MergedBeanDefinition:

45 / 124
代码对照
代码xml
<bean id="baseService" abstract="true">    <property name="timeout" value="3000"/></bean><!-- child 继承 baseService:拿到 timeout=3000,同时可以覆盖 --><bean id="userService" class="com.example.UserService" parent="baseService">    <property name="timeout" value="5000"/></bean>
解读
  • 子定义只写差异;未声明的字段从父定义继承,声明了的字段覆盖父定义
  • abstract="true" 的父定义本身永远不会被实例化,它只是一份模板
  • 合并细节:class 不可被覆盖(否则报错),属性列表按名字覆盖而非"合并"
46 / 124

更强大的是:定义在实例化前还能被后置处理器批量改写。BeanFactoryPostProcessor 运行在 refresh() 的第 5 步,此时所有定义已注册、但尚无任何单例被创建——这正是"改定义的最后窗口":

47 / 124
代码对照
代码java
@Componentpublic class LazyUpgrader implements BeanFactoryPostProcessor {    @Override    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {        BeanDefinition bd = beanFactory.getBeanDefinition("userService");        bd.setLazyInit(true);          // 把某个 Bean 改成懒加载        System.out.println("已改写定义: " + bd.getBeanClassName());    }}
解读
  • ConfigurationClassPostProcessor 就是最重量级的一个:它扫描 @Configuration、处理 @Bean 与 @Import,把注解世界"翻译"成定义
  • 因为它在预实例化之前运行,所以它能决定"到底有哪些 Bean、每个 Bean 长什么样"

说明:BeanFactoryPostProcessor 改的是定义,BeanPostProcessor 改的是实例。前者在 refresh 第 5 步、对象诞生之前;后者在对象初始化前后。名字只差一个词,作用时机差一大截,是面试高频区分点。

48 / 124

这两个「只差一个词」的处理器,在时间轴上其实隔着半条命。动画里 ①→④ 那四格就是「档案还能改」的窗口,第 ④ 格一过,容器再没人去读定义;后面 ⑤ ⑥ 两格发生的都是对象层面的事:

49 / 124
原理动画
动图 · 改定义的最后窗口
动图 · 改定义的最后窗口
50 / 124
小节
七、坑:定义层面的三个陷阱
51 / 124
坑

@Bean 与 @Component 生成的定义并不等价。@Bean 方法默认是 full 模式(@Configuration 类被 CGLIB 增强,方法之间互相调用会返回同一个单例),而写在普通类里的 @Bean 是 lite 模式(不增强,方法直接当普通方法调用,可能产生多个实例)。把 @Bean 从 @Configuration 类挪到 @Component 类上,对象数量可能就从 1 变成了 2——定义没变,语义变了。

52 / 124

这条坑值得动图说明,因为「定义一模一样、结果差一个对象」是新手最难接受的一类差异。注意第 ② 格:full 模式下你调自己的 @Bean 方法会被拦下来先查池子;lite 模式没有这一拦,方法就是普通方法,调一次真 new 一次:

53 / 124
原理动画
动图 · 同一个 @Bean:full 与 lite
动图 · 同一个 @Bean:full 与 lite
54 / 124
坑

@Lazy 只改一个标志位。它并不"移动"任何代码,只是在定义上把 lazyInit 置为 true。所以:懒加载的 Bean 如果在启动时真的被别的 Bean 依赖,它照样会被创建——@Lazy 不是"永不创建",而是"没人用时才不创建"。

55 / 124
警告

父子定义里最容易被误解的规则。父定义写了 class、子定义又写了不同的 class,启动直接报 Cannot override bean class。记住:可继承的是属性 / 构造器参数 / 作用域等配置,不可覆盖的是 class 本身。

56 / 124
小节
八、上手体验:看定义如何变成 Bean
57 / 124

下面这个演示把"Beans 列表里每个定义的状态变化"可视化出来——从注册、被后置处理器改写,到最终实例化:

58 / 124
内核实验
59 / 124

不过 ioc 看的是「装配结果」。要真正看懂本篇的主线——注解是怎么变成一份定义的——得回到扫描现场。下面的实验就是这条链本身:先选「扫描与解析」看 @Component 如何被翻译成 AnnotatedGenericBeanDefinition,再选「scope / lazy 属性」看你写的注解值究竟落到哪个字段:

60 / 124
内核实验
TeaVMBeanDefinition 的诞生:从注解到档案未启动
依次切 scan → attrs → registry;最后用 multi 看同类型两个 Bean 怎么在注册表里和平共处
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
61 / 124

XML 那条路径同理,只是入口换成一个文件读取器。下面这个实验把 XML 容器的全过程摊开,重点看最后一个参数「写错 class 会怎样」——它对应第十三节速查表里的第一行:

62 / 124
内核实验
TeaVMXML 容器启动全过程未启动
按 parse → register → getbean 走一遍,再看 typo 理解「定义写得对不对,注册时就能发现」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
63 / 124

定义不是写完就定了:refresh() 第 5 步上还有一批处理器专门负责「加定义」和「改定义」。自动配置之所以能凭空造出 Bean,靠的就是在这里塞定义。先用十二步实验定位这一步:

64 / 124
内核实验
TeaVM定义在哪一步被补齐、在哪一步被冻结未启动
选「完整十二步」盯住 invokeBeanFactoryPostProcessors;再选「只盯关键四步」看 beanDefinitionMap 何时定型
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
65 / 124

条件装配(@Conditional)则决定了一份定义要不要进注册表。Boot 里成千上万个自动配置 Bean 就是这样被筛掉的:

66 / 124
内核实验
TeaVM条件不满足,定义根本不会存在未启动
切 @ConditionalOnClass / OnBean / OnProperty,看候选清单如何被逐条判掉;最后的评估报告就是 --debug 打印的那份
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
67 / 124

还有一个定义层面的特例必须在这里认识:有些定义写的类,和你 getBean 拿到的对象根本不是一个类型。FactoryBean 就是这样一份「说自己是工厂、容器却当它是产品」的定义——第二节那句「beanClass 也可能是工厂方法所在类」说的正是它。先看第一条结论:

68 / 124
内核实验
TeaVMgetBean 给你的是产品,不是工厂未启动
注意日志里打印的对象类型:定义登记的是工厂类,取出来的却是产品类
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
69 / 124

想知道这算哪个类型,容器靠的是 getObjectType()——这也是 @ConditionalOnBean、按类型注入能不能命中的依据。这一格配错,报错会写成「定义明明在,却说没有这个类型的 Bean」:

70 / 124
内核实验
TeaVM类型判断的坑:getObjectType 说了算未启动
对照 & 前缀那条:按名字取工厂和按类型取产品走的是两条判断分支
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
71 / 124
小节
8.1 把「一次取用」拆成七步单看
72 / 124

上面这些实验回答的是「定义从哪来」,而这一段回答「定义怎么变成对象」。下面这台单步调试台左边是 getBean 的真实骨架,右边同步刷新此刻的变量与调用栈——连点「下一步」,盯住第 ④ 步:档案是在那一刻被合并、然后才被消费的:

73 / 124
单步调试台
单步台一次 getBean:从翻开档案到交出对象1 / 7
连点下一步七次,盯住第 ④ 步——那里定义被合并成 RootBeanDefinition
被调试的代码
1ctx.getBean("userService"); // 你写的只有这一行
2String beanName = transformedName(name); // 别名与 & 前缀在这里被摊平
3Object cached = getSingleton(beanName); // 先问单例池:已经造好了吗
4RootBeanDefinition mbd = mergedDefinition(name); // 档案在这一刻被合并
5Object raw = createBeanInstance(mbd, args); // 反射调构造器,参数取自档案
6populateBean(raw, mbd); // 装配:把依赖填进字段/setter
7initializeBean(raw, mbd); // 回调:初始化,然后交给池子
此刻的变量
你手上: 一行调用
容器状态refresh() 已跑完
调用栈
1ApplicationContext.getBean
1学员最容易把这行读成「容器 new 了一个对象」。准确说法是:容器拿 beanName 去查那张档案表。名字是唯一的钥匙——这也是为什么本篇一直在讲定义而不是对象。
74 / 124
小节
8.2 换成命令行:查档案,而不是查对象
75 / 124

实验做到这里,可以换成自己敲命令了。这台控制台连着浏览器里那个真容器,beans 打出来的是注册表此刻的内容(名字、scope、是否懒加载),di 打出来的是装配关系。逐条敲,把本篇的结论一条条验出来:

76 / 124
内核控制台
77 / 124
提示

lab xmlbean typo 与 beans 连着敲最有收获——前者把一份写坏的定义放进注册表,后者让你看清「注册成功」和「能用」是两个时刻。第十一节沙盘里那档 sigletion 拼写错误,就是这个组合的字符串版。

78 / 124
小节
十、沙盘:scope 与 lazy 写错会怎样
79 / 124

新手对 BeanDefinition 最没感觉的地方在于:你在类上写的注解,其实只是往这张表的某个格子里填了个值。填对了省内存;填错了容器不会当场拦你——它只照着格子里的字符串办事,要等到第一次取用才炸。下面这个沙盘把「作用域 + 是否懒加载」合成一个开关,切一档立刻看后果:

80 / 124
沙盘
沙盘定义字段沙盘:scope × lazyInit
运行结果
getScope() = ""(没显式设置时的默认值),isSingleton() = true
启动阶段:preInstantiateSingletons 创建 1 个实例
连续 3 次 getBean(userService) → == 比较:true true true
内存中存活实例数:1
单例池 singletonObjects: {userService=UserService@1a2b}
默认就是单例:注意 getScope() 打回来的是空串而不是 "singleton",判断要用 isSingleton()。
81 / 124
提示

这个沙盘最值得记的是最后一档。「填错 scope」这件事在注册表层面完全合法——那只是一个字符串,没人校验它。所以看到 No Scope registered for scope name 'xxx',别去查 Bean 的代码,直接 getBeanDefinition(name).getScope() 把定义里的原值打出来,九成是个拼写错误。

82 / 124
小节
十一、随堂自测
83 / 124

热身题,考第二节的字段表和上面的沙盘:

84 / 124
随堂自测
随堂自测你在 UserService 上写了 `@Scope("sigletion")`(少打了一个 t)。应用的表现最可能是下面哪一种?
先自己选一个,选中立刻告诉你对不对
85 / 124

重头戏,把第六节与决策卡串起来:

86 / 124
随堂自测
随堂自测你想在启动阶段给所有名字以 `Report` 结尾的 Bean 统一加上懒加载标记,并把某个 Bean 的定义改成另一个实现类。正确的做法是?
先自己选一个,选中立刻告诉你对不对
87 / 124
小节
九、运行时动态注册 Bean,用哪种方式?
88 / 124
决策
决策你要做一个多租户后端,每个租户的"数据源"需要在运行时按配置动态注册进容器,且注册后必须能被其他 Bean 正常注入。选哪条路?
89 / 124
总结

容器管理的从来不是对象,而是"对象的定义"。记住三件事——BeanDefinition 是蓝图,beanClass / scope / lazyInit / dependsOn 等字段描述它;定义有 XML、注解扫描、@Bean 三种来源,最终汇入同一个注册表;定义在实例化前可被父子合并与 BeanFactoryPostProcessor 改写,而这正是自动配置与动态注册的立足点。

90 / 124
小节
十二、常见报错速查
91 / 124

这一列「报错原文」都能整段复制去搜索。它们有个共同点:都在说「这张档案表出了状况」——要么没这张卡,要么两张卡撞了名字,要么卡上填错了格子。

92 / 124
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.UserService' available注册表里根本没有这份定义:类不在扫描范围内、忘了 @Service/@Component、或它是 @Bean 方法产出但那个配置类没被加载先确认「定义存在不存在」而不是「对象能不能建」:打印 ctx.getBeanDefinitionNames() 全量名单搜一下;按类型找不到就试 getBean("userService") 按名取本篇第三节 · #5 第一个 Spring 工程
NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.Notifier' available: expected single matching bean but found 2: smsNotifier,mailNotifier(实际抛的是其子类 NoUniqueBeanDefinitionException)同类型有多个候选,注入点上又没有 @Qualifier / @Primary 来消歧义给注入点加 @Qualifier("smsNotifier"),或给首选实现加 @Primary;用 bd 实验的 multi 参数可以看清两个定义如何并存本篇第二节字段表 primary · #6 IoC 与 DI
org.springframework.beans.factory.support.BeanDefinitionOverrideException: Invalid bean definition with name 'userService' defined in URL [file:beans.xml]: There is already [Generic bean: class [com.example.UserService]] bound.同一个 beanName 被注册了两次且定义不兼容。Boot 2.1 起 spring.main.allow-bean-definition-overriding 默认为 false,直接拦下九成是 XML 与注解混用或重复 @ComponentScan。用 -Ddebug=true 看是谁注册了两次;确有需要再显式打开覆盖开关(不建议长期开)本篇第四节 · #19 条件装配
java.lang.IllegalStateException: No Scope registered for scope name 'sigletion'定义里的 scope 拼错了。这个字符串在写入时完全合法(容器不校验注解值),直到第一次取用该 Bean、要按名字找作用域时才失败报错点离病因很远,别翻业务代码:直接 ctx.getBeanDefinition("userService").getScope() 把定义里的原值打出来,九成是少写或多写了字母本篇第十节沙盘 · #9 Bean 生命周期
org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'userController' for bean class [com.bee.web.UserController] conflicts with existing, non-compatible bean definition of same name and class [com.bee.api.UserController]两个包里同名类都被扫到了,默认 beanName 都是首字母小写的简单类名,于是撞车给其中一个显式命名 @RestController("apiUserController"),或收窄 @ComponentScan 范围本篇第七节 · #17 SpringApplication
java.lang.IllegalStateException: BeanFactory not initialized or already closed - call 'refresh' before accessing beans via the ApplicationContext你在 refresh() 之前取了 Bean,或者容器已经 close() 之后还在用;也可能是自定义容器只 register 了定义却忘了调 refresh()顺序必须是「登记 → refresh() → 取用」。手写 GenericApplicationContext 时最容易漏掉那一句 ctx.refresh()本篇第四节代码 · #8 refresh() 十二步
93 / 124
提示

这六行里最值得新手反复练的是 BeanDefinitionOverrideException 与 ConflictingBeanDefinitionException 那两条——报错都很长,但真正信息量只有两处:冲突的名字和冲突的两个来源。读这种异常时用 Ctrl+F 找 defined in 和 There is already,两句话就能定位到具体文件。

94 / 124

表格第一行是这一篇最典型的「档案缺失」现场。别看结论,先在下面这段真堆栈里点出你认为是凶手的那一帧:

95 / 124
报错急救
报错急救NoSuchBeanDefinitionException

本地 mvn spring-boot:run 一切正常,一跑集成测试就报「没有这个类型的 Bean」,而这个类确实在项目里躺着。

APPLICATION FAILED TO START
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.legacy.UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:1862)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1425)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1346)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredFieldElement.java:693)
at com.example.report.ReportJob.<init>(ReportJob.java:24)
... 58 more
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
96 / 124
小节
十三、动手练习
97 / 124
小节
第一档 · 照做
98 / 124

目标:亲手往注册表里塞两份定义,一份单例一份原型,然后把「定义里的字段」和「对象的数量」这两件事同时验证出来。

99 / 124

第一步,pom.xml 只要一个依赖:

100 / 124
xml
<dependencies>    <dependency>        <groupId>org.springframework</groupId>        <artifactId>spring-context</artifactId>        <version>6.1.8</version>    </dependency></dependencies>
101 / 124

第二步,一个极简业务类(src/main/java/com/example/demo/HelloServiceImpl.java):

102 / 124
java
package com.example.demo;public class HelloServiceImpl {    private final String greeting;    public HelloServiceImpl(String greeting) {        this.greeting = greeting;        System.out.println("   构造 HelloServiceImpl@" + Integer.toHexString(hashCode()));    }    public String sayHello(String name) {        return greeting + ", " + name;    }}
103 / 124

第三步,主程序(src/main/java/com/example/demo/DefinitionLab.java):

104 / 124
java
package com.example.demo;import org.springframework.beans.factory.config.BeanDefinition;import org.springframework.beans.factory.support.BeanDefinitionBuilder;import org.springframework.beans.factory.support.RootBeanDefinition;import org.springframework.context.support.GenericApplicationContext;public class DefinitionLab {    public static void main(String[] args) {        GenericApplicationContext ctx = new GenericApplicationContext();        // ① 手写一份「单例」定义:构造器参数 Hi        RootBeanDefinition singletonBd = new RootBeanDefinition(HelloServiceImpl.class);        singletonBd.getConstructorArgumentValues().addIndexedArgumentValue(0, "Hi");        ctx.registerBeanDefinition("hiSingleton", singletonBd);        // ② 用建造者再来一份「原型」定义:构造器参数 Hello        BeanDefinition prototypeBd = BeanDefinitionBuilder                .genericBeanDefinition(HelloServiceImpl.class)                .setScope(BeanDefinition.SCOPE_PROTOTYPE)     // ← 唯一区别就在这一行                .addConstructorArgValue("Hello")                .getBeanDefinition();        ctx.registerBeanDefinition("hiPrototype", prototypeBd);        // ③ 必须先刷新:登记只是"上户口",refresh 才是"开始生产"        ctx.refresh();        // ④ 读回定义本身,证明字段真的被存进去了        BeanDefinition s = ctx.getBeanDefinition("hiSingleton");        BeanDefinition p = ctx.getBeanDefinition("hiPrototype");        System.out.println("hiSingleton  scope=[" + s.getScope() + "] isSingleton=" + s.isSingleton() + " isLazy=" + s.isLazyInit());        System.out.println("hiPrototype  scope=[" + p.getScope() + "] isSingleton=" + p.isSingleton() + " isLazy=" + p.isLazyInit());        System.out.println("注册的定义总数     = " + ctx.getBeanDefinitionCount());        // ⑤ 再看对象身份:单例恒定,原型每次都换        Object a1 = ctx.getBean("hiSingleton", HelloServiceImpl.class);        Object a2 = ctx.getBean("hiSingleton", HelloServiceImpl.class);        System.out.println("singleton == ? " + (a1 == a2));        Object b1 = ctx.getBean("hiPrototype", HelloServiceImpl.class);        Object b2 = ctx.getBean("hiPrototype", HelloServiceImpl.class);        System.out.println("prototype == ? " + (b1 == b2));        System.out.println(((HelloServiceImpl) a1).sayHello("Manual"));        ctx.close();    }}
105 / 124

运行 main,预期输出(@xxxxxx 是哈希地址,你的机器会不同;singleton 只应打印一行构造日志,prototype 的两行地址必须互不相同):

106 / 124
text
   构造 HelloServiceImpl@5f2108b5          ← 这一行夹在 refresh() 内部:单例已被预实例化hiSingleton  scope=[] isSingleton=true isLazy=falsehiPrototype  scope=[prototype] isSingleton=false isLazy=false注册的定义总数     = 2singleton == ? true                         ← 两次取用都命中缓存,没有新的构造日志   构造 HelloServiceImpl@71bbf57e   构造 HelloServiceImpl@7f13d6eprototype == ? false                        ← 每取一次多打一行构造日志   (删掉 setScope 那一行后重跑:两行构造日志消失,这里变成 true)Hi, Manual
107 / 124

说明三点:① 第一行构造日志出现在 refresh() 内部而不是任何 getBean 之后,正好印证「登记只是上户口,refresh 才开始生产」;② scope=[] 是空串——默认单例并不会把字符串 "singleton" 写进定义,只有你显式设置过才有值,所以判断作用域要用 isSingleton() 而不是比字符串;③ 这个容器里定义总数就是 2,GenericApplicationContext 不会自动塞内建定义,这与 AnnotationConfigApplicationContext 一刷新就冒出十几个内建 BeanName 的情况不同。

108 / 124

验收清单:① 把 .setScope(SCOPE_PROTOTYPE) 那一行删掉再跑,观察 prototype == ? 变成 true——你就手动改了一次「定义」并立刻看到行为变化;② 说出 hiSingleton 是在哪一步被创建的(答案见第四节与上一篇的第 11 步);③ 故意把一个 beanName 注册两次且 class 不同,记下你看到的完整异常原文。

109 / 124
小节
第二档 · 变体
110 / 124

每条只改一处,先猜结论再跑:

111 / 124
  1. 把 singletonBd.setLazyInit(true) 加上。你会观察到:hiSingleton 的构造日志从 refresh() 期间挪到了第一次 getBean 那一刻——@Lazy / lazyInit 只改时机,不改作用域,它仍是单例。
  2. 在 ctx.refresh() 之后调用 ctx.registerBeanDefinition("late", bd)。你会观察到:不再抛出「已激活」的 IllegalStateException,但这个定义不会被预实例化,只能靠手动 getBean 取到——这就是第四节的坑为什么强调「要动态加 Bean 请用 BeanDefinitionRegistryPostProcessor」。
  3. 再加一份 hiPrototype2,与 hiPrototype 同为 HelloServiceImpl 类型但 beanName 不同,然后调用 ctx.getBean(HelloServiceImpl.class)。你会观察到:NoUniqueBeanDefinitionException: ... expected single matching bean but found 3,对应速查表第二行;给其中一份加 singletonBd.setPrimary(true) 后立刻恢复。
  4. 用 ctx.getBeanFactory().getBeanDefinition("hiSingleton") 拿到定义,读它的 getResourceDescription()。你会观察到:手写的定义这里是 null,而 XML 或扫描来的定义会带文件名/类路径——排「谁注册了我」时最好用的一招。
112 / 124

提示:第 3 条做完再回去跑第八节的 bd 实验(multi 参数),框架视角和你手工复现的现象应当严丝合缝。

113 / 124
小节
第三档 · 造一个
114 / 124

做一个「迷你注册表可视化工具」DefinitionDumper,让任何项目的定义一览可查——这也是你自己第一篇「框架级」工具。

115 / 124

需求:

116 / 124
  • 提供一个静态方法 dump(ConfigurableApplicationContext ctx),遍历 getBeanDefinitionNames(),逐条打印:beanName | scope | lazyInit | primary | beanClassName | 来源描述(resourceDescription)
  • 统计并打印摘要:定义总数、单例数 / 原型数 / 其他作用域数、被标记懒加载的数量、有父定义(getParentName() != null)的数量
  • 支持一个过滤参数:只列出类名包含给定关键字的定义(例如 dump(ctx, "Tx"))
  • 额外挑战:识别出「同名但来源不同」的可疑定义并单独告警(比较 resourceDescription),这就是 BeanDefinitionOverride 的前置排查
117 / 124

验收清单:① 在一个空 Spring Boot 工程里跑 dump(ctx),能打出上千条且包含自动配置类产生的定义;② 加 --filter=DataSource 后只剩个位数条目;③ 人为制造一次同名冲突,工具能在启动失败前给出告警;④ 全程没有反射之外的私有 API 依赖(用 ConfigurableListableBeanFactory 的公开方法即可)。

118 / 124
小节
十四、要点自查
119 / 124
自检

不看上文,用一句话说清 BeanDefinition 和 Bean 的区别,并说明容器在哪一刻才真正 new 出对象。

120 / 124
自检

XML、注解扫描、@Bean 三种来源分别由哪个类读取?它们在流程上从哪一步开始合流?

121 / 124
自检

想批量修改「有哪些 Bean」该用哪个接口、在 refresh() 第几步?想修改「某个对象长什么样」又该用什么?

122 / 124
自检

scope 写成拼错的字符串为什么不报错?此时你应该用哪一行代码把定义里的原值打出来?

123 / 124
自检

registerBeanDefinition 为什么必须在 refresh() 之前?如果一定要在运行期动态加 Bean,正确姿势是什么?

124 / 124
口诀

容器认卡不认菜——beanName 是抽屉,BeanDefinition 是卡;改卡找 BFPP(第 5 步),改菜找 BPP(出生以后),卡撞名字就报 Override。