Spring 的 Bean 这一个主题,说难不难,说简单也真的不简单。很多人用 Spring 用了一两年,@Autowired和@Service用得飞起,但一旦面试官问"Bean 的生命周期是怎样的""三级缓存到底是怎么解决循环依赖的",一下子就露馅了。这是因为我们平时只看到了 Bean 的使用,却没有理解 Spring 容器在背后到底做了哪些事。
这篇文章我想以"第二章 Spring 中的 Bean"为切入点,把 Bean 从概念到源码、从实例化到销毁、从单例到循环依赖,整个链路梳理一遍。不管你是刚学 Spring 的初学者,还是想补基础准备面试的开发者,这篇文章都值得认真读一遍。我会尽量不堆术语,用最直白的方式把原理讲清楚,同时把实操中容易踩的坑也一并列出来。
1. Bean 是什么:Spring 框架的核心灵魂
1.1 从"对象"到"Bean"的认知升级
我们平时写 Java 代码,创建一个对象用的是new关键字:UserService userService = new UserService()。这看起来很自然,但你有没有想过,如果UserService内部依赖了UserDao,而UserDao又依赖了DataSource,你每次创建UserService的时候,都要手动把这些依赖一层一层地 new 出来。对象少还行,对象一多,代码就成了"构造器地狱"。
Spring 做的事情,就是把这套"创建对象、组装对象、管理对象"的权力收归容器。所谓Bean,就是在 Spring 容器管理下的对象。它和你用new创建出来的对象本质上是同一个东西,区别在于:new出来的对象由你负责它的生老病死,而 Bean 由 Spring 容器负责它的实例化、属性赋值、初始化、依赖注入,以及最后的销毁。
这个过程有个专业说法叫"控制反转"(IoC,Inversion of Control)。通俗点讲,以前是你自己控制对象的创建,现在你把这个控制权反转给了 Spring 容器。你的代码里只需要声明"我需要一个 UserService",Spring 就会在合适的时机把它创建好、装配好,然后交到你手上。
1.2 Bean 到底解决了什么问题
Bean 机制的核心价值,我认为可以浓缩成三个词:解耦、集中、复用。
先说解耦。假如系统里有 A、B、C、D 四个业务类,A 依赖 B,B 依赖 C,C 依赖 D。如果全部用new来创建,那么 A 的代码里必须知道 B 的构造器长什么样,B 的代码里必须知道 C 的构造器长什么样,类的耦合度极高。一旦 C 的构造器加了一个参数,所有依赖 C 的类全要跟着改。用 Spring 管理之后,类之间只需要通过接口或者注解声明依赖,具体的实现类由容器去匹配,改一个实现类不影响其他代码。
再说集中。所有 Bean 的定义、作用域、初始化顺序、销毁策略,都集中在配置信息里,可以写在 XML 里、注解里或者 Java 配置类里。你查看一个系统有哪些 Bean、它们之间是什么关系,不需要满项目去搜new关键字,直接看配置就能了解全貌。
最后说复用。Bean 默认是单例的,一个系统里共享同一个实例。这一点对无状态的 Service 类非常合适,既节省了内存,也避免了反复创建对象的性能损耗。后面我会详细说单例和多例的选择问题,这里先记住:单例是默认策略,但不是唯一策略。
2. Bean 的生命周期:从诞生到消亡的完整链路
2.1 一条时间线看懂 Bean 的一生
理解了 Bean 是什么之后,下一个问题就是:一个 Bean 从创建到销毁,中间到底经历了哪些步骤?我把整个过程整理成了一条时间线,你可以对照着看:
- Spring 容器启动,扫描配置,读取 Bean 定义(
BeanDefinition)。 - 根据 Bean 定义,通过构造器反射实例化对象(此时对象的属性都是默认值)。
- 进行属性填充,也就是依赖注入(
@Autowired、@Resource、XML 中的 property 都在这一步生效)。 - 如果 Bean 实现了
BeanNameAware、BeanFactoryAware、ApplicationContextAware等 Aware 接口,容器会回调对应的方法,把 Bean 的名字、工厂、上下文等信息告诉 Bean。 - 调用
BeanPostProcessor的postProcessBeforeInitialization方法。 - 执行初始化逻辑,包括
@PostConstruct注解方法、InitializingBean接口的afterPropertiesSet方法、XML 中配置的init-method方法(三者顺序:@PostConstruct→afterPropertiesSet→init-method)。 - 调用
BeanPostProcessor的postProcessAfterInitialization方法,此时 Bean 已经可以用了。 - 容器销毁时,执行销毁逻辑,包括
@PreDestroy注解方法、DisposableBean接口的destroy方法、XML 中配置的destroy-method方法(三者顺序:@PreDestroy→destroy→destroy-method)。
这里有一个特别容易被忽略的点:BeanPostProcessor 是所有 Bean 都会经过的关卡。Spring 的很多核心功能,比如@Autowired的注入、@Async的代理、AOP 的动态代理,都是靠BeanPostProcessor在 Bean 初始化前后"做手脚"实现的。你甚至可以自己写一个BeanPostProcessor,在所有 Bean 初始化完成后打印一句日志,这在排查问题时非常有用。
2.2 初始化阶段的三个扩展点怎么选
很多人分不清@PostConstruct、InitializingBean、init-method到底有什么区别,我在实际开发里看到过混用的情况。其实它们的触发时机基本一致,都是初始化阶段,但使用方式不同。
| 扩展点 | 使用方式 | 特点 |
|---|---|---|
@PostConstruct | 在方法上加注解 | JSR-250 标准,最推荐,代码侵入性小 |
InitializingBean | 实现接口,重写afterPropertiesSet | Spring 特有接口,会让类耦合 Spring 的 API |
init-method | XML 或@Bean(initMethod=...)指定方法名 | 适合不使用注解的老项目,方法名可自定义 |
我的建议是,新项目统一用@PostConstruct。因为它不依赖 Spring 的接口,跑在 JSR-250 规范上,就算以后脱离 Spring(比如换成别的容器),代码几乎不用改。
另外要注意:如果你的初始化逻辑里需要用@Autowired注入的属性,那么这些属性在@PostConstruct阶段已经完成注入,可以放心使用。这一点和构造器里直接访问注入属性不同,构造器阶段属性还没填充,强行访问会拿到 null。
2.3 销毁阶段的那些坑
Java 对象本身没有析构函数,但 Spring 给了我们销毁的扩展点。@PreDestroy和DisposableBean常用于释放资源,比如关闭连接池、销毁线程池。
但有一个非常经典的坑:当 Bean 的作用域是 prototype 时,Spring 容器不会管理它的销毁。也就是说,你每次从容器里拿到的原型 Bean,容器在关闭时不会主动调用它的销毁方法。这个行为不是 bug,而是 Spring 的设计——容器认为原型实例由调用方全权负责,容器只管创建,不管后续。
所以如果你在 prototype 作用域的 Bean 里配置了复杂的资源释放逻辑,要么自己手动调用销毁方法,要么干脆别把这种 Bean 交给容器管理。我自己就踩过一次这个坑:一个 prototype 的 Task Bean 里开启了线程池,应用重启时线程池没有优雅关闭,导致日志里刷了一堆线程中断异常。后来我把线程池的管理挪到了单例的生命周期组件里,问题才解决。
3. Bean 的作用域:不是只有单例
3.1 六种作用域逐一解析
Spring 内置了六种 Bean 作用域,最常用的就两个:singleton 和 prototype。剩下的几个主要用在 Web 应用中,我放在表格里一起说。
| 作用域 | 说明 | 实例数量 | 适用场景 |
|---|---|---|---|
| singleton | 默认作用域,每个容器只创建一个实例 | 1 | 无状态 Service、工具类、配置类 |
| prototype | 每次获取都创建新实例 | N | 有状态的对象、多线程冲突的场景 |
| request | 每个 HTTP 请求一个实例 | 每请求 1 个 | Web 应用中的请求级数据容器 |
| session | 每个 HTTP 会话一个实例 | 每会话 1 个 | 用户登录信息、购物车等 |
| application | 每个 ServletContext 一个实例 | 全局 1 个 | 应用级共享配置 |
| websocket | 每个 WebSocket 连接一个实例 | 每连接 1 个 | WebSocket 会话数据 |
需要特别提醒:singleton 和 prototype 的区别不仅仅是"实例数量不同",而是"容器对 Bean 的掌控程度不同"。singleton Bean 由容器创建、管理、销毁,prototype Bean 创建完就交给调用方了,容器不再跟进后续生命周期。
3.2 什么时候必须用 prototype?我的判断标准
很多初学者以为"所有 Bean 都应该是单例的",这是不对的。判断标准其实很简单:这个 Bean 内部有没有可变状态?多线程共享它会不会出问题?
比如一个DateUtils工具类,里面全是静态方法,没有任何字段,那它用单例没问题。再比如一个OrderService,它虽然内部有OrderDao依赖,但OrderDao是无状态的(不持有具体业务数据),所以单例安全。
但如果一个 Bean 持有类似ThreadLocal之外的可变实例变量,比如private int count,或者内部维护了一个List用来暂存数据,那么单例就意味着所有线程共享这份数据,并发环境下就乱了。这种场景就该用 prototype,让每个调用方拿到独立实例。
不过说实话,现代开发里我们更推荐"单例 + 无状态"的设计,把可变状态要么抽出去放在独立对象里,要么用ThreadLocal隔离,而不是单纯靠调节 Bean 作用域来规避问题。prototype 有它的价值,但它会牺牲容器管理的便利性,能不用尽量不用。
在 Spring Boot 中,你可以用@Scope("prototype")或者@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)来指定。如果你是使用@Configuration类配置 Bean,注意一点:用@Bean方法返回对象时,方法和类都不能是 final 的,否则 CGLIB 代理无法工作,prototype 作用域可能失效。
4. Bean 的实例化方式:三种主流方案加一个特殊存在
4.1 构造器、静态工厂、实例工厂怎么选
Bean 的创建并不局限在"外部new然后注册"这一种方式,Spring 支持多种实例化方式,我把实际开发里常用的几种列出来:
第一种:构造器实例化。这是最常用的方式,Spring 通过反射调用类的无参构造器或带参构造器创建实例。无论是 XML 里的<bean class="...">,还是注解扫描@Component,走的基本都是这条路径。需要注意:如果你写了有参构造器但没写无参构造器,Spring 也能通过参数类型匹配自动完成实例化,前提是参数本身也是受 Spring 管理的 Bean。
第二种:静态工厂方法。通过类上的静态方法返回实例。在 XML 中配置factory-method即可。这种方式的典型场景是返回类型是接口,但具体实现类不想暴露给调用方。老项目中常见,新项目里已经很少直接这么写了。
第三种:实例工厂方法。工厂类本身是一个 Bean,工厂里的某个方法返回目标 Bean。早期用来做第三方库的整合(比如旧的SqlSessionFactoryBean),现在更多的是被FactoryBean接口取代了,所以这一种了解即可。
4.2 FactoryBean:一个容易被误解的 Bean
说到实例化,必须提一下FactoryBean。它和BeanFactory名字很像,但完全是两回事。BeanFactory是容器本身,而FactoryBean是一个特殊的 Bean,它生产其他 Bean。
实现FactoryBean<T>接口的类,Spring 在创建它时,不会注册它本身,而是注册它getObject()方法返回的那个对象。如果你需要获取工厂本身,需要在 Bean 名字前加&符号,比如applicationContext.getBean("&myFactoryBean")。
FactoryBean的强大之处在于,它可以在创建对象时做很多自定义逻辑。比如MyBatis的MapperFactoryBean,就是通过工厂方法动态生成 Mapper 接口的代理对象。如果你需要整合没有源码的第三方库,或者想动态生成代理对象,FactoryBean是最合适的入口。
4.3 注解扫描和装配优先级
除了 XML,Spring 也支持包扫描加注解。@Component、@Service、@Repository、@Controller这组注解的作用是一样的,都是告诉容器"这个类需要被管理",只是语义上区分了业务层、数据层和控制器层。@Configuration和@Bean则是用 Java 代码显式声明 Bean。
如果同一个类型既被@Component扫描到了,又在@Configuration里通过@Bean定义了,那最终生效的是哪个?答案是@Bean定义的那个覆盖扫描的。因为@Configuration里的@Bean方法在容器加载时优先级更高,并且在配置类刷新时会覆盖同类型的 Bean 定义。这个机制在排查"为什么我改了实现类但注入的还是原来的对象"这类问题时很有用。
5. 依赖注入的三种方式:一篇文章彻底搞懂
5.1 字段注入、Setter 注入、构造器注入的对比
依赖注入(DI)是 IoC 的具体实现手段。Spring 支持三种注入方式,但在实际项目里,它们的地位完全不同。
| 注入方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 字段注入 | @Autowired private UserDao userDao | 代码最简洁 | 依赖不显式、不便于测试、容易形成循环依赖 |
| Setter 注入 | 属性上加@Autowired,但放在 setter 方法上 | 可选择性,后期可修改依赖 | 依赖不是 final,可能被改掉 |
| 构造器注入 | 在构造器上使用@Autowired(可省) | 依赖不可变、显式、便于测试 | 依赖多了构造器会长 |
从 Spring 官方推荐的角度,最推荐的是构造器注入。因为构造器强制要求你在创建对象时就把依赖传进来,所以 Bean 不可能出现"半初始化"的状态,而且最终的字段可以用final声明,不可变性更好。Spring 4.3 之后,如果类只有一个构造器,@Autowired甚至可以省略,Spring 会自动选择该构造器。
字段注入虽然舒服,但它带来两个痛点:一是你无法在构造对象时传入依赖,导致单元测试必须借助 Spring 容器或者反射框架(比如ReflectionTestUtils),测试成本增加;二是字段注入在语义上是一种"隐式依赖",代码审查时不容易看出这个类到底需要什么。我看到不少项目为了图省事全用字段注入,结果对象间的依赖关系变成了一张看不见的网,排查问题非常痛苦。
5.2 @Autowired 和 @Resource 到底差在哪
这个问题几乎每次面试都会碰到,我直接给结论。
@Autowired是 Spring 的注解,它的装配策略是先按类型(byType)再按名称(byName)。当容器中存在多个同类型的 Bean 时,它会先尝试按字段名去找对应名字的 Bean,找不到就会报NoUniqueBeanDefinitionException。配合@Qualifier("beanName")可以精确指定。
@Resource是 JDK 自带的注解(JSR-250),它的装配策略是先按名称(byName)再按类型(byType)。如果没有指定name属性,它会用字段名作为 Bean 名字去找;找不到再按类型找,还是找不到就报错。
打个比方:@Autowired像一个按职业找人的猎头,先看你这有几个人干这个职位,再看有没有恰好叫某个名字的;@Resource像一个按姓名找人的通讯录,先看有没有这个名字,找不到再问"那你干什么职业的"。
在只有一个同类型 Bean 时,两个注解没有区别;一旦出现同类型多个 Bean,@Autowired默认是报错的,@Resource则可能根据字段名顺利匹配。我个人在新项目里更喜欢用@Autowired + @Qualifier,因为语义更明确,而且在 Spring 生态里@Autowired对构造函数、方法、参数都支持,而@Resource一般只用在字段上。如果是第三方组件的注入、需要按名字精确取 Bean 的场景,@Resource反而更顺手。
5.3 依赖注入常见的一个误用:静态工具类
很多工具类喜欢写成"静态方法 + 静态字段",比如:
public class RedisUtil { @Autowired private static RedisTemplate redisTemplate; }这样写等于白写。因为@Autowired只能对 Spring 管理的 Bean 的实例字段生效,静态字段属于类级别,容器在注入时根本没有"对象实例"的概念,所以静态字段永远都是 null。我见过好几个项目因为这个原因在线上出现了空指针,排查半天最后发现是静态字段注入。
如果你确实需要一个静态工具类里能用 Spring 管理的 Bean,有两种方案:
第一种,不要用静态字段,改成非静态字段,把工具类本身注册为一个 Bean,在需要的地方以构造器注入的方式使用它。
第二种,用ApplicationContextAware写一个静态上下文工具,启动时把容器实例保存下来,需要的时候通过getBean获取。但这种方式是一种补丁式方案,能不用就不用,它会破坏依赖注入的显式性。
6. 循环依赖与三级缓存:Spring 最精妙的设计之一
6.1 循环依赖长什么样
循环依赖就是两个或多个 Bean 互相依赖,形成一个环。
@Service public class AService { @Autowired private BService bService; } @Service public class BService { @Autowired private AService aService; }在上面代码里,创建 A 时需要注入 B,创建 B 时又需要注入 A,如果不做任何处理,这就成了"鸡生蛋、蛋生鸡"的问题,Spring 容器会直接抛BeanCurrentlyInCreationException异常。
Spring 解决这个问题的思路并不复杂:提前暴露"半成品"对象。在 A 可以创建的时候就先把 A 的一个实例(可能还没有完成属性填充)记下来,等 B 创建需要注入 A 时,直接把这个半成品给 B;B 完成创建后,A 再完成剩余步骤,拿到 B 的引用赋值进来。这样一来,A 和 B 就都成功创建了。
6.2 三级缓存:为什么两级不够
Spring 用来管理这个流程的是三个缓存 Map,俗称三级缓存:
| 级别 | 名称 | 内容 |
|---|---|---|
| 一级 | singletonObjects | 已经完整创建好的单例 Bean |
| 二级 | earlySingletonObjects | 提前暴露的"半成品"Bean |
| 三级 | singletonFactories | 存放 Bean 创建工厂的注册表 |
创建单例 Bean 时,核心逻辑在DefaultSingletonBeanRegistry的getSingleton方法里。我来描述一下整个流程:
首先,实例化 A 对象(通过构造器),但此时 A 还是"早期引用",属性都是默认值。Spring 不会把 A 直接放进二级缓存,而是先向三级缓存里放一个ObjectFactory(对象工厂),这个工厂后续可以通过调用它拿到 A 的早期引用。然后尝试对 A 进行属性填充,发现需要 B,于是去创建 B。
创建 B 时,B 的实例化完成后同样先放三级缓存,接着 B 需要注入 A。此时容器在三级缓存里发现了 A 的工厂,调用这个工厂拿到 A 的早期引用,把 A 注入到 B 里。B 完成所有初始化后,放进一级缓存。
回到 A,A 拿到 B 的完整引用,完成属性填充和初始化,放进一级缓存,同时清理掉二级和三级缓存里的 A 记录。整个过程完成。
那为什么要分三级而不是两级?关键就在于:只要没有循环依赖,Spring 不希望提前暴露半成品。三级缓存里存的是ObjectFactory而不是裸对象,好处是,可以在这个工厂里对早期引用做包装。比如当 A 涉及 AOP 需要生成代理对象时,工厂返回的就是代理对象的早期引用,而那些没有循环依赖的 Bean 根本不会被提前实例化。
如果只有一级缓存和二级缓存,Spring 必须在 A 实例化后立刻暴露半成品,这样会带来两个问题:一个是性能浪费,所有 Bean 都被强制提前创建,失去了按需创建的灵活性;另一个是对 AOP 代理无法做懒处理,可能造成一次创建多个代理对象。三级缓存的"工厂"机制让 Spring 精确控制"什么时候需要半成品、什么时机生成代理"。
6.3 哪些循环依赖救不了
三级缓存确实强大,但它不是万能的。以下三种情况,循环依赖依然会报错:
第一种:构造器循环依赖。比如 A 的构造器中需要 B,B 的构造器中需要 A。因为构造器在实例化阶段就触发了依赖,此时对象都还没创建出来,三级缓存里自然没有可用的早期引用。解决方案是改用 Setter 注入或者字段注入,把创建和依赖填充拆开。
第二种:非单例作用域的循环依赖。prototype 作用域的 Bean 每次都是新实例,本身就是"用完就扔"的,容器不会为其保留缓存,所以循环依赖无法解决。出现这种情况优先考虑重新设计依赖关系,别再坚持 prototype。
第三种:使用了@Async或者 AOP 且代理未正确提前暴露时。正常逻辑下,Spring 的 AOP 代理可以在三级缓存工厂里生成,但如果代理模式或者@EnableAsync的处理方式不当,也会陷入 Bean 正在创建中。这种问题排查比较费劲,建议直接用@Lazy注解打破循环:@Autowired @Lazy private BService bService。@Lazy会注入一个代理对象,B 只有在真正使用时才会去创建,从源头避免循环。
7. 常见问题与排查技巧实录
7.1 报错 NoSuchBeanDefinitionException:Bean 到底在不在容器里
这是 Spring 开发中最常见的异常,大概占我遇到问题的三成。遇到它首先别慌,按下面几步排查:
先确认类上是否有@Component系列注解。如果是@Service、@Repository、@Controller这些,确认包路径被 Spring Boot 启动类的@SpringBootApplication(它隐式包含@ComponentScan)扫描到了。如果你把类放到了启动类包路径之外,Spring 默认是扫不到的,需要手动加@ComponentScan。
再确认该类是否被@Configuration类里的@Bean方法引用。如果@Bean方法返回了一个null,容器里自然也没有 Bean。这种情况代码不会立即报错,直到你在别处注入时才暴露。
还有一个很隐蔽的坑:如果类在编译时被混淆了或者改名了,但配置里的 Bean 名字没变,也可能是容器里没有对应 Bean。排查方法是启动时加debug=true日志,或者在测试类里打印applicationContext.getBeanDefinitionNames(),一眼就能看到容器里到底注册了哪些 Bean。
7.2 注入成功了但运行时报 null 引用
这种情况通常在 Controller 或者工具类里出现。我总结一下主要原因:
最典型的是我上面讲过的静态字段注入。静态字段根本不参与 Spring 的依赖注入,所以永远是 null。还有一个原因是对象是通过new手动创建的,而不是容器创建。比如你在某个方法里手动new了一个 Service,这个新对象里面的注入依赖自然是空的,Spring 不会参与管理。
另一个坑发生在构造函数顺序上。比如你在构造器里调用了某个实例方法,而这个实例方法里又使用了一个尚未注入的依赖。构造器执行时,依赖注入还没有完成,所以会空指针。正确的做法是把初始化逻辑放到@PostConstruct方法里,或者使用构造器参数注入而不是字段注入。
7.3 同类型多个 Bean 冲突:NoUniqueBeanDefinitionException
当一个接口有多个实现类时,@Autowired默认按类型注入就不知道选谁了。报错信息会列出所有候选 Bean 的名字,解决办法有几种:
用@Primary标记一个 Bean 为默认首选。这样不指定名字时,容器优先注入它。
用@Qualifier("beanName")精确指定。比如:
@Autowired @Qualifier("userDaoImpl2") private UserDao userDao;第三种是利用字段名匹配。@Autowired在类型冲突时会尝试按字段名找 Bean,如果你的字段名字恰好和某个实现类的 Bean 名字一致,也能注入成功,但这种方式比较隐晦,容易出错,我建议还是明明白白用@Qualifier。
这里还要强调一下:解决冲突和解决循环依赖是两个不同维度的问题。不要为了解决循环依赖而随意加@Primary,这会掩盖掉类型上的歧义,给后续维护埋雷。优先考虑用@Qualifier。
8. 实操中的三个心得:写给正在写代码的你
最后分享几个我在实际项目里反复确认过的经验,希望能帮你少走一些弯路。
第一点,写单元测试时尽量走构造函数注入。字段注入在容器里没问题,但一旦脱离 Spring 容器做单元测试,你就得想方设法给私有字段赋值。构造器注入天然支持手动new对象并传入 mock 依赖,测试代码干净不少。
第二点,如果你要扩展 Spring 容器对 Bean 的处理,优先选 BeanPostProcessor,其次才是监听器。BeanPostProcessor 在每个 Bean 初始化前后都有回调机会,是"针对所有 Bean"的万能钩子。比如我想统一给所有 RPC 服务加上链路追踪日志,就是写了一个 BeanPostProcessor,在 Bean 创建后判断类型动态生成代理,效果非常稳定。
第三点,善用 @Lazy 处理你不想改架构的循环依赖。虽然我建议从根本上避免循环依赖,但实际项目中总有一些历史代码绕不开。这时用@Lazy给其中一个依赖打上注解,注入一个代理对象,就能立竿见影地解决启动报错。不过要记住:@Lazy是"镇痛药",不是"根治药",长期维护下有机会就应该重构依赖关系。
Spring 的 Bean 机制是理解整个框架的一把钥匙。你把 Bean 的创建、生命周期、作用域、依赖注入这几个点吃透了,再看 AOP、事务、MyBatis 整合这些高阶功能,会发现它们全都是在 Bean 机制的骨架之上搭建的。希望这篇文章能帮你把这块基础打牢。