☰
Spring Bean注册三方式:XML、注解扫描与JavaConfig底层原理
2026/10/9 6:40:53 网站建设 项目流程

很多搞 Java 的同行,一听到"Spring 把 Bean 交给你管理"这句话,脑子里蹦出来的只有 @Service 和 @Autowired。等真去排查问题,扫描路径写错、Bean 被重复定义、@Bean 方法每次返回新对象……这些九九归一,最后都会落到同一个问题上:你到底是怎么把一个 bean 对象交给 Spring 容器管理的。这篇文章我就把这三种主流方式掰开揉碎讲一遍:XML 声明式配置、注解加组件扫描、JavaConfig 的 @Bean 方法注册,配上能直接跑起来的最小示例,再往下挖一层看 Spring 底层是怎么处理这三类注册的,最后把实际项目里高频踩坑的问题整理成一份自查清单。不管你是刚学 Spring 的入门者,还是写业务代码三年五年没系统捋过这条线的人,这篇文章都值得你花十分钟看完。

另外先把一个概念说清楚:字面上是"把一个对象交给容器",但在 Spring 的设计里,我们交给容器的其实是 Bean 的定义(BeanDefinition),容器再根据这个定义去创建对象。如果你手里已经有一个 new 出来的对象想塞进容器,那是registerSingleton的玩法,不在今天讨论的范畴。面试官问这个问题时,想听的也是这三种"定义 Bean"的方式。

1. 三种方式拆开前,先想明白"交给容器管理"到底做了什么

1.1 管理的前后两个阶段:定义注册与实例化

容器管理一个 Bean,不是一个瞬间动作,而是一条流水线。前半段是"收集配置信息",Spring 把你在 XML 里的<bean>、类上的@Component、配置类里的@Bean方法,统统整理成一个个叫BeanDefinition的对象,里面有类的全限定名、作用域、是否懒加载、依赖关系等元数据;后半段才是根据 BeanDefinition 去实例化、填充属性、执行初始化回调,最后放进容器的单例池。

理解这个区分很重要:三种方式本质上只是"生产 BeanDefinition"时走了不同的读取器(Reader)和扫描器(Scanner)。一旦 BeanDefinition 注册进了容器,后续的实例化逻辑对三种方式来说完全一样。这也是为什么三种方式可以在同一个项目里混用而没有违和感。

1.2 三条路线的演进逻辑与定位差异

Spring 刚出来那几年只有 XML 一条路,后来工程规模变大,XML 越来越难维护,Spring 2.x 引入了注解支持,大家开始用 @Autowired 省掉一堆 setter 配置;到 Spring 3.0 推出 JavaConfig,才算是把"用 Java 代码写配置"变成了一等公民。现在主流项目基本都是"注解扫描 + @Bean"两条腿走路,XML 只存在于老系统或者需要动态配置的场景。

三条路线各有自己的适用位置:XML 适合纯粹的配置声明、依赖关系完全静态可视化;注解扫描适合自己业务代码里的 Bean,开发效率最高;@Bean 适合把第三方类、需要手动组装逻辑的 Bean 注册进容器,比如 RedisTemplate、各种 Client。选型不是看哪个时髦,而是看"这个 Bean 谁最了解它的构造过程"。

2. 方式一:XML 配置,Spring 最原始的家法

2.1 一段最小可运行的 XML 注册示例

我以一个最简单的用户服务为例。先有一个 UserDao:

package com.example.demo.dao; public class UserDao { public void listUsers() { System.out.println("query user list from db"); } }

再有一个 UserService,注意它依赖了 UserDao,并且通过 setter 方法接收注入:

package com.example.demo.service; import com.example.demo.dao.UserDao; public class UserService { private UserDao userDao; public void setUserDao(UserDao userDao) { this.userDao = userDao; } public void printUsers() { userDao.listUsers(); } }

接着在 classpath 下建一个 beans.xml:

