☰
Spring Bean注册三种方式:XML、注解与JavaConfig全解析
2026/10/1 18:19:57 网站建设 项目流程

面试的时候有个出现频率特别高的基础题:Spring中如何把一个bean对象交给Spring容器管理。说实话,这道题算得上“送分题”,但能清清楚楚答到位的人不多,很多人张口就是“加个@Component”,然后就没有然后了。更麻烦的是,不少人把“bean的注册方式”和“依赖注入的方式”搅在一起,面试官一问“还有呢”,就卡住了。

这篇文章我把三种方式从头到尾捋一遍:XML配置、注解声明、JavaConfig的@Bean,外加每种方式背后的设计逻辑和实际工程里会遇到的坑。不管你是刚开始学Spring,还是被循环依赖折磨过几次的老手,读完应该都能把这条线理清楚。

1. 为什么要把bean交给Spring容器管理

1.1 控制反转解决的是什么问题

先回到最原始的写法。假设你要写一个订单服务,它依赖库存服务和支付服务。没有Spring的时候,代码大概是这样的:

public class OrderService { private InventoryService inventoryService = new InventoryService(); private PayService payService = new PayService(); }

看起来没啥问题,但项目一旦大起来,这种写法就是灾难的起点。库存服务内部可能又依赖数据库连接池、消息队列客户端、缓存客户端,你得一层一层手动new,任何一个依赖变了,所有依赖它的地方都要跟着改。更难办的是写单元测试的时候,你根本没法替换这些内部实现,因为对象已经在你代码里写死了。

Spring容器要解决的就是这个事,它把对象的创建和装配过程从你的业务代码里抽走,统一交给容器来做。你只需要告诉容器“我要一个订单服务,它需要库存服务和支付服务”,容器就会自动把这三者组装好,大大降低耦合度,让服务可以互相独立替换和测试。

这个概念就是控制反转(IoC)的核心思路,对象不再由自己控制依赖,而是把控制权交给容器,反向获取所需依赖,所以叫反转。而所谓的依赖注入(DI),就是容器在创建bean时把依赖“塞”进对象里,这正是IoC的一种具体实现方式。

有人问,那“把bean交给Spring容器管理”到底是什么意思?说穿了就是:告诉容器某个类需要由它来创建和组装,而不是在应用代码里自己new。而“告诉”的方式,就是本文要说的三种注册方式。

1.2 bean在容器里的生命周期和默认行为

一旦一个类被注册成bean,容器会按照一套固定的流程来处理它:实例化对象,填充属性,执行初始化回调,放入缓存,应用关闭时执行销毁回调。整个过程开发者不用操心,容器帮你兜底。

这里有几个默认行为值得记住。默认情况下,容器中的bean都是单例,也就是说同一个bean定义只会创建一次实例,后续注入的都是同一个对象。大多数场景下这样做既节省内存又保证状态一致。

另外bean的创建时机也很有意思。默认的单例bean在容器启动时就“迫不及待”地创建了,不是等到被使用的时候才创建。这解释了很多新手遇到的怪现象:项目一启动,日志里全是bean的构造信息,明明还没有任何接口请求进来。

再补充一个常被问到的点:容器的名字规则。如果是XML方式注册的bean,我们可以给它指定id;如果用注解或JavaConfig注册,默认的bean名通常是类名的首字母小写形式,比如UserService这个类默认就叫userService。bean实例化的底层策略大致有构造器实例化、静态工厂方法和实例工厂方法这三种,普通开发时你接触最多的是构造器实例化,剩下的两种在特殊扩展场景才会用到。

2. 方式一:XML配置声明bean——最老牌也最能看清原理

2.1 最小可用的XML bean配置长什么样

现在纯用XML的Spring项目确实不多了,但老系统里存量很大,而且理解了XML方式的底层逻辑,后面理解注解方式就是一通百通。

先看一段最经典的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 http://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>

