我刚接触Spring那会儿,最难受的不是语法,而是那种"黑盒感":一个类加个@Component,它就被容器接管了;另一个类里写个@Autowired,对象就像变魔术一样冒出来。后来负责了大一点的项目,帮同事排查过不少启动报错,才慢慢意识到,很多问题的根源其实出在同一个地方——大家没有把"Spring Bean注入"和"容器管理"当成一条完整的链路来看。前半段是怎么把类交给容器,后半段是怎么把Bean注入到使用方,中间还夹着容器的生命周期管理。这篇总结就是把我自己踩过、也帮别人排查过的这条路完整梳理一遍,先讲清楚容器到底解决了什么问题,再讲交给容器的三种方式,接着看容器内部怎么管理Bean的一生,最后落到注入使用的各种姿势和常见坑。适合刚学完Spring基础、想系统串一遍的同学,也适合每天写@Service但很少深究背后机制的人。
1. 先想明白:没有容器时,依赖管理是怎么失控的
1.1 两层依赖就让人头疼:从手动new说起
不要一上来就背IoC定义,先看一段最原始、没有容器的代码长什么样。假设你现在写一个下单功能,OrderService要调用UserService和InventoryService,最直觉的写法是直接在构造器里new:
public class OrderService { private final UserService userService; private final InventoryService inventoryService; public OrderService() { this.userService = new UserService(); this.inventoryService = new InventoryService(); } }这个写法在只有两层依赖时勉强能跑,但问题很快会暴露。有一天UserService的构造器改了,必须传入一个DataSource,那么OrderService的构造器也得跟着改,否则编译不过。如果UserService内部又new了别的组件,那整个依赖链就是一层层硬编码出来的"钢筋混凝土",牵一发而动全身。
更麻烦的是测试。你想给OrderService注入一个mock的UserService,但这个类在构造器里自己new了真实实现,没有外部入口可以替换。要么改代码加个setter,要么用反射硬塞,怎么看都别扭。这还只是两层依赖,真实项目的Service层动不动就是五六层调用,如果每层都自己new,维护成本直接爆炸。所以第一步要意识到:依赖不该由类自己创建,而应该由外部提供。这个"外部"就是我们说的容器。
1.2 IoC反转的到底是哪个"控制"
"控制反转"这四个字太抽象,我给同事讲的时候经常用一个点外卖的比喻。你不自己买菜、洗菜、做饭,而是打开外卖平台下一单,平台负责调度商家和骑手把饭送到你手上。做饭的控制权从你手上被"反转"出去了,但你的需求表达得更清晰——你只需要说"我要一份红烧肉"。容器也一样,它接管了"创建依赖、组装依赖"的控制权,类只需要说明"我需要一个什么类型的依赖"。
落到代码上,最直观的体现是构造器参数:
public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } }OrderService不再关心UserService是怎么创建出来的,构造器只声明"我需要一个UserService"。至于这个UserService是单例还是新对象、是真实实现还是mock,由外部容器来决定。这种"依赖由外部注入进来"的设计,就是依赖注入(Dependency Injection,DI),它是控制反转的一种实现方式。也就是说,IoC是思想,DI是它的落地手段,而Spring容器是这套思想的载体。
1.3 Bean在Spring里到底指什么
很多新手以为Bean就是指加了@Component注解的类,这个理解其实差了一点。Spring官方的定义很简洁:由Spring容器实例化、组装和管理的对象,都叫Bean。注意关键词是"由容器管理"。你自己new出来的UserService,就算类上面标了@Component,它也不是Spring意义上的Bean,它只是"一个普通对象"。Bean是"容器上下文中的对象",离开容器谈Bean没有意义。
为了管理这些Bean,容器在启动阶段会读取配置信息,把它们解析成BeanDefinition。你可以把BeanDefinition理解为Bean的"生产说明书",里面记录了类名、作用域、是否懒加载、依赖关系、生命周期回调方法等信息。容器之后所有的实例化、属性填充、初始化动作,都是按这份说明书执行的。理解这一点,后面看生命周期和第3章的内容就会顺畅很多。平时我们用@Component、@Bean、XML里的<bean>标签,本质上都是在往容器里注册BeanDefinition。
2. 把类交给容器的三种姿势:XML、注解扫描与Java Config
2.1 XML时代的 标签:最直白但也最啰嗦
早期Spring没有注解,所有Bean都要在XML里声明。写起来大概长这样:
<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"/> <bean id="orderService" class="com.example.service.OrderService"> <constructor-arg ref="userService"/> </bean> </beans>这种方式的优点是把所有Bean集中在一个文件里,项目里有哪些对象、谁依赖谁,扫一眼就能看个大概。但缺点也很明显:啰嗦。Bean一多,XML文件就变成几百行的"电话本",改一个类名或者调整构造函数,可能要翻好几个文件。而且XML是字符串配置,写错了没有编译期检查,启动时才报错。现在的实际项目很少见到从零写XML配置了,但维护老系统、或者某些历史遗留代码时还会碰到,至少要能看懂。如果你接手过十年前的老项目,大概率见过一个叫applicationContext.xml的文件,里面密密麻麻全是<bean>标签。
2.2 组件扫描与@Component家族:门槛最低的入门姿势
注解方式的核心是"组件扫描":告诉容器去哪些包下面找标了特定注解的类,把它们自动注册成Bean。配置方式很简单:
@Configuration @ComponentScan("com.example") public class AppConfig { }常见的业务类只需要加一个注解:
@Service public class UserService { }@Component、@Service、@Repository、@Controller对Spring来说本质是同一个东西,底层都是@Component的派生注解,功能上没有区别。区分使用更多是为了语义清晰:@Service表示业务层、@Repository表示数据访问层、@Controller表示Web层。你在代码里全用@Component也能跑,但别人看代码的时候就无法一眼看出类的分层角色,所以规范一点没坏处。
这里有个特别容易踩的坑:扫描路径。@SpringBootApplication自带@ComponentScan,但默认扫描的是启动类所在包及其子包。如果你的业务类放在启动类包之外,没被扫到,容器里就不存在这个Bean,后面注入时直接报NoSuchBeanDefinitionException。我帮人排查过不少这类问题,最后恍然大悟的居多——改一下包的摆放位置,或者显式指定scanBasePackages,问题就没了。
2.3 @Bean与@Configuration:把初始化逻辑握在自己手里
注解扫描解决的是"写在自己项目里的类",但如果是第三方库呢?你总不能在别人的类上加@Component。这时候就该@Bean出场了,典型写法是这样的:
@Configuration public class RestClientConfig { @Bean public RestTemplate restTemplate() { RestTemplate template = new RestTemplate(); template.setConnectTimeout(5000); template.setReadTimeout(5000); return template; } }关键点有两个。第一,@Bean方法返回的对象会被注册进容器,方法名默认就是Bean名,所以方法命名要规范,别随手写个getRestTpl(),后面@Qualifier("restTemplate")全靠这个名字。第二,@Configuration类本身会被CGLIB代理,被@Bean标注的方法在容器内部被调用时,会优先返回容器里已存在的单例对象,而不是重新执行一遍方法逻辑。这一点面试常考,实际使用中最直观的体现是:在多个@Bean方法里通过方法调用来复用别Bean时,拿到的是同一个单例。
@Bean还有一个优势场景:初始化逻辑比较复杂,不是单纯new一下就完事。比如创建连接池要设置一堆参数、要校验配置合法性,写在方法体里非常顺手。如果是用组件扫描,这些初始化逻辑就得塞到@PostConstruct方法里,表达上不如@Bean方法直观。
2.4 三种方式如何选择:这不是一道单选题
我经常被问"到底该用哪种方式",其实三种方式可以共存,不是非此即彼。这里我把它们放一张表里对比一下,方便你结合团队和项目的实际情况做取舍:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| XML配置 | 老项目维护、无注解环境、需要集中管理 | 所有Bean集中可见,配置与代码分离 | 啰嗦、字符串无编译期检查、重构困难 |
| 组件扫描 | 自己项目里的业务类 | 写起来最快、代码即配置、可读性好 | 隐式注册,Bean全貌需要靠工具查看 |
| Java Config | 第三方库集成、复杂初始化、条件装配 | 初始化逻辑可控、类型安全、可编程 | 需要额外写配置类,代码量比注解多 |
拿我自己的习惯来说:项目自己的Service、Repository、Controller统一用@Service、@Repository这套注解;引入中间件客户端、第三方SDK时用@Configuration+@Bean;XML基本只在维护老项目的时候碰。至于网上经常争论的"注解优于XML"还是"XML优于注解",我认为没有银弹,关键是团队能不能形成统一规范。混用没问题,但别一个项目里既有XML又用注解又用Java Config还彼此覆盖,那才是真正的灾难。
3. 容器拿到Bean之后:实例化、缓存与生命周期
3.1 单例不是"省内存"那么简单
说一个经常被误解的点:Spring默认把Bean的作用域(Scope)设为singleton,很多人理解成"为了节省内存"。省内存确实是副产品之一,但更核心的原因是为了共享无状态服务。Service层、Repository层这些Bean通常不持有用户相关的可变数据,它们在设计上就是线程安全的无状态对象,整个容器只需要一份实例,所有调用方共用。这样做既避免了反复创建的消耗,也让对象的行为一致。
如果确实需要每次拿到新实例,可以把作用域改成prototype:
@Component @Scope("prototype") public class TaskContext { }但要记住,prototype不意味着Bean创建完就没人管了——容器只在每次获取时帮你new一个新实例,之后的生命周期回调(除了初始化)容器基本不负责,销毁需要你自己处理。用prototype的场景也不多,常见的是"每个任务要一份独立的上下文,不能和别的任务串数据"。这里提前打个预防针:写单例Bean时,千万别在里面放一个不断变化的成员变量,比如某个List用来存请求数据。看似方便,但单例是全局共享的,高并发下数据必然互相覆盖。这类Bug排查起来非常痛苦,因为问题不是必现的,而是偶发的、和请求顺序强相关。我就在这个上面栽过一次,从此对"单例里的可变状态"异常敏感。
3.2 Bean的一生:实例化、属性填充、初始化、销毁
理解Bean的生命周期,是排查很多容器问题的底子。一个常规单例Bean从生到死,大致经历这么几步:
- 实例化:根据
BeanDefinition,通过构造器创建对象。 - 属性填充:执行依赖注入,也就是Spring根据配置把依赖的Bean塞进来。
- Aware回调:如果Bean实现了
BeanNameAware、ApplicationContextAware等接口,在这里回调。 - BeanPostProcessor前置处理:
postProcessBeforeInitialization。 - 初始化:依次执行
@PostConstruct方法、InitializingBean#afterPropertiesSet、XML里的init-method。 - BeanPostProcessor后置处理:
postProcessAfterInitialization,AOP代理多数是在这里生成的。 - 使用:Bean交付给业务代码。
- 销毁:容器关闭时,依次执行
@PreDestroy、DisposableBean#destroy、destroy-method。
用代码验证一下执行顺序会更直观:
@Service public class UserService implements InitializingBean, DisposableBean { public UserService() { System.out.println("1. 构造器"); } @PostConstruct public void postConstruct() { System.out.println("3. @PostConstruct"); } @Override public void afterPropertiesSet() { System.out.println("4. afterPropertiesSet"); } @PreDestroy public void preDestroy() { System.out.println("5. @PreDestroy"); } @Override public void destroy() { System.out.println("6. destroy"); } }实际的顺序是构造器 → 属性填充 → postProcessBeforeInitialization → @PostConstruct → afterPropertiesSet → init-method → postProcessAfterInitialization。记住一个最常用的结论:@PostConstruct先于afterPropertiesSet执行。如果你在@PostConstruct里做了某些初始化,又在afterPropertiesSet里重复做,那是多余的。实际开发里我更推荐只用@PostConstruct和@PreDestroy,因为语义直接,而且不侵入业务类的接口继承体系。
3.3 BeanFactory与ApplicationContext:容器的两种形态
聊容器管理,绕不开Spring的两个顶层接口。BeanFactory是最底层的容器,只提供最基础的Bean获取和管理能力,getBean就是它定义的。ApplicationContext是它的子接口,在基础上增加了AOP支持、事件发布、国际化、环境解析等能力,日常项目里用的"容器"其实都是ApplicationContext。
这里有一个比较重要的行为差异:ApplicationContext在启动(refresh()触发)的时候,默认会把所有单例Bean提前实例化一遍,也就是传说中的preInstantiateSingletons。这带来一个体验上的差异:很多配置错误、依赖缺失问题,在应用启动阶段就暴露出来,而不是等第一次调用才炸。从排查问题的角度说,这是好事——启动报错比运行时报错容易处理得多。而BeanFactory默认是懒加载的,getBean时才创建,等你发现问题时程序都已经跑起来了。所以实际项目一定要用ApplicationContext,别自己去撸BeanFactory。
另外有一个排查技巧:ApplicationContext启动过程中任何一个Bean创建失败,整个启动流程都会失败并回滚。所以当你遇到类似"Bean named xxx is expected to be of type yyy but was actually of type zzz"这种报错时,本质就是某个Bean在实例化阶段类型不匹配或代理出了问题。把日志里的Caused by链路从头看到尾,通常能找到真正的根因,而不是只看最后一行的Exception。
4. 注入的三种姿势与@Autowired的查找逻辑
4.1 构造器、Setter还是字段注入:Spring官方推荐为什么变了
依赖注入有三种常见姿势,写法区别很大:
// 字段注入 @Component public class OrderService { @Autowired private UserService userService; } // Setter注入 @Component public class OrderService { private UserService userService; @Autowired public void setUserService(UserService userService) { this.userService = userService; } } // 构造器注入 @Component public class OrderService { private final UserService userService; // Spring 4.3之后,单构造器可以省略@Autowired public OrderService(UserService userService) { this.userService = userService; } }字段注入写起来最爽,代码最少,但代价是依赖被隐藏了。你只看类定义的话,根本不知道这个类有哪些依赖,而且它和Spring容器强耦合,想单独做单元测试,必须靠Spring容器去new,否则字段永远是null。Setter注入灵活一点,支持之后动态替换依赖,但会让对象变得可变,业务代码里不太推荐。
构造器注入是Spring官方文档明确推荐的方案。原因很简单:依赖以构造器参数形式暴露,对象创建出来时所有依赖都已就位,字段可以用final修饰,保证不可变;测试时直接new一个对象、传入自己构造的mock依赖就行,完全不需要容器。Spring 4.3之后,如果一个类只有一个构造器,@Autowired都可以不加,容器会自行选择。这里有个容易忽略的小知识:类里有多个构造器时,必须用@Autowired显式标注其中一个,否则容器不知道该用哪个,会直接报错。
4.2 @Autowired的查找过程:先byType,后byName
@Autowired的查找规则可以总结成一句话:先按类型找,多个候选再按参数名/字段名找,还不行就报错。
假设有两个实现类:
@Service public class VipUserService implements UserService { } @Service public class NormalUserService implements UserService { }这时候写:
@Component public class OrderService { @Autowired private UserService userService; }容器按UserService类型搜索,找到两个候选:vipUserService和normalUserService。接着Spring会对字段名和Bean名做匹配,发现字段名是userService,两个Bean里没有一个叫这个名字,于是抛出NoUniqueBeanDefinitionException。但如果字段名改成vipUserService,就能和vipUserService这个Bean匹配上,注入成功。如果把实现类改名,比如其中一个类用@Service("userService")显式指定Bean名,那么字段名userService也能匹配到它。
这个机制在方法参数上同样生效。也就是说,@Autowired并不是"直接按类型简单粗暴地注入",它在背后有一套规则,理解之后,遇到"为什么我明明有两个实现类却不报错"这类问题,就能快速反应过来——多半是字段名恰好匹配上了某个Bean名。
4.3 多个候选Bean:@Primary、@Qualifier、@Resource怎么权衡
当确实存在多个实现类,而你又想明确指定用哪一个,有三种处理方式:
@Primary:标记某个Bean为优先候选。多个实现里有一个被标了@Primary,@Autowired按类型查找时就默认用它。@Qualifier:显式指定Bean名。比如@Qualifier("vipUserService")直接在注入点上指定名字,优先级高于@Primary。@Resource:JDK的JSR-250注解,默认按名称注入。它不支持@Primary的语义,所以用起来更直接,但和Spring的@Autowired风格不太统一。
我的建议是:如果你要表达的是"默认情况下用这个实现,但调用方可以覆盖",用@Primary;如果你要表达的是"这个调用方就必须用某个具体实现",用@Qualifier。两者的语义层级不同,混着用也不冲突。至于@Resource,除非是在维护老代码,否则新代码里我更倾向统一用@Autowired+@Qualifier,因为Spring生态里对@Autowired的周边支持更丰富(比如@Autowired可以为空处理)。
5. 实测最容易翻车的地方:循环依赖与注入失败排查
5.1 循环依赖:三级缓存到底做了什么,又没做什么
循环依赖是Spring容器管理里最有名的一个坑,形式很简单:A依赖B,B又依赖A。容器在创建A的时候发现需要B,转去创建B,B又需要A,于是两边互相等,如果没有特殊机制,创建流程就死循环了。
Spring的解决方案是著名的"三级缓存":
- 一级缓存
singletonObjects:存放已经完整创建好的单例Bean。 - 二级缓存
earlySingletonObjects:存放提前暴露的"半成品"Bean,即对象已经new出来了,但属性还没填充完。 - 三级缓存
singletonFactories:存放能生成该Bean早期引用的工厂。
它们在字段注入的循环依赖里是这样协作的:创建A时,先把A的工厂放进三级缓存;A需要B,去创建B;B的创建过程中需要A,此时从三级缓存里拿到A的工厂,通过它得到一个早期引用,放进二级缓存,注入给B;B创建完成,A再继续把B注入进来,最终A也创建完成。整个过程的核心是"先给对象,再补属性",通过提前暴露不完整的对象来打破僵局。
但注意,这套机制有明确边界。构造器注入的循环依赖是解决不了的,因为构造器执行时对象还没new出来,没有"半成品"可以提前暴露,只能因死循环而报错。另外prototype作用域的Bean也解决不了循环依赖,因为原型Bean不在缓存体系里。Spring Boot 2.6之后默认关闭了循环依赖支持,启动时如果检测到直接报错。我个人的看法是:遇到循环依赖,优先打破代码结构,比如把互相调用的逻辑下沉到一个新组件里,而不是继续靠在注入方式上打补丁。靠@Lazy在构造器参数上做延迟代理能绕过去,但属于"治标",代码里留着结构性问题,后面改起来还是别扭。
5.2 注入失败排查:最经典的两种NoSuch报错
注入相关的报错,90%以上集中在两个异常上。第一个是NoSuchBeanDefinitionException,意思是容器里压根找不到这个Bean。常见原因按出现频率排一下:
- 类不在
@ComponentScan扫描范围内,最常见,多半是启动类包路径和业务类包路径不一致。 - 类上忘了加
@Component或@Service注解。 - 注入的是接口/抽象类,但没有任何实现类。
- 配置类里
@Bean方法写错返回类型,或者@Bean方法所在的配置类没被加载。 - 条件装配没命中,比如
@ConditionalOnProperty配置的开关没打开。
排查的时候,最快的方式是先把"容器里到底有哪些Bean"看一遍。本地调试可以加一个临时的ApplicationRunner:
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }@Component public class BeanLister implements ApplicationRunner { @Autowired private ApplicationContext context; @Override public void run(ApplicationArguments args) { for (String name : context.getBeanDefinitionNames()) { System.out.println(name); } } }打印出来之后,看看目标Bean到底存不存在,再对照上面的清单逐条排查,比自己瞎猜高效得多。
第二个经典异常是NoUniqueBeanDefinitionException,在第4.2节已经讲过,这里补充一个前缀场景:多个实现类都没标@Primary,字段名又对不上任何一个Bean名,就会报这个。解决方式就是@Qualifier或@Primary,另外顺便查一下是不是不小心注册了两个相同名称的Bean。
5.3 单例Bean里注入原型Bean:作用域失效的经典场景
这是"容器管理Bean作用域"和"注入"交叉产生的问题,特别容易误导人。看这个例子:
@Component @Scope("prototype") public class Task { } @Service public class TaskRunner { @Autowired private Task task; }直觉上,Task是prototype,每一次使用都应该是新实例。但实际上,TaskRunner是单例,它在创建时只会被注入一次Task,之后TaskRunner里持有的永远是那同一个对象。也就是说,作用域是prototype的Bean,如果被注入到一个singleton的Bean里,它的"原型效果"就失效了。
解决思路是"延迟获取",把注入Task改成注入"获取Task的能力",常用的有三种方案:
// 方案一:ObjectProvider @Service public class TaskRunner { @Autowired private ObjectProvider<Task> taskProvider; public void run() { Task task = taskProvider.getObject(); } } // 方案二:@Lookup @Service public abstract class TaskRunner { @Lookup public Task getTask() { return null; // Spring会动态生成子类来覆盖这个方法 } } // 方案三:Scoped Proxy @Component @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS) public class Task { }我在实际项目中优先推荐ObjectProvider。原因有两个:一是它语义直白,代码读起来就能理解"每次获取一个新实例";二是它不用给类加proxyMode这种绕来绕去的修饰,也不要求类变成抽象类。@Lookup写起来最简洁,但类会变成抽象类,影响静态分析;Scoped Proxy方案最隐蔽,注入进来的其实是一个代理对象,很多人看到一堆$$EnhancerBySpringCGLIB$$的类名直接懵了。这三种方案选一个用就行,但一定要知道它们背后都指向同一个思想:prototype的Bean不该被过早地"绑死"在单例里。
最后再分享一点个人经验。我带了几年新人,发现能把Bean注入和容器管理这两件事真正讲清楚的人,遇到Spring相关的报错时,排查速度总是快很多。原因不复杂——注册、查找、实例化、注入、销毁,这本来就是一个完整闭环,你只懂其中一环,报错时就会像无头苍蝇一样乱试。如果看完这篇你还是觉得有点绕,建议自己动手做个10分钟的小实验:写一个接口、两个实现类,分别试@Primary和@Qualifier切换注入;再造一个字段注入的循环依赖,亲眼看一遍启动日志里的循环依赖报错长什么样;再试一次单例注入原型的效果,用ObjectProvider改掉。这几个实验跑通一遍,比你翻十遍教程记得都牢。