<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="userDao" class="com.example.demo.dao.UserDao"/> <bean id="userService" class="com.example.demo.service.UserService"> <property name="userDao" ref="userDao"/> </bean> </beans>

入口加载 XML:

ApplicationContext context = new ClassPathXmlApplicationContext("beans.xml"); UserService userService = context.getBean("userService", UserService.class); userService.printUsers();

这里最难记的是<property>的name要和 setter 方法对应:name 写userDao,Spring 就会去找setUserDao方法。过去很多人把 name 当成属性名去类里找不到,报异常一脸蒙,其实就是这个细节。

2.2 XML 里的几个常用配置项与选择理由

XML 注册 Bean 时,经常用到的属性不只是 class 和 id。我把常见的配置项列一下,面试时被问"XML 怎么定义一个 Bean",基本就是这几个:

  • id:Bean 在容器中的唯一名称,不写也没关系,Spring 会帮你生成一个全限定类名加编号的默认名。
  • class:类的全限定名,必须能通过反射加载。
  • scope:默认 singleton,改成 prototype 后每次 getBean 都会得到新实例。
  • lazy-init:是否为懒加载,true 时只有第一次被使用才初始化。
  • init-method/destroy-method:自定义初始化和销毁方法,不需要实现 Spring 接口,只需要类里有对应方法。
  • <constructor-arg>:用构造器方式注入依赖,很多不可变对象推荐这种方式。
  • <property>:setter 方式注入依赖。

举个例子,用构造器注入改造上面的配置:

<bean id="userService" class="com.example.demo.service.UserService"> <constructor-arg ref="userDao"/> </bean>

UserService 里去掉 setter,加一个构造器:

public UserService(UserDao userDao) { this.userDao = userDao; }

这个细节我在实际项目中踩过坑:某个依赖需要用构造器注入保证对象在创建后状态完整,结果同事图省事只用<property>,结果对象被 set 前就被其他地方拿到了,出现 NPE。所以能用构造器注入的场景,别犹豫。

2.3 XML 方式的底层解析逻辑

XML 走的是XmlBeanDefinitionReader,它会把<bean>节点逐个解析成GenericBeanDefinition,再注册到容器里。整个过程是独立于框架启动入口的,无论用ClassPathXmlApplicationContext还是FileSystemXmlApplicationContext,只要把 XML 资源喂给 reader,容器就能拿到对应的 BeanDefinition。

这个方式现在看起来繁琐,但有个独特优势:配置集中在外部文件里,不做代码改动也能调整 Bean 的依赖关系,适合某些系统上线后运营人员微调参数。但缺点也很明显——类改名、包重构后,XML 里的全限定名经常断了,项目启动才炸。

3. 方式二:注解加组件扫描,自动化开发的主力军

3.1 @Component 家族与一个完整示例

现在业务代码里注册一个 Bean,通常就是给类加一个注解。Spring 2.5 引入组件扫描,3.0 后成为主流。注解家族包括:

  • @Component:通用 Bean 标记。
  • @Service:业务层专用,语义更明确。
  • @Repository:数据访问层专用,还会被翻译成持久层异常。
  • @Controller/@RestController:Web 层专用。

它们在注册机制上没有任何逻辑差异,只是语义写给人看。我见过有人把@Service加到工具类上,功能没错,但代码可读性很差,团队规范里不建议这么干。

完整示例:

package com.example.demo.dao; import org.springframework.stereotype.Repository; @Repository public class UserDao { // 数据访问逻辑 }
package com.example.demo.service; import org.springframework.stereotype.Service; @Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } }

开启扫描有两种常见姿势。一是写一个配置类:

package com.example.demo.config; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @Configuration @ComponentScan(basePackages = "com.example.demo") public class AppConfig { }

二是启动类上直接写:

package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }

@SpringBootApplication里面已经包含了@ComponentScan,默认扫启动类所在包及其子包。下面这段原理一定要记住:只扫描启动类所在包。把 Controller 或 Service 放在包外,必然扫描不到。