第一行的beans标签就是容器配置文件的根节点,里面每一个bean标签就是一条注册声明。id是对象的唯一标识,class是类全路径,Spring在启动时通过反射加载这个类,再按配置给它装配依赖。

property标签是设置属性用的,name对应UserService里的userDao字段名,ref表示引用另一个bean。这里要注意,Spring做的是先通过无参构造器创建对象,然后调用setter方法把依赖注入进去。如果你的类没有无参构造器,又用property方式传入依赖,启动时会直接报错。还有一种注入写法叫constructor-arg,走的是构造器参数注入:

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

value用于注入基本类型和字符串,ref用于注入bean引用,这个区别要记住。

除了注入,XML里还能写初始化方法和销毁方法,比如在bean标签上配置init-method和destroy-method指向类中的实例方法,容器会在完成属性注入后调用init方法,在容器关闭前调用destroy方法。用起来很简单,但看源码的时候会发现Spring在背后做了一大堆判断和回调。

应用的启动代码也很直观,在Spring 5.x时代用ClassPathXmlApplicationContext加载配置:

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

这里可以清晰地看到容器扮演的角色——你根本没有new过UserService,它已经作为bean躺在容器缓存里了,你只是把它取出来而已。

2.2 XML方式适用的场景与注意点

XML方式看起来麻烦,但有它不可替代的价值。第一类是纯第三方类,比如你引入了一个开源SDK,它的类本身没有加任何Spring注解,你又没办法改它的源码,这时候用XML的bean标签注册最直接。第二类是历史遗留的老项目,整套系统都在用XML管理依赖,你上去就推注解迁移,成本高风险大,不划算。

XML配置也有几个明显缺点,新手要提前有心理准备。第一个是类型不安全,配置里全是字符串,id写错了、class类路径写错了,只能在容器启动报错的时候才能发现。第二个是配置文件的体量会越来越大,一个大型项目里动辄几千行XML,想找一个bean定义就得翻半天。

多环境配置方面XML有一个挺方便的能力——profile。比如开发环境和生产环境要用不同的数据源,可以在XML里这样写:

<beans profile="dev"> <bean id="dataSource" class="..."/> </beans> <beans profile="prod"> <bean id="dataSource" class="..."/> </beans>

启动时通过spring.profiles.active指定当前激活的环境。这个能力在注解时代也有对应方案,但XML方式确实算最早被广泛使用的多环境处理手法。

如果是在新项目里,我个人建议不要主动选择XML来注册bean,除非有明确的不可改源码的场景。它适合理解原理,不适合拿来当主力开发方式。

3. 方式二:注解方式——如今最主流的选择

3.1 组件标注三步走

注解方式能成为现在Spring Boot项目的默认标配,核心原因就是方便。它把注册信息写在类本身上,编译器能帮你检查,重构类名的时候注解不用单独改,比XML那种“类定义和配置分离”的方式省心得多。

注解方式的第一步是添加组件扫描的开关。在Spring Boot项目里,你的启动类上通常有@SpringBootApplication,这个注解组合了@ComponentScan,会自动扫描启动类所在包及其子包下所有标注了注解的类。如果是传统的Spring项目,需要在XML里加上:

<context:component-scan base-package="com.example.demo"/>

这一步必须要有,否则写了注解也等于白写,容器根本看不到你的类。

第二步就是给类打上“交给容器”的标记。Spring提供了三个从属关系的注解:@Component是通用组件,所有类都可以用它;@Service用于业务层;@Repository用于数据访问层;@Controller用于Web控制层。后面三个注解在功能上都是@Component的扩展,但按照代码规范,业务模块的类应该用语义最匹配的注解,让代码读到注解就知道它在哪一层。

第三步就是正常写类了:

@Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } }

类上加@Service,容器扫描到这个类之后就会自动注册成bean,bean默认名是类名首字母小写userService。这就算把bean交给容器了。

3.2 依赖注入怎么配

注册完之后,类与类之间的依赖关系也要靠容器来维护。Spring里最常用的注入注解是@Autowired。它可以加在三个位置:字段上、setter方法上、构造器上。

