1. 一上来先搞清楚:把 bean 交给容器到底是在干什么
很多刚接触 Spring 的同学,看了无数教程,背了一堆注解,最后还是一脸懵:到底什么叫“把一个对象交给 Spring 容器管理”?我换个直白的说法——你不再自己 new 对象了,你只负责告诉 Spring:“这个类归你管,以后谁要用它,你给它发一个就行。”至于这个对象什么时候创建、创建几个、怎么装配依赖、什么时候销毁,全部由 Spring 容器统一调度。
这听起来很简单,但真正写代码的时候,很多人会在这件事上栽跟头。最常见的报错就是热搜词里那两条:Unsatisfied dependency expressed through field和Consider defining a bean of type 'java.lang.Long' in your configuration.。这些报错本质上就一句话:你想要的 bean,Spring 容器里根本没有,或者容器不知道这个类归它管。所以这篇文章我想彻底把“交给容器管理”这件事拆开聊清楚。
Spring 里把一个类交给容器管理,标准做法其实就三个方向:XML 配置、注解扫描、JavaConfig 配置类。这三个方向不是谁取代谁的关系,而是适用场景完全不同。我见过不少团队,明明用 Spring Boot 还在硬写 XML,也见过老项目里为了“统一风格”把所有类都塞进配置类,结果配置类膨胀到上千行。这些都属于没想明白“三种方式各自解决什么问题”。下面我按理解难度从低到高,把每条路的原理和实操都过一遍。
在开始之前,先说一个贯穿全文的基础认知:Spring 容器管理 bean 的底层核心机制,是BeanFactory和它的高级实现ApplicationContext。不管是 XML、注解还是配置类,最终做的事情都是一样的——把类的定义信息(BeanDefinition)注册进容器,然后容器根据这份“生产图纸”在合适的时机创建实例、完成依赖注入。所以你看那三种方式,可以理解成三种写“生产图纸”的语法,图纸本身最终都会被解析成BeanDefinition。这个底层机制搞懂了,后面所有细节就都串起来了。
2. 三种方式全景对比:各自适合什么场景
先给一张总表,把三种方式放在一起看,后面再逐个深入。
| 方式 | 代表写法 | 核心优势 | 主要局限 | 常见使用场景 |
|---|---|---|---|---|
| XML 配置 | <bean id="userService" class="com.example.UserService"/> | 不侵入代码,改配置无需重新编译 | 写起来冗长,类型安全差,工程化弱 | 老系统维护、部分中间件集成、框架底层 |
| 注解扫描 | @Component/@Service/@Repository/@Controller | 开发效率高,类即声明,直观 | 对已有类侵入性强,必须改源码 | Spring Boot 项目、新业务模块开发 |
| JavaConfig | @Configuration+@Bean | 类型安全,可编程,适合装配第三方类 | 配置类容易膨胀,需要维护额外类 | 第三方库集成、条件装配、复杂依赖配置 |
我的建议很简单:新项目无脑用注解 + JavaConfig 组合,XML 能不用就不用,但必须看得懂。为什么?因为 Spring Boot 的自动配置机制本身就是建立在 JavaConfig 之上的,你天天用的@SpringBootApplication背后就是一坨复杂的@Configuration。你要是看不懂配置类,遇到自动配置失效的问题根本无从下手。
但这里要特别提醒一句:“注解优于 XML”不等于“注解万能”。有些场景下 XML 依然有不可替代的价值。比如你要把一个类同时注册成多个不同名字的 bean,或者需要在运行时动态调整 bean 的属性,XML 的<bean>标签反而更灵活。再比如很多老项目里,别人写好的 XML 里配了无数<property>注入,你如果不懂 XML,连排查问题都做不到。所以三种方式不是三选一,而是“会用注解干活,能看懂 XML,会写配置类”三件事都要掌握。这也是面试里 Spring 部分必考的内容,热搜词里“spring面试题”经常挂着,原因就在这里。
3. XML 配置方式:最古老但最直白的一条路
3.1 从最简单的实例化开始
XML 方式的核心就是一个<beans>根标签包着一堆<bean>子标签。每个<bean>标签对应一个类的“生产图纸”。我拿一个最基础的用户服务举例:
<?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="userService" class="com.example.service.UserService"/> </beans>然后启动容器拿到这个 bean:
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("beans.xml"); UserService userService = context.getBean("userService", UserService.class); userService.doSomething();这段代码背后发生了什么?ClassPathXmlApplicationContext启动时会读取beans.xml,解析出BeanDefinition,注册到容器,然后容器在默认情况下(非懒加载时)单例池里直接创建好这个实例等着你用。我之前遇到过一个很搞笑的 bug:项目启动后一切正常,但一调用userService.doSomething()就报错,查了半天发现是类名写错了,Spring 启动时居然没报出来。原因是我把<bean>配置成了懒加载,Spring 启动时根本不创建实例,等到getBean才去创建,才发现类不存在。这个坑后面细说。
3.2 构造器注入与属性注入的 XML 写法
XML 里给 bean 配置依赖有两种常用姿势:构造器注入和 setter 属性注入。构造器注入用<constructor-arg>,setter 注入用<property>。
<bean id="userService" class="com.example.service.UserService"> <!-- 构造器注入:按索引或按名称匹配参数 --> <constructor-arg name="userDao" ref="userDao"/> <constructor-arg name="maxRetry" value="3"/> <!-- setter 注入:要求 UserService 必须有 setUserDao 方法 --> <property name="userDao" ref="userDao"/> </bean> <bean id="userDao" class="com.example.dao.UserDao"/>这里的ref="userDao"表示引用容器里另一个 bean 的 id,value="3"表示注入一个基本类型的值。Spring 会做类型转换,字符串"3"自动转成int。这背后其实是 Spring 的TypeConverter体系在起作用,支持 String 到任意基本类型的转换。
我个人强烈建议优先使用构造器注入,而不是 setter 注入。原因有两点:第一,构造器注入能保证依赖不可变,且 bean 在被创建的那一刻就是完整可用的状态,不会出现“new 出来但属性还是 null”的半成品;第二,构造器注入天然支持final字段,这在写不可变对象时非常有用。但 XML 的构造器注入有个隐患:如果构造器有多个同类型参数,必须用index或name显式指定,否则 Spring 是按参数顺序匹配的,很容易配错。这在实际项目中真的会遇到——两个 String 类型的参数,顺序一换,线上数据就乱了。
3.3 静态工厂与实例工厂:被忽略的老方法
除了直接new一个类,XML 还支持通过工厂方法创建 bean。这在老项目集成第三方框架时很常见。
静态工厂的写法:
<bean id="connection" class="com.example.factory.ConnectionFactory" factory-method="createConnection"/>对应的工厂类是一个静态方法:
public class ConnectionFactory { public static Connection createConnection() { return new Connection(); } }实例工厂的写法稍微绕一点,需要先定义一个工厂 bean,再通过factory-bean引用它:
<bean id="factory" class="com.example.factory.ConnectionFactory"/> <bean id="connection" factory-bean="factory" factory-method="createConnection"/>说实话,这种写法在现在的业务代码里已经很少见了,但在 Spring 的底层整合代码里还经常出现。比如整合 MyBatis 时,SqlSessionFactoryBean就是个典型的工厂类。你如果去看它源码,里面核心逻辑就是getObject()方法,本质就是一种工厂模式。所以这个知识点不是没用,而是藏在了框架内部。
这里有个细节值得注意:工厂方法创建的对象,Spring 容器无法干预它的构造过程,只能干预它的生命周期管理。也就是说,init-method和destroy-method依然有效,但依赖注入就没法自动完成了。所以如果工厂方法返回的对象需要依赖 Spring 容器里的其他 bean,你需要在工厂类内部自己搞定,或者用后面说的 JavaConfig 方式包装一层,更优雅。
3.4 XML 里那些容易踩的坑
XML 方式用起来虽然直白,但坑也不少,我把自己踩过的整理一下。
第一个坑是bean 的 id 重复。Spring 在启动的时候如果发现同一份 XML 里有两个相同的 id,会直接报错。但如果是一份 XML 里配了,另外一份 import 进来的 XML 里也配了同名的,行为会有点微妙——默认情况下后加载的会覆盖先加载的(allowBeanDefinitionOverriding默认行为不同版本不一致)。这种隐蔽的覆盖问题,排查起来特别折磨人。我的建议是:id 命名务必全项目唯一,最好带模块前缀,比如orderService、userService。
第二个坑是lazy-init掩盖启动错误。前面说的那个类名写错、启动时不报错的问题,就是lazy-init="true"导致的。我在实际项目里排查过这个问题,最后发现是一个同事为了让“启动变快”,给大量 bean 加上了懒加载。结果启动是快了,但很多配置错误全部延迟到运行时才暴露,线上环境一调用就报错,比启动时直接报错难排查十倍。所以我的建议是:默认不要开启懒加载,只有对启动耗时确实有明显影响、且可能根本不会被用到的 bean 才考虑。
第三个坑是autowire属性的误用。XML 里可以配autowire="byType"或byName,但这种方式非常隐蔽,你看一个 bean 的配置时根本看不出来它注入了哪些依赖,debug 的时候满世界找。这个特性在 Spring 3.0 之后基本就被废弃推荐了,取而代之的是注解驱动的自动注入。如果你在维护老项目时看到这种配置,建议逐步迁移掉,不然团队协作会很难受。
4. 注解方式:Spring Boot 时代的绝对主流
4.1 四个注解的本质区别:其实只有一个
注解方式的入门门槛低到几乎不需要解释:在类上加一个@Component,它就归容器管了。但很多老手也不一定能说清楚@Component、@Service、@Repository、@Controller这四个注解到底有什么区别。
答案是:它们四个在 Spring 容器眼里没有任何区别,本质都是@Component的派生注解。唯一的差别是语义不同:@Service表示业务层,@Repository表示数据访问层,@Controller表示 Web 层。这些语义在某些场景下会被框架感知——比如@Repository上的持久化异常转换,@Controller上的请求映射处理。但如果你在一个工具类上用了@Service,Spring 照常创建,不会报错,只是语义上不严谨。
@Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } } @Repository public class UserDao { public User findById(Long id) { // 模拟数据库查询 return new User(id, "张三"); } }在使用注解方式时,有一个前置条件必须满足:Spring 必须扫描到这个类所在的包。Spring Boot 的启动类@SpringBootApplication里默认定义了@ComponentScan,扫描范围是启动类所在包及其子包。如果你把一个@Service类放到启动类所在包的子包之外,Spring 就不会扫描到,然后就会得到那个经典报错:
Consider defining a bean of type 'com.example.dao.UserDao' in your configuration.这个报错我见过太多人卡住。排查思路很简单:先看这个类有没有加注解,再看它的包路径是否在启动类的扫描范围之内。绝大多数情况是包路径放错了位置。
4.2 扫描配置:@ComponentScan 的细节
如果你的项目里确实有某些类不在启动类的子包下,就需要手动指定扫描范围。@ComponentScan支持basePackages和basePackageClasses两种指定方式,推荐用后者——basePackageClasses可以避免字符串拼写错误导致扫描失效:
@SpringBootApplication @ComponentScan(basePackages = {"com.example.core", "com.example.module"}) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这里有一个很大的坑:如果同时配置了多个@ComponentScan,或者@SpringBootApplication自带的扫描逻辑和自定义的扫描范围冲突,可能导致某些 bean 被重复扫描或者完全漏扫。举个例子,启动类在com.example.application包下,默认扫描整个com.example及其子包;如果你又显式加了@ComponentScan(basePackages = "com.example.application"),相当于把扫描范围缩回去了,那些原本能被扫描到的com.example.module下的类就全部失效。这类问题不是启动时报错,而是运行时注入失败,特别难排查。所以我个人的习惯是:能用默认扫描就不自定义。确实有需要自定义时,宁可把扫描范围写得大一些,也不要写得刚刚好,给自己留点容错空间。
4.3 @Scope、@Lazy、@Primary、@DependsOn:注解方式的生命周期控制
把类交给容器之后,你还需要控制它的“存在形式”。最常用的是@Scope,默认值是singleton(单例),意味着整个容器里只有这一个实例。另一个极端的值是prototype,意思是每次getBean都新建一个实例。
@Service @Scope("prototype") public class PrototypeService { }这里有个非常容易踩的认知误区:很多人以为@Scope("prototype")会让依赖注入的地方每次拿到新实例。实际上,如果在单例 bean 里注入 prototype bean,Spring 默认只会注入一次,之后永远用同一个实例。因为单例 bean 在创建时,Spring 就把依赖注进去了,不会感知后续的 prototype 重新创建。要解决这个问题,需要用到ObjectProvider或者@Lookup注解。这个坑在企业应用里特别典型,比如一个单例的任务调度器里需要每次执行都用新的状态机实例。
@Lazy注解对应 XML 里的lazy-init,作用是延迟 bean 的创建。但和 XML 时代不同,注解方式下@Lazy还有一个特殊用法:可以标注在注入点上,表示“注入一个代理对象,真正调用时才去容器里拿真实 bean”。这在解决构造器循环依赖时很有用,后面我细说。
@Primary解决的是“多个同类型 bean 注入哪个”的问题。比如有两个UserService类型的 bean,你注入UserService时 Spring 会报NoUniqueBeanDefinitionException。解决办法要么用@Qualifier指定名称,要么在其中一个上标记@Primary。这个注解是重构的大杀器——当你想给某个接口换默认实现时,只需要在旧实现上不加@Primary,新实现上加,所有没有显式指定@Qualifier的注入点就自动切换到新实现,不用改一堆调用代码。
@DependsOn控制 bean 的创建顺序。Spring 的默认创建顺序主要依赖依赖关系图:A 依赖 B,A 创建之前 B 一定创建好了。但如果你有两个 bean 之间没有依赖关系却又希望先创建某一个,比如一个负责初始化缓存的 bean 必须在另一个 bean 启动前准备好数据,就需要@DependsOn了。
4.4 注解方式最容易被忽略的问题:扫描的代价
说了这么多注解的好处,我也得说一个它不那么美好的面:扫描是有性能代价的。每次启动时,Spring 会把扫描路径下所有类都读一遍,判断有没有目标注解。项目小的时候无所谓,项目大了(几千个类)启动时间就会明显变慢。Spring Boot 支持了@Indexed注解(通过spring-context-indexer编译期生成候选类索引)来优化这一点,但实际项目里用的人不多,毕竟现代机器上这点启动时间对大部分应用可以接受。不过如果你在写一个对启动时间极其敏感的 CLI 工具或者 Serverless 函数,倒是可以考虑用 JavaConfig 显式指定 bean,减少扫描成本。但话说回来,这个场景比较小众,大多数时候你不会为启动省这几百毫秒去牺牲开发效率。
5. JavaConfig 配置类:最灵活、最类型安全的方式
5.1 从 @Configuration 到 @Bean
JavaConfig 里,核心就是@Configuration标注的类和@Bean标注的方法。每有一个@Bean方法,容器里就多一个 bean。
@Configuration public class AppConfig { @Bean public UserDao userDao() { return new UserDao(); } @Bean public UserService userService() { return new UserService(userDao()); } }拿到容器的方式也变了,不再读 XML 文件,而是使用AnnotationConfigApplicationContext:
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); UserService userService = context.getBean(UserService.class);这个方法执行完之后,容器里就有了userDao和userService两个 bean。看起来就是一个普通方法调用的过程,但里面有很重要的一条知识:带@Configuration的类,默认是被 CGLIB 增强过的,userDao()这个方法的调用不会真的每次都 new 一个新的UserDao,而是先从容器里拿。所以你即使反复调用userDao()方法,拿到的还是同一个单例实例。这是 JavaConfig 和普通 Java 方法最大的区别,也是它设计得最巧妙的地方。
但如果@Configuration的类被标记为proxyBeanMethods = false(Spring Boot 里经常能看到这么配),CGLIB 增强就会失效,userDao()方法的每次调用都会返回一个全新实例。这个差异在单体应用里可能感觉不到,但在某些依赖注入场景下会导致意想不到的 bug。所以我的理解是:proxyBeanMethods = true(默认值)适合有内部方法调用的配置类,false适合那些没有内部调用的简单配置类来提升启动性能。除非你确定配置类内部不会互相调用,否则别改成 false。
5.2 各种场景下如何优雅地写 @Bean
如果说注解方式是“把类自己交出去”,那@Bean方式更像是“你手动把对象递给容器”。最常见的场景是你要把一个无法加注解的类放进容器,典型分两类:第三方库里的类(比如 Redis 连接工厂、HTTP 客户端),以及当前你没法改源码的类。这时候@Bean几乎是唯一选择。
@Configuration public class HttpClientConfig { @Bean public RestTemplate restTemplate() { return new RestTemplateBuilder() .setConnectTimeout(Duration.ofSeconds(3)) .setReadTimeout(Duration.ofSeconds(5)) .build(); } }另一个高频场景是条件装配。Spring Boot 的自动配置大量依赖这个能力。@ConditionalOnProperty、@ConditionalOnClass、@ConditionalOnMissingBean这些条件注解可以用在@Bean方法上,意思是“满足条件才创建这个 bean,不满足就跳过”。这个机制的威力在于:你的配置类可以写死,但实际容器里有没有某个 bean,取决于运行时环境。这在写通用组件、框架插件时是核心利器。
@Bean @ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true") public CacheManager cacheManager() { return new ConcurrentMapCacheManager("userCache"); }还有个容易被忽略的标准是@Bean方法的名字。方法的名称默认就是 bean 的 id。如果你定义了getUserService()方法,bean 名就是getUserService,不是userService。如果后续用@Qualifier("userService")注入会失败。所以建议写@Bean方法时方法名就叫userService()、redisTemplate(),和生成的 bean 名保持一致,避免不必要的认知负担。
5.3 @Bean 方法间调用的真相:CGLIB 代理
前面说了@Configuration类默认被 CGLIB 增强,这里展开讲一行代码背后的原理。你写:
@Configuration public class AppConfig { @Bean public UserDao userDao() { return new UserDao(); } @Bean public UserService userService(UserDao userDao) { // 注意:我并没有在这里调用 userDao() 方法, // 而是通过参数接收 return new UserService(userDao); } }注意一点:在userService()方法里,我使用了参数UserDao userDao来接收依赖,而不是直接调用userDao()方法。这其实利用了 Spring 容器对@Bean方法参数的隐式解析——Spring 会自动从容器里找到UserDao类型或名字匹配的 bean 传进来。这种写法比直接方法调用在变量传递上更清晰,也避免了 CGLIB 代理相关的困惑。
如果你选择直接调用userDao()方法,那就要理解代理的作用。CGLIB 会生成一个AppConfig的子类,重写所有@Bean方法,方法内部先去容器里检查有没有对应的 bean(singleton 作用域下命中 DefaultSingletonBeanRegistry 里的单例池),有就直接返回,没有才执行真正的new UserDao()。所以userDao()方法实际上只会在第一次被真正执行一次。
如果@Configuration变成了@Component,或者proxyBeanMethods = false,CGLIB 增强就会丢失。userService()里调用userDao()时就会每次都执行一遍new UserDao(),创建出一个新的实例。如果UserDao有状态,比如内部有个计数器,那这个计数器在每个UserService实例里都会被重置。我当时排查过一个类似的 bug:一个全局状态机在注入后,每次访问状态都不对,查了半天发现是配置类上误写了@Component而不是@Configuration。
5.4 FactoryBean:另一种把对象交给容器的姿势
除了@Bean,JavaConfig 时代还有一个容易混淆的概念:FactoryBean<T>。它的玩法是——你定义了一个类来实现FactoryBean<T>接口,然后把这个类本身注册成 bean,Spring 在拿到这个 bean 时会调用getObject()方法,把返回的T类型对象当真正的 bean 用,而FactoryBean本身反而成了“工厂”。比如 MyBatis 的SqlSessionFactoryBean就是一个经典实现:
@Configuration public class MyBatisConfig { @Bean public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); return factoryBean; } }容器里被注入的其实是SqlSessionFactory类型(getObject()返回的类型),不是SqlSessionFactoryBean。如果你需要拿到FactoryBean本身,要用getBean("&sqlSessionFactory")加个&前缀。这是一个很冷门的语法,但了解它之后再看很多框架源码就不会懵了。
那FactoryBean和@Bean有什么区别?简单说:@Bean是“方法返回对象交给容器”,FactoryBean是“把这个类交出去,容器自动调用它的getObject()”。前者是配置层面的手段,后者是创建对象的工厂抽象。实际工作中,你写业务代码用@Bean就够了,但读框架源码时一定会碰到FactoryBean,所以这个点值得了解,不用深究。
6. 三种依赖注入手段对比:字段、Setter 与构造器
把 bean 交给容器之后,紧接着的问题就是依赖注入。Spring 支持三种注入方式,虽然这不是“把对象交给容器”的直接动作,但它是容器管理 bean 的核心部分,而且很多报错都出在这里。
| 注入方式 | 写法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 字段注入 | @Autowired private UserDao userDao; | 最简洁 | 难测试、隐藏依赖、无法 final | 不推荐 |
| Setter 注入 | @Autowired public void setUserDao(UserDao dao) {...} | 可选依赖友好 | 依赖可变、可能遗漏 | 可选依赖 |
| 构造器注入 | private final UserDao userDao; public UserService(UserDao userDao){...} | 不可变、必填依赖清晰 | 构造器参数多时稍显冗长 | 推荐 |
字段注入大概是 Spring 初学时代用得最多的写法,因为它最省事。但它在工程上的问题不小:第一,private字段的注入靠的是反射,IDE 和编译期根本不知道这个依赖存在;第二,单元测试时无法直接 new 一个对象把依赖塞进去,必须借助 Spring 或者反射工具;第三,被注入的字段不能是final,破坏了对象不可变性。所以我一直建议团队里新代码不要用字段注入。
构造器注入还有一个隐藏的优势:在测试的时候,你可以很自然地new UserService(mockUserDao),完全不需要 Spring 参与。这在写单元测试时爽到飞起。如果类的构造器参数太多,往往说明这个类的职责过重,这时应该考虑拆分,而不是换一种注入方式来逃避问题。
7. 生命周期和三级缓存:容器接管后到底做了什么
7.1 bean 的一生
把 bean 交给容器之后,容器并不是简单地new一下就完事。一个普通 bean 在容器里的完整生命周期大致是:
- 扫描/解析配置,生成
BeanDefinition; - 实例化:通过构造器创建对象(此时对象还不是完整 bean);
- 属性填充:完成
@Autowired、@Value等依赖注入; - 初始化前置:执行
BeanPostProcessor.postProcessBeforeInitialization; - 初始化:执行
@PostConstruct、InitializingBean.afterPropertiesSet()、init-method; - 初始化后置:执行
BeanPostProcessor.postProcessAfterInitialization,这里会生成 AOP 代理对象; - 使用:业务调用;
- 销毁:执行
@PreDestroy、DisposableBean.destroy()、destroy-method。
这里面最容易被忽略的是BeanPostProcessor。它是 Spring 容器“开挂”的核心机制,AOP、@Autowired本身、事务注解等等,底层全是靠它实现。你可以把BeanPostProcessor理解成“每个 bean 出生后都要过一遍的流水线工位”,每个工位可以对 bean 做加工。所以当你看到一个 bean 莫名其妙多了某些代理行为时,大概率就是某个BeanPostProcessor干的。
7.2 三级缓存为什么是三级而不是一级
三级缓存是和循环依赖强相关的机制。当你有两个 bean 互相引用时,比如 A 依赖 B、B 依赖 A,如果按常规流程:创建 A → 发现需要 B → 创建 B → 发现需要 A → 又回到创建 A,这就死循环了。
Spring 的三级缓存解决这个问题:
- 一级缓存
singletonObjects:存放完整的单例 bean; - 二级缓存
earlySingletonObjects:存放提前暴露的“早期引用”(对象已 new 出,但属性还没填充完成); - 三级缓存
singletonFactories:存放创建对象的工厂,用来生成早期引用。
流程简化说就是:创建 A → A 的实例化完成、属性未填充时,先把 A 的工厂放入三级缓存 → 填充属性时发现需要 B → 去创建 B → B 的属性填充时发现需要 A → 从三级缓存拿到 A 的工厂 → 生成 A 的早期引用 → 把这个早期引用注入给 B → B 创建完成 → 继续完成 A 的属性填充,最后把完整 A 放入一级缓存。
这里有个常见的误区:三级缓存不是用来解决性能问题的,而是为了解决“AOP 代理对象与普通对象不一致”的问题。如果只有一级缓存,早期 A 是普通对象,但最终 A 可能被BeanPostProcessor包装成代理对象,那 B 里持有的早期 A 引用和最终的单例 A 就不是同一个对象了。三级缓存加ObjectFactory的设计,确保早期暴露时就能通过getEarlyBeanReference拿到最终一致的代理对象。
需要明确的是,这只是 Spring 解决自身创建流程中循环依赖的一种机制,并且只对单例、非构造器注入的 bean 有效。构造器注入的循环依赖无法通过三级缓存解决,因为构造器在实例化阶段就必须拿到依赖对象,此时第三级缓存还没用上。@Scope("prototype")的 bean 循环依赖也无法解决,因为原型 bean 不存在“提前暴露”的意义。我见到的团队规范里,很多直接禁止循环依赖,这确实是最干净的方案——用@Lazy注解在构造器参数上打断循环也行,但治标不治本。
8. 高频报错排查实录:从报错入手反推原理
8.1UnsatisfiedDependencyException:缺依赖的典型场景
这个报错提示非常长,但核心关键词就是Unsatisfied dependency expressed through field 'xxx',直译就是“字段 xxx 的依赖无法满足”。比如:
Unsatisfied dependency expressed through field 'userDao'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.dao.UserDao' available排查这个报错,按这个顺序走基本不会漏:
- 看提示里是哪个字段,找到具体类是哪个;
- 检查这个类是否加了
@Component/@Service等注解——没加就是没交出去; - 检查包路径是否在扫描范围内——不在就是漏扫描;
- 检查注入的类型是否有多个实现——有多个就看看有没有
@Primary或@Qualifier。
我之前排查过一个贼隐蔽的案例:一个接口有两个实现类,都加了@Service,但其中一个实现类里的@Value("${app.timeout}")配置的值在配置文件中不存在。结果 Spring 启动时直接报UnsatisfiedDependencyException,而不是报@Value解析失败,因为@Value解析失败发生在属性填充阶段,先于@Autowired的类型匹配判断。很多人一看这个报错就去查依赖注入,查了半天没结果,其实是配置值的问题。所以我建议:这类报错先看完整堆栈,不要只看第一行。完整堆栈里那个Caused by往往才是根本原因。
8.2Consider defining a bean of type 'java.lang.Long':报错的信号远比字面意思多
热搜词里有一条很经典的报错:Consider defining a bean of type 'java.lang.Long' in your configuration.。这个报错看着荒诞,因为Long是 JDK 类型,你不可能去容器里注册一个Longbean。它实际的含义是:某个地方试图注入一个Long类型,但容器里没有这个类型的 bean。
什么场景会出现这种报错?最常见的是自动配置类里的条件判断失败。我举个例子:一个类构造器是public CacheManager(Long timeout),你想通过@Bean构造它,但容器里没有Long类型的 bean。这时候 Spring 就报这个。它其实是在提醒你:要么注册一个Long类型的 bean,要么把Long改成从配置读取的@Value("${cache.timeout}"),要么修改构造器参数。
这种报错最麻烦的地方在于:报错信息里提到的 bean 类型(比如Long、String、Integer)往往是基本类型,你第一反应是“这怎么可能有 bean”。但这恰恰说明,你的某个@Bean方法或者组件的构造器参数设计得不合理,把基本类型当注入依赖了。Spring 的设计哲学是:需要配置值,优先用@Value读取配置文件;需要复杂对象(比如DataSource、RestTemplate),才用依赖注入。把Long丢给容器管理,本质上是“想用容器管理一切”的思路走偏了。
8.3 循环依赖与三级缓存之外的问题
循环依赖相关报错的标志性文案是The dependencies of some of the beans in the application context form a cycle。看到这个报错,先确认循环链路到底是怎么形成的。最常见的两种:
- 构造器循环依赖:
new A需要 B,new B需要 A。此时三级缓存不生效,必须手动拆环。 - 代理类循环依赖:A 被切面代理,B 依赖 A,C 依赖 B 的深度链路。
我处理循环依赖的经验是:尽量从设计上消灭循环依赖,而不是用@Lazy或setter注入去“绕过”。循环依赖往往意味着职责划分不清晰。比如 A 依赖 B,B 又依赖 A,很可能 A 和 B 有一部分逻辑应该抽到 C 里。但如果确实有暂时绕不开的情况,@Lazy是开销最小的方案——它在构造器参数上标注后,Spring 会注入一个代理对象,真正调用时才去容器里找目标 bean。注意:@Lazy不解决循环依赖的“循环”本身,它只是打断了“此时必须拿到完整对象”的约束。
8.4 常见问题速查表
| 报错关键信息 | 可能原因 | 推荐排查方向 |
|---|---|---|
No qualifying bean of type ... available | 依赖类没加注解、包没扫描到、类型有多个实现 | 检查注解、扫描路径、@Primary/@Qualifier |
Unsatisfied dependency expressed through field ... | 字段类型容器里没有 | 看完整堆栈的Caused by,找原始原因 |
The dependencies of some of the beans form a cycle | 循环依赖 | 画依赖图,拆环或使用@Lazy |
BeanDefinitionStoreException | XML 解析失败或配置类有问题 | 看具体配置行号和标签 |
BeanCreationException | bean 创建过程异常(初始化方法抛错等) | 看堆栈Caused by,重点检查@PostConstruct和BeanPostProcessor |
NoUniqueBeanDefinitionException | 同一类型有多个 bean | 加@Primary或指定@Qualifier |
BeanNameNotUniqueException | bean 名称冲突 | 统一 bean 命名规范,检查配置 |
9. 三种方式如何选型与混用
很多人问一个问题:三种方式能不能混用?能,而且 Spring Boot 项目里每天都在混用。关键在于理解各自的边界。
我的推荐分层是这样的:
- 业务层类:用
@Service、@Repository、@Controller这类派生注解,让 Spring 自动发现; - 第三方库实例:用
@Configuration+@Bean集中装配,便于统一管理超时、连接池等参数; - 老项目或特殊中间件:保留 XML 配置,但要通过 import 方式整合进来,不要和注解混在同一个类里处理。
还有一点,@Configuration类里可以配合@ImportResource把 XML 引入进来,实现“新代码用配置类,老配置不破坏”的平滑过渡:
@Configuration @ImportResource("classpath:legacy-beans.xml") public class AppConfig { }这个注解在系统迁移时特别有用。我处理过一个老项目从 XML 全部迁移到注解的痛苦过程,当时就是先用@ImportResource把旧的 XML 留着,保证系统能继续运行,然后一个个把 XML 里的 bean 改成@Bean或注解方式,每改一个就删一段 XML。整个过程持续了好几周,但系统一直稳定运行,没有任何一个晚上因为迁移加班。这种渐进式改造的思路,比“一个大版本全部改完”要稳妥得多。
10. 最后分享几个小经验
写到这里,核心内容已经讲得差不多了。最后我再分享几个在实际项目中验证过的小经验,希望对你有帮助。
第一,遇到 bean 相关的报错,先看启动日志最前面的异常堆栈,而不是中间的业务报错。Spring 在启动失败时通常会把所有上下文加载失败的异常都列一遍,但根因往往只有一个。翻到Caused by那一行,看到的信息远比报错标题有价值。
第二,不要在 bean 的构造器或@PostConstruct里做耗时太长的操作。尤其是单例 bean,这些操作会在启动阶段阻塞容器初始化。我有一个项目,之前把数据预热逻辑放在@PostConstruct里,导致启动要两分钟。后来改成用ApplicationReadyEvent监听器来触发预热,启动时间瞬间降到十秒。容器管理 bean 的“初始化”阶段不是什么事情都适合做的。
第三,尽量让 bean 的创建和使用保持简单。如果一个@Configuration类里的逻辑越来越复杂,各种条件判断、属性注入、循环调用,你就要考虑拆分了。配置类和普通代码一样需要维护,过度设计的配置类比业务代码更难改。
最后,如果你看完了这篇文章,我建议你亲手做一个小实验:用三种方式分别把同一个类交给容器,然后调试看看ApplicationContext.getBeanDefinitionNames()里打印出来的 bean 信息。你会发现三种方式最终都殊途同归——容器里注册的都是BeanDefinition,只是“注册的入口”不同。这个实验做完,你对 Spring 容器管理 bean 的理解就真的打通了。