3.2 扫描的底层动作:ClassPathBeanDefinitionScanner 在找什么

组件扫描不是靠反射把所有类都加载一遍,Spring 做了一件很优化的事情:ClassPathBeanDefinitionScanner遍历指定包路径下的.class文件,用 ASM 读取类的元数据,判断类上有没有@Component及其派生注解,有的话就组装成ScannedGenericBeanDefinition注册进容器。

这意味着什么?扫描阶段不会触发类的静态块、不会真正加载类,所以这个过程很快。也正因为读的是字节码,类上的注解得有@Component的派生关系才行。你手动用new创建的类、第三方 jar 里的类,如果没被@Component标记,扫描器根本不会理它,这也是 JavaConfig 的 @Bean 方法存在的意义之一。

3.3 注解方式的开销与工程约束

虽然扫描阶段快,但组件扫描也不是完全没有代价:容器启动时要遍历 classpath 目录,包层级越深、类越多,启动时间就越长。大项目里建议把 basePackages 写得精确一点,不要图省事扫到根包、把无关模块全部拉进来。

另外还有个容易踩的坑:配置多个@Configuration类各自写@ComponentScan,扫描范围重叠时,同名 Bean 会触发覆盖或冲突。排查时先看启动日志里有没有BeanDefinitionOverrideException,再看各模块的扫描路径是不是有交集。

4. 方式三:JavaConfig 的 @Bean,半程序化的注册方式

4.1 一个配置类里的 @Bean 注册示例

@Bean 注解标记在方法上,Spring 容器启动时会调用这个方法,把返回值作为 Bean 注册进去。最简单的用法:

package com.example.demo.config; import com.example.demo.dao.UserDao; import com.example.demo.service.UserService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class AppConfig { @Bean public UserDao userDao() { return new UserDao(); } @Bean public UserService userService() { return new UserService(userDao()); } @Bean(initMethod = "init", destroyMethod = "destroy") public SomeWorker someWorker() { return new SomeWorker(); } }

4.2 @Bean 方法之间的依赖调用,为什么可以放心直接 new

在上面的例子里,userService()方法内部直接调用了userDao()。如果是普通 Java 配置类,这样写每次调用都会产生一个新的 UserDao 实例,但 Spring 容器注册的结果并不是这样:只要类上标着@Configuration,Spring 会对配置类做 CGLIB 代理,所有 @Bean 方法调用都会先查容器里有没有现成的 Bean,有就直接返回容器中的单例,没有才方法真实调用。这个机制叫"Bean 方法的单例语义"。

正因如此,在 @Configuration 类里,你可以放心让不同 @Bean 方法互相调用。但有个特例要注意:如果类上没有@Configuration,只有@Component,或者你用的是注解配置类但没被代理,@Bean 方法会退化成"lite mode",每次调用都执行真实方法、创建新对象。这在 Spring 5.x 之前的版本里坑过不少人:明明加了 @Bean,结果 @Service 里的依赖引用对象反复变,排查半天才发现是配置类没标 @Configuration。

4.3 @Bean 方法接收参数,更灵活的注入方式

除了方法内手动调用其他 @Bean 方法,@Bean 本身也可以有参数。容器会把符合条件的 Bean 按类型注入到方法参数里:

@Configuration public class AppConfig { @Bean public UserService userService(UserDao userDao) { return new UserService(userDao); } }

这种写法比方法内部直接调userDao()更明确,也适合处理复杂的依赖链。很多时候 @Bean 方法的参数本身就是外部库里不好扫描的类,比如:

@Bean public RestTemplate restTemplate() { return new RestTemplate(); } @Bean public UserService userService(RestTemplate restTemplate) { return new UserService(restTemplate); }

4.4 @Bean 的真正优势场景:第三方类组装

注解扫描再方便,也没法给 RedisConnectionFactory、MongoClient、RestTemplate 这类第三方类打上 @Component。它们的构造过程需要配置连接参数、设置线程池、注册拦截器等,这种"半程序化手工组装"的场景正是 @Bean 发挥作用的地方。