字段注入的写法最简洁:

@Service public class UserService { @Autowired private UserDao userDao; }

但这种方式有一个问题:字段是private的,单元测试时想手动替换UserDao很麻烦,而且依赖关系被隐藏了,不够直观。构造器注入现在是社区推荐的写法,好处是依赖以参数形式明确暴露出来,类一旦创建,所有依赖就已经就位,不容易出现半初始化的状态。

字段注入和setter注入还有一个隐患,就是对象在构造完成后依赖才被塞进去,如果构造器里就用到依赖,就会拿到null。构造器注入天然规避了这个风险。所以Spring团队的官方文档也很明确地推荐构造器注入。

当同一个类型在容器里有多个bean时,@Autowired会迷茫。比如你有两个实现类都实现了UserDao接口:

@Component public class JdbcUserDao implements UserDao {} @Component public class RedisUserDao implements UserDao {}

这时候注入UserDao会报NoUniqueBeanDefinitionException。解决方式是在注入点用@Qualifier指明bean名字:

@Service public class UserService { private final UserDao userDao; public UserService(@Qualifier("redisUserDao") UserDao userDao) { this.userDao = userDao; } }

也可以用JSR-250标准注解@Resource来代替@Autowired,它的查找逻辑是:先按字段名找同名bean,找不到再按类型找。这个细节在面试里经常被拿来考察。

3.3 注解方式容易踩的三个坑

第一个坑是扫描包路径不对。组件扫描的base-package写得过大,应用启动会变慢;写得太小,注解类扫不到直接报找不到bean。遇到过最典型的情况是:把启动类放在com.example.demo包下,业务代码放在com.example.business包下,结果启动时各种NoSuchBeanDefinitionException,排查了半天才发现是包没扫到。

第二个坑是依赖注入到了非Spring管理的对象里。比如在工具类或者通过new创建的对象里用@Autowired,容器根本不会管你,因为Spring管理的对象范围只限于容器创建的bean,你自己new出来的对象如果用了@Autowired,字段就是null。这种情况应该配合ApplicationContext在容器里查找需要的bean,或者把对象也注册成bean。

第三个坑和配置有关。如果你在XML里配置了注解扫描,又同时在类上加@Component,会出现重复的情况吗?表面上不会报错,因为Spring处理bean定义的合并逻辑比较复杂,默认情况下后加载的定义会覆盖先加载的,但如果你开启allowBeanDefinitionOverriding=false,就会触发冲突异常。老项目和Spring Boot基础配置混在一起时特别容易遇到。

4. 方式三:JavaConfig方式——显式声明和类型安全的平衡点

4.1 一个典型的@Configuration加@Bean配置类

第三种方式用纯Java代码完成bean注册,没XML,也不靠扫描,而是专门写一个配置类,在里面用方法声明bean。

先看代码:

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

一个Java类标上@Configuration,它就成了配置类,容器扫描到或者显式加载它的时候,会把类中所有带@Bean的方法都找出来,每个方法的返回值就是一个bean,bean名称默认是方法名。

这段代码传递了两个信息。第一,@Bean方法本质上就是一个对象工厂,你想让容器管理什么对象,就在方法里把它创建出来。好处是对象创建逻辑可以很复杂,比如模拟一个带参数的对象、从配置文件中读值再拼接对象,这些用注解在类级别上很难完整表达,但在@Bean方法里就是普通Java代码。

第二,方法里调用另一个@Bean方法是有讲究的。注意userService()方法里调用了userDao()方法,看起来像是new了两个UserDao,但因为AppConfig被@Configuration修饰后,Spring会通过CGLIB代理增强这个类,保证同一个配置类中多次调用同一个@Bean方法返回的都是同一个bean实例。这就是很多源码解析里说的full模式代理机制。这也是为什么@Configuration类不能被声明为final,final类无法被CGlib子类化,代理会失效,bean的单例性就会出问题。

加载这个配置类的方式也很灵活。传统Spring里用AnnotationConfigApplicationContext:

ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); UserService userService = context.getBean("userService", UserService.class);

Spring Boot里,只要这个配置类在启动类的扫描路径下,它会被自动发现。

4.2 JavaConfig到底比另外两种方式强在哪

JavaConfig的核心优势是类型安全。XML方式里class属性和ref指向全靠字符串,写错了编译期不会提醒;JavaConfig方法返回值是真实类型,IDE能立刻帮你检查,重构类名时配置会跟着变,这是极大的维护优势。相比注解方式,JavaConfig又显得更“显式”,看到@Bean方法就明确知道容器里注册了什么,不像组件扫描那样把类翻一遍。

JavaConfig还有一个注解方式很难替代的场景:注册第三方类。一个外部jar包里的类,你不能给它加@Component,但你可以在这个配置类里这样写:

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

对于这类“不是自己的类”,@Bean是首选方案。很多Spring Boot的自动配置类内部,就是大量使用@Bean方法把默认组件注册进容器的,比如DataSourceAutoConfiguration里的数据源定义。

复杂对象也可以通过方法参数完成装配:

@Bean public DataSource dataSource() { return DataSourceBuilder.create().build(); } @Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); }

jdbcTemplate()的参数DataSource会被Spring自动注入,你不需要在方法里手动调用dataSource(),容器会自动传递。这种方式比在方法内手动调用其他@Bean方法更优雅,适合依赖链比较长的场景。

JavaConfig还有配套的条件注解,比如@ConditionalOnProperty和@ConditionalOnClass,让某个bean在满足特定条件时才注册。这已经是Spring Boot自动配置的地基了,固定写法在大型项目里见得很多。

4.3 三种方式混用怎么办

实际项目里很少有一口咬定只用一种方式的情况,更多时候是三种方式并存。老项目XML里有个bean,新模块用注解,还写了不少JavaConfig,这就需要知道它们如何共处。

XML配置和JavaConfig的桥接有一个专门的注解:@ImportResource。在配置类上声明它就能把XML引入:

@Configuration @ImportResource("classpath:legacy-context.xml") public class AppConfig { }

JavaConfig之间也可以用@Import引入其他配置类。这种方式适合把一个大的配置类按业务模块拆成多个小配置类,在主配置类里统一汇总。

在使用上需要明确的是,不同方式注册进来的bean,在容器里地位是平等的。它们的名字规则略有不同——XML用id,注解用类名首字母小写,JavaConfig用方法名——但只要是同一个名字,最终在容器里合并的就是同一个bean定义。这里就埋了一个隐患:同名bean到底谁覆盖谁,我建议你先把这条规则记在心里,到了第六节我详细演示。

5. 三种方式对比与选型建议

5.1 一张表看懂三种方式的差异

对比维度XML方式注解方式JavaConfig方式
注册位置独立XML文件类上的@Component及衍生注解@Configuration里的@Bean方法
bean名称来源显式id类名首字母小写方法名
类型安全性差,全字符串中等,类本身是类型强,返回值是真实类型
第三方类注册支持不支持,无法改源码支持,最佳方案
复杂对象创建写起来很笨拙只能在类内写构造逻辑在@Bean方法里写任意逻辑
配置可读性信息分离,容易迷失集中但依赖隐式集中且显式
条件化配置支持profile依赖其他注解配合支持@Conditional系列
当前主流程度低,老项目为主高,业务组件最常用高,配置类和自动配置主选

从表格里可以看到,三种方式并不是淘汰更替的简单关系。XML是“能不用就不用但有存量”,注解是“日常开发的主力”,JavaConfig是“搞配置和集成时的武器”,它们的适用场景有交集但侧重点完全不同。

5.2 实际工程里怎么选才不纠结

结合我这些年的经验,选型逻辑可以简化成一句话:能用JavaConfig表达的对象用JavaConfig,自己的业务类优先用注解,遗留XML不主动迁移只做桥接。