我真实项目里最常用的一个模板是封装一个带超时和重试的 RestTemplate:

@Bean public RestTemplate restTemplate() { HttpClient httpClient = HttpClientBuilder.create() .setMaxConnTotal(100) .setMaxConnPerRoute(20) .setConnectionTimeToLive(30, TimeUnit.SECONDS) .build(); RequestConfig requestConfig = RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(5000) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); }

这些逻辑如果写到 XML 里,表达能力非常有限;写到配置类里就是普通 Java 代码,想怎么组装怎么组装。这也是为什么现在的 Spring Boot 项目里,几乎看不到大段的 XML 配置,但每个项目都会有几个 @Configuration 类。

5. 三种方式深层对比,以及它们共同的底层出口

5.1 一张表格看清三者的差异

对比维度XML 配置注解 + 组件扫描JavaConfig @Bean
配置载体外部 XML 文件类上的注解Java 方法
可读性依赖关系集中,但配置多时冗长就近查看类,业务代码内聚配置逻辑可写任意代码,可读性最高
动态逻辑几乎不支持不支持支持条件判断、循环、工厂方法
第三方类注册可以不能直接扫描最推荐
重命名类影响class 字符串写错则启动失败重命名类不影响扫描方法内部返回类型变化时需改方法签名
适合场景老项目维护、外部配置化自己业务代码的通用 Bean组装第三方组件、复杂初始化
底层处理入口XmlBeanDefinitionReaderClassPathBeanDefinitionScannerConfigurationClassPostProcessor

5.2 所有方式最终汇聚到同一个 BeanDefinitionMap

刚才反复强调过:三种方式注册的产物都是 BeanDefinition。真正存放 BeanDefinition 的地方是DefaultListableBeanFactory里的beanDefinitionMap,一个ConcurrentHashMap<String, BeanDefinition>。

容器启动后的大致流程可以压缩成四步:

  1. 解析配置:XML 被 reader 解析、扫描器收集 @Component、配置类处理器解析 @Bean 方法,最终都是生成一个个 BeanDefinition。
  2. 注册 BeanDefinition:以 beanName 为 key,放进 beanDefinitionMap。
  3. 实例化:根据 BeanDefinition 的 class 信息反射创建对象,处理依赖注入。
  4. 初始化:执行各种 BeanPostProcessor、初始化回调,最后放入单例池。

理解了这层,你就明白为什么三种方式可以无缝共存:它们只是给容器投递工作单的方式不同,容器拿到的工作单格式一样。

5.3 一类常被忽略的扩展方式:FactoryBean 与 @Conditional

除了正文里的三种基础方式,Spring 生态里还有两个跟"注册 Bean"紧密相关的机制值得知道。一个是FactoryBean,它本身是一个工厂Bean,容器在解析时会向容器注册它 getObject() 返回的对象;另一个是@Conditional(以及 Spring Boot 里的各种@ConditionalOnXxx注解),它让 BeanDefinition 在注册阶段就能做一些判断。

这两个不算是"把一个 bean 对象交给容器的基本方式",但在源码阅读和自动配置类里非常常见。我和很多同事的体会是,先吃透 XML、注解、@Bean 三条主线,再去看 Spring Boot 的 AutoConfiguration 源码,思路会顺很多,不至于被 @Conditional 和 FactoryBean 绕晕。

6. 高频踩坑问题与排查实录

6.1 扫描不到 Bean:先按顺序排查这四件事

这是出现频率最高的问题,没有之一。明明写了 @Service,运行时 getBean 却报 NoSuchBeanDefinitionException。我会按这个顺序排查:

  • 类上有没有加注解:偶尔漏写,或者写成了自定义注解但没被 Spring 识别。
  • 扫描路径对不对:@ComponentScan的 basePackages 是否包含了类所在包。Spring Boot 里就是启动类所在包及子包。
  • 依赖模块是否被加载:多模块工程里,业务类在另一个 maven 模块,但该模块没有进入当前应用的类路径,自然扫不到。
  • 有没有被 IDE 缓存坑:改完包名或注解后没有重新编译,target 目录里还是旧的 class 文件,清一下 target 再启动。