具体展开说。新项目从零起步,业务代码直接用@Component和@Service,配合构造器注入,风格统一、代码简洁。数据库连接池、消息队列、各种客户端SDK这类“外部件”,统一放进一个配置类用@Bean管理,这样别人看到配置类就知道所有基础设施都在这里。老项目如果XML运行得好好的,不用为了“跟上时代”去大规模改写,迁移成本高且容易出回归问题,用@ImportResource桥接就够了。而当项目开始引入Spring Boot自动配置时,你大量接触到的其实都是JavaConfig的写法,比如各种@EnableXXX注解背后本质上都是@Import一个配置类。

还有一种思维方式可以帮你判断:你在写一个“服务”还是一个“工厂”?写业务服务的时候,关注的是这个类做什么,用@Service自然合适;写配置的时候,关注的是“创建什么对象、怎么创建”,这正是@Bean的语境。

6. 常见问题排查与避坑实录

6.1 同名bean冲突,到底谁覆盖谁

这是实际项目里遇到频率最高的容器异常之一。触发场景很典型:你手写了一个@Bean方法叫dataSource,把一整套数据源配置好,结果启动时发现容器里跑的却不是你的配置,甚至报ConflictingBeanDefinitionException。

Spring的默认覆盖规则在不同版本有差异。Spring Boot 2.1开始,默认允许同名的bean定义覆盖,但把allow-bean-definition-overriding设为true时会有日志警告;Spring Boot 2.1之前默认是允许覆盖的。在新版Spring Boot里覆盖行为默认被禁止,同名定义直接抛异常。

规则归纳下来是:后注册的bean定义会覆盖先注册的同名定义。启动时Spring会按照配置加载顺序解析bean定义,后面的覆盖前面。所以如果你的注解类扫描和JavaConfig配置都注册了同一个名字,结果取决于谁先谁后。

排查思路很简单:先在启动日志里找“Overriding bean definition”或“ConflictingBeanDefinitionException”相关关键字,找到后把冲突的类名找出来,确认是不是扫描路径重叠把同一个类注册了两遍,或者是自己的配置类和某个自动配置类撞了名。实践中最稳的解法是:换bean方法名,避开自动配置的默认名;或者调整扫描范围。这里建议不要贪图覆盖自动配置对象,尤其是数据源这类核心组件,容易埋下大坑。

6.2 构造器循环依赖和setter循环依赖,结局完全不同

循环依赖指的是两个及以上的bean互相依赖,形成闭环。具体点说就是A依赖B,B依赖A。

@Service public class A { private final B b; public A(B b) { this.b = b; } } @Service public class B { private final A a; public B(A a) { this.a = a; } }

这种情况下,容器启动时会发生什么?构造器注入的方式下,容器创建A时需要B,创建B时需要A,两个人互相等对方先出生,最后直接抛出BeanCurrentlyInCreationException。这就是构造器循环依赖必挂的原因。而setter或字段注入的情况下,容器可以先把A的半成品对象放到缓存里,再创建B,B里注入A的引用时发现缓存里有了,B完成创建之后再回到A把B注入进去,整个链路走完。

Spring解决单例setter循环依赖靠的是容器内部的三级缓存结构:一级缓存保存完整对象,二级保存早期半成品,三级保存对象工厂。A刚构造到一半放进第三级缓存,B创建时从三级缓存拿引用,返回给B,B完成后A再接着收尾。这套机制是面试拔高题经常问的,现在你至少应该知道它解决的是哪类循环依赖。

但我要强调一句:能解决不代表应该依赖。循环依赖本身就是设计上的瑕疵,长期维护期会越缠越紧。遇到这种报错,优先考虑重构:把A依赖B的部分抽成方法回调,或者中间加一层抽象,彻底拆开循环。

6.3 @Bean方法写成static导致bean行为异常

有次我在代码评审里看到有人写了这样一个配置方法:

@Configuration public class AppConfig { @Bean public static DataSource dataSource() { return DataSourceBuilder.create().build(); } }

static关键字看起来无关紧要,但对@Configuration下的@Bean方法来说,它会让Spring的CGLIB代理完全失效。原因在于:CGLIB是通过生成配置类的子类来拦截方法调用的,而静态方法是类级别的方法,子类无法覆盖它,也就没法拦截。一旦方法里依赖另一个@Bean方法的返回值,比如:

@Bean public DataSource dataSource() { return new DataSource(...); } @Bean public JdbcTemplate jdbcTemplate() { return new JdbcTemplate(dataSource()); // 这里拿到的可能不是同一个dataSource }

静态方法调用dataSource()时绕过代理,会直接执行原始方法逻辑,导致每个bean可能拿到独立的实例,单例约束就破了。

什么情况下static是合理的?如果是自己写工具类,不依赖其他bean,而且明确想跳过代理,可以用static。但在配置类中推荐一律用普通方法,让Spring接管代理逻辑,避免出现“时灵时不灵”的诡异现象。

6.4 bean初始化顺序问题

有些业务场景对bean的初始化顺序有要求,比如A要先准备好缓存数据,B启动时才能正确读取。Spring容器默认的bean初始化顺序是按依赖关系推导的,你无法绝对控制,但可以通过两种方式干预。

一种是在@Bean方法上使用@DependsOn:

@Bean @DependsOn("cacheService") public ReportService reportService() { return new ReportService(); }

这个方法告诉容器,先创建cacheService,再创建reportService。另一种是让bean实现Ordered接口或者在类上使用@Order注解,同类场景下按order值从小到大排列。不过要注意@Order只作用于同一类型或同一集合的多个bean,它并不能严格定义所有bean的启动顺序,预期不要放太高。

还有一个常见现象是:某些bean构造时会读配置属性,但配置属性还没被加载完。这大概率不是顺序问题,而是没有使用正确的方式获取配置值——应该用@Value或者@ConfigurationProperties绑定,而不是在构造方法里直接静态读取。如果必须在启动时读配置,建议使用ApplicationRunner或CommandLineRunner,在容器完全启动后再执行。

6.5 @Configuration类加final后出现bean覆盖异常

之前提过Spring会通过CGLIB生成@Configuration类的子类来实现代理,而CGLIB无法继承final类。有人为了避免配置类被继承,顺手加了final,结果发现启动报错或者bean被创建了多份,一脸茫然。

@Configuration public final class AppConfig { // 不要这么写 @Bean public UserDao userDao() { return new UserDao(); } }

加了final之后,Spring无法创建配置类的代理子类,只能退回到lite模式的处理逻辑,也就是不拦截@Bean方法的内部调用,导致多次调用@Bean方法时返回不同实例。更直接的后果是,一些需要拦截方法才能生效的特性全部失效。

正确做法是不要用final修饰配置类,或者退一步用@Bean + 普通类的组合,明确接受lite模式,不再依赖代理特性。想防止配置类被误继承,使用package-private类或把配置类的细节收起来就好,Spring对配置类没有非得public的要求。

这个小细节不能说天天遇见,但真出现时定位起很容易走弯路,属于“配置声明”这个主题里值得记住的坑。

7. 关于容器管理的几个小习惯

经验上,我建议你把“容器能自动做的事”和“需要你显式告诉容器做的事”分清楚。自动装配依赖是容器帮你做的事,而声明bean本身是你必须做的事。构建复杂对象时优先写@Bean方法,注册业务类时用@Autowired配合构造器注入,遗留XML先桥接再逐步消化。

还有一个实操细节值得保持:项目里尽量统一bean的命名规则。XML时代人们习惯叫userService,注解时代默认也是这个,到了JavaConfig里也按这个习惯来。名字统一之后,排查问题时的心理负担会小很多,IDE的提示也更友好。

这种基础功底不一定会天天展示,但一旦遇到循环依赖、bean冲突、配置失效这类问题,能不能快速定位,很大程度上取决于你对容器管理机制的理解深度。三种方式看似只是写法差异,背后的容器思想才真正值钱。

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

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

立即咨询