6.2 Bean 重复定义:覆盖与冲突的两种现场

Bean 重复定义有两种表现。一是同一个名字被注册了两次,Spring Boot 2.1 以后默认会直接抛BeanDefinitionOverrideException;二是不同名字但类型相同,注入时容器不知道该用哪个,报NoUniqueBeanDefinitionException。

前一种常见于 XML 和 JavaConfig 混用、或者多个配置类都声明了同名 @Bean。解决思路是统一命名规范,或者给其中一个加@Primary。后一种则更值得注意:两个不同类都实现了同一个接口,比如有两个UserDao实现类,你注入UserDao时容器分不清。推荐用@Qualifier("beanName")精确指定,或者在业务代码入口做抽象选择。

6.3 @Configuration 与 @Bean 组合时的三个隐藏坑

先看第一个坑:@Configuration(proxyBeanMethods = false)。这个配置项在 Spring Boot 项目里经常出现,目的是减少配置类的代理开销。但代价是 @Bean 方法之间的调用不再保证单例语义,调用userDao()会走真实方法。如果你在 @Bean 方法里直接调用了另一个 @Bean 方法,并且期望拿到容器里的同一个实例,这个开关要谨慎使用。

第二个坑:@Bean 方法不能是 final 方法。因为配置类需要被 CGLIB 代理,final 方法无法被覆写增强,启动的时候会报错。这个问题在低版本 Spring 里直接启动失败,新版会有更明确的错误提示。

第三个坑:@Bean 方法的返回类型别写成实现类。比如:

@Bean public UserDao userDao() { return new UserDaoImpl(); }

如果调用方注入的是接口UserDao,没问题;但如果后续你想替换实现类,改方法内部实现即可。最麻烦的是有人把方法返回类型写成具体子类,然后别的地方按父类注入,容易产生预期外的类型不匹配。

6.4 循环依赖:为什么三级缓存只救得了部分场景

顺带聊一下热搜里常见的"spring三级缓存原理"。在实际使用注解方式时,Bean 之间偶尔会出现循环依赖,比如 A 依赖 B、B 依赖 A。Spring 对单例非构造器注入的循环依赖,靠的是三级缓存机制:实例化 A 后先把"早期引用"暴露在第三级缓存中,再尝试注入 B,B 的回填需要 A 时就能从缓存里拿到未最终初始化的 A。

但有两个限制必须知道:构造器注入的循环依赖 Spring 处理不了,会直接报错;prototype 作用域的循环依赖也处理不了。所以团队规范里我一直建议:优先用构造器注入,遇到循环依赖先想到"是不是设计有问题",而不是依赖三级缓存兜底。

常见问题典型报错/现象处理思路
扫描范围错误Bean 找不到检查 @ComponentScan / 启动类所在包
同名 Bean 覆盖BeanDefinitionOverrideException统一命名,关闭覆盖或避免重名
同一个类型多个 BeanNoUniqueBeanDefinitionException@Qualifier 指定或 @Primary
@Bean 方法被重复调用每次 getBean 返回新对象确认配置类用的是 @Configuration 而非 @Component
final 方法上了 @Bean启动代理失败去掉 final 修饰符
构造器循环依赖BeanCurrentlyInCreationException改字段注入或用 @Lazy 打破闭环

最后分享一点自己的体会:框架层面看,注解和 @Bean 是今天的绝对主流,XML 更多是为了维护老系统,但三种方式背后的 BeanDefinition 这条主线始终不变。真到排查问题的时候,能快速区分"当前 Bean 是被哪个入口注册进来的",思路会比瞎翻日志快得多。平时写代码,我也不会为了炫技去混用配置方式:一个项目里配置方式越少,心智负担越小,这比任何技巧都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询