Spring框架里的IoC(控制反转)是个“背概念容易、真用明白难”的知识点。刚开始学Spring时,我能在十分钟内背出“把对象的创建和管理交给容器”这句话,但直到第一次在项目里遇到构造器里互相引用的循环依赖,第一次因为漏标@Service导致NoSuchBeanDefinitionException,第一次发现static字段上的@Autowired一直是null,才意识到自己对IoC的理解只是停留在表面。这篇文章是Spring详解系列的第二篇,我想用一线开发者的视角,把IoC从设计动机、内部容器、Bean生命周期、依赖注入选型、自动装配原理到日常高频报错的排查方法,完整地串联一遍。适合正在学Spring的初学者建立整体框架,也适合工作一两年后回炉细读的开发者补齐底层认知。
1. 控制反转:先搞清楚“反转”的到底是什么
1.1 没有IoC时,代码长得什么样
先看一段很典型的“反面教材”。过去写Service层时,最常见的做法是在构造器里直接new出依赖:
public class UserService { private final UserDao userDao; public UserService() { this.userDao = new JdbcUserDao(); } public User getById(Long id) { return userDao.findById(id); } }这段代码三个问题都很致命。第一,UserService和JdbcUserDao死死绑在一起,哪天想把实现换成MyBatis版或者内存Mock版,必须打开UserService的源码改。第二,单元测试没法做,因为new JdbcUserDao()会真的去连数据库,你无法轻易塞一个假的UserDao进去。第三,依赖的依赖也要自己组装——JdbcUserDao如果还需要一个DataSource,构造函数就变成new UserService(new JdbcUserDao(dataSource)),而dataSource背后又有一堆连接池参数,层层new下去,对象装配的复杂度全部堆到调用者身上。
用一个生活化的类比来解释:你开了一家餐厅,从种菜、采购调味品、联系食材供应商到收银全都要自己干。某天供应商涨价要换人,你从后厨到账本全要跟着改。IoC容器就像是一个成熟的中央供应链,你只需把需求清单报给它,它负责把食材按时按量配送到后厨,你只管专心炒菜。至于菜从哪块地里来、物流走哪条线,跟你没有关系。
1.2 反转的本质是控制权的转移
很多人把IoC理解成“容器帮我创建对象”,这个说法不准确。IoC真正反转的不是创建这个动作,而是“控制权”——谁来决定创建哪个实现类、用单例还是多例、在什么时机创建、以及对象之间的装配关系。
传统的写法里,控制权在调用者手上:我自己new,我自己决定实现类。引入IoC之后,调用者只声明“我需要一个UserDao”,至于UserDao的实例化、生命周期管理、具体选哪个实现,全部交给容器统一掌控。容器读取配置、扫描注解、解析依赖关系,然后在合适的时机把对象送进来。对象本身并不知道是谁创造了它,只管用就好。
这里有个很容易混淆的概念需要讲透:IoC是思想,依赖注入(DI)是实现手段。IoC可以通过依赖注入来实现,也可以通过依赖查找来实现,只不过现代Spring生态中DI占了绝对主导。你平时写的构造器参数、@Autowired注解、XML里的constructor-arg,本质上都是“告诉容器我要什么”,让容器在合适的时机把依赖送进来。理解这一点后,再去读Spring文档时会有一种豁然开朗的感觉——原来文档里反复强调的IoC容器、DI、Bean装配,说的其实是同一件事的三个方面。
1.3 IoC带来的实际收益
IoC带来的收益不是理论上漂亮,而是实打实能落地的。第一是解耦。把依赖收口到接口上,替换实现类不用改业务代码,改配置或注解就行。第二是可测试性。一个基于构造器注入的类,测试里直接new一个Mock实现传进去,不需要启动整个容器。第三是统一管理带来的“规模红利”。单例的复用、Bean的生命周期、AOP代理的植入,全部由容器在创建对象的过程中统一搞定,开发者的心智负担大幅降低。
写一个依赖接口的典型形态:
public interface UserDao { User findById(Long id); } @Repository public class JdbcUserDao implements UserDao { // 数据库实现 } @Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } }放在Spring容器里运行时,容器会自动把JdbcUserDao注入UserService;放到单元测试里,你直接new UserService(new MockUserDao())就能跑通业务逻辑。这种“换个环境就能无缝替换依赖”的能力,是IoC最核心的工程价值。很多设计模式试图解决耦合问题,但没有一个能像IoC这样,从对象创建这个最底层环节就把耦合切断。
2. 容器核心:BeanFactory、ApplicationContext与Bean的一生
2.1 BeanFactory与ApplicationContext的关系
Spring容器里有两个核心接口,初学者经常被绕晕。BeanFactory是最底层的容器接口,定义了getBean、containsBean、isSingleton这些最基本的方法。ApplicationContext继承了BeanFactory,在它之上又扩展了事件发布、国际化、资源加载、环境抽象、AOP整合这些企业级能力。
实际项目中几乎不会直接用BeanFactory,大家用的ApplicationContext实现类,比如AnnotationConfigApplicationContext、ClassPathXmlApplicationContext,本质上都是“BeanFactory + 一堆高级功能”。举一个例子,你在Spring Boot里经常用到的ApplicationContextAware回调、ApplicationEventPublisher事件发布,这些都是ApplicationContext的扩展能力,裸的BeanFactory不提供。
两者还有一个关键差异:加载时机。BeanFactory默认在getBean时才会实例化对象,属于懒加载;ApplicationContext默认在启动阶段就会把单例Bean提前实例化好。启动时多花一点时间,换来的是提前发现配置错误,而不是等到运行时访问那个Bean才突然炸出来。所以我的建议很直接——除非是做嵌入式框架这类极端场景,否则一律用ApplicationContext,别碰BeanFactory。
2.2 从注册到销毁:Bean的完整生命周期
理解IoC绕不开生命周期。Bean在容器里的经历可以拆成一条完整的流水线:
- 解析配置(XML、注解、JavaConfig),生成BeanDefinition。
- 把BeanDefinition注册到容器内部。
- 实例化前扩展点:BeanPostProcessor的postProcessBeforeInstantiation等方法此时有机会拦截。
- 实例化:通过构造器或工厂方法创建对象。
- 属性填充:也就是依赖注入阶段,@Autowired、@Value在这里生效。
- Aware回调:BeanNameAware、BeanFactoryAware、ApplicationContextAware等接口被回调。
- 初始化前扩展点:postProcessBeforeInitialization。
- 初始化:执行@PostConstruct、InitializingBean.afterPropertiesSet、自定义init-method。
- 初始化后扩展点:postProcessAfterInitialization,AOP代理通常在这一步生成。
- 使用Bean。
- 销毁:执行@PreDestroy、DisposableBean.destroy、自定义destroy-method。
这条流水线可以类比新员工入职:登记身份信息(BeanDefinition)→ 行政审核(后置处理器)→ 到岗报到(实例化)→ 配电脑开账号(属性填充)→ 入职培训(初始化)→ 投入工作(使用)→ 离职交接(销毁)。任何一个环节出问题,都要看这个环节对应的扩展点在哪里。
其中初始化环节的执行顺序很重要:@PostConstruct先执行,然后是InitializingBean接口的afterPropertiesSet,最后才是XML里配置的init-method。销毁顺序同理:@PreDestroy、DisposableBean的destroy、自定义destroy-method。我在项目中见过有人在初始化回调里写了重DB操作导致启动变慢,也见过有人不理解顺序、把依赖注入放在@PostConstruct里手动赋值导致重复初始化,这些坑都源于对生命周期顺序不够清晰。
2.3 作用域:单例之外还有哪些选择
Bean的作用域决定了容器以什么频率创建实例。默认是singleton,也就是每个Spring容器只创建一个实例,大家共享这一个对象。绝大多数无状态Service、DAO都应该用单例,这也是Spring默认设计的原因——无状态对象本身就是线程安全的,单例可以大幅节省内存。
除了单例,还有prototype,每次获取Bean时都创建一个新实例,适合有状态的组件。但用prototype要记住一个坑:Spring只负责创建,不负责销毁prototype Bean,对象的资源清理需要自己处理。Web环境下还有request、session、application三种作用域,分别对应一次请求、一个会话、整个ServletContext的生命周期。
不同作用域的差异用一个表格就能看清:
| 作用域 | 实例数量 | 创建时机 | 典型场景 |
|---|---|---|---|
| singleton | 整个容器一个 | 容器启动时预创建 | 无状态Service、DAO |
| prototype | 每次获取一个 | 每次getBean或注入时 | 有状态组件、非线程安全对象 |
| request | 一次请求一个 | 请求开始 | 保存请求上下文的组件 |
| session | 一个会话一个 | 会话开始 | 用户会话状态对象 |
| application | 整个应用一个 | 应用启动 | 跨会话共享的全局状态 |
做设计时一定要想清楚“这个对象有没有状态”。有状态对象塞进singleton里,轻则数据错乱,重则并发安全问题连环爆。这是我在代码评审里最长看到的问题之一:一个本该是prototype的类,因为默认没注意,被注册成了单例,然后多个用户请求共享了同一份可变状态。
2.4 生命周期扩展点怎么用
日常开发中,用得最多的生命周期扩展点是@PostConstruct和@PreDestroy,再进阶一点就是BeanPostProcessor。举个例子,在Bean初始化前后做点事情:
@Component public class MessageService implements InitializingBean, DisposableBean { @PostConstruct public void init() { // 第一步执行 System.out.println("PostConstruct"); } @Override public void afterPropertiesSet() { // 第二步执行 System.out.println("InitializingBean"); } public void setup() { // 第三步执行,需要在配置里指定 initMethod } @PreDestroy public void shutdown() { // 销毁第一步 System.out.println("PreDestroy"); } @Override public void destroy() { // 销毁第二步 System.out.println("DisposableBean"); } }具体用哪种要看场景。自己写的类优先用@PostConstruct/@PreDestroy,它们不依赖Spring的接口,语义清晰;InitializingBean和DisposableBean这类Spring接口会把业务代码和框架耦合在一起,能不用就不用;如果在@Bean配置里注册第三方库对象,就用initMethod和destroyMethod属性,例如@Bean(initMethod = "start", destroyMethod = "stop"),这样不用修改第三方代码也能挂接生命周期。
我自己的经验是,自定义BeanPostProcessor这种重武器尽量少用。它是框架级的全局拦截器,作用在所有Bean上,写错一个判断就会影响整个应用。如果你只是想对某个特定类的Bean做处理,先判断类型再处理,并且一定要记得返回bean本身,否则会把正常的Bean替换掉。
3. 依赖注入的三种方式:选型、对比与避坑
3.1 构造器注入:默认首选
构造器注入是把依赖作为构造器参数传入:
@Service public class OrderService { private final InventoryService inventoryService; public OrderService(InventoryService inventoryService) { this.inventoryService = inventoryService; } }Spring 4.3以后,单个构造器时可以省略@Autowired,容器会自动按参数类型和名字装配。所以上面的代码连注解都不用加。
为什么在现代Spring实践里,构造器注入成了默认首选?原因很实在。第一,依赖关系编译期可见,一个类需要什么东西,看构造器签名就一清二楚。第二,final关键字让依赖不可变,避免运行期被重新赋值。第三,单元测试不需要启动Spring容器,直接new OrderService(mockInventoryService)就行。第四,构造器在对象创建时就必须完成依赖注入,如果存在循环依赖,启动阶段就会暴露出来,而不是拖到业务运行期才炸。
这跟“把依赖关系写进合同”很像。合同里写清楚要哪些原材料,缺了哪一个,工厂压根就不开工,绝不会等到产品做了一半才发现缺料。
3.2 Setter注入:什么时候才用它
Setter注入在Setter方法上加@Autowired:
@Service public class OrderService { private InventoryService inventoryService; @Autowired public void setInventoryService(InventoryService inventoryService) { this.inventoryService = inventoryService; } }在Spring早期版本里,官方文档推荐的是Setter注入,原因是可以处理循环依赖,且依赖可以在运行期重新配置。但随着构造器注入的普及和循环依赖被重新审视,Setter注入的主流地位已经被取代。
那它还有没有用?有。当依赖是可选时,比如一个组件有一个告警服务,有就发通知,没有就静默,用Setter注入加@Autowired(required = false)就很合适。第三种场景是那些需要调用默认构造器的第三方类。但绝大多数业务类,我都建议优先走构造器注入。Setter方法看起来灵活,实际上给了运行期随意替换依赖的口子,反而不利于保持对象状态的一致性。
3.3 字段注入:便捷背后的隐患
字段注入应该是最常见也最容易被吐槽的写法:
@Service public class OrderService { @Autowired private InventoryService inventoryService; }写起来确实爽,一个注解就完事。但字段注入的隐患是系统性的。第一,依赖被藏在了私有字段里,类的依赖面从外部完全不可见,别人看这个类根本不知道它依赖谁。第二,mock测试必须依赖Spring容器或者反射工具,没法直接new,测试成本被拉高。第三,字段没法设成final,对象状态不再不可变。第四,这种写法往往会助长“哪里需要就注入到哪里”的投机习惯,导致依赖关系理不清、类越写越臃肿。
Spring官方对字段注入的态度一直很明确:不推荐。现实中我却见过大量老项目满屏字段注入,代码也能跑,但重构和测试的痛苦是真真切切的。我的态度是:新代码一律不要用字段注入,存量代码不用急着一次性推翻,在改动相关类时顺手改造成构造器注入,逐步消化即可。
3.4 循环依赖:成因、场景与破解
循环依赖就是A依赖B,B又依赖A,形成了一个环。Spring单例Bean在Setter或字段注入场景下,默认可以处理环,靠的是内部的三级缓存机制。简单概括,三级缓存就是:一级缓存存完整实例,二级缓存存早期暴露的半成品,三级缓存存生成早期引用的工厂。它的设计主要为了兼容AOP代理,保证提前暴露的对象和最终代理对象是同一个。这个机制本身很精巧,但我不建议把它当成解决循环依赖的手段。
原因很简单:循环依赖本质上是一个设计坏味道。两个类互相引用,通常意味着它们职责划分不清,或者被强行塞进了同一层。Spring Boot 2.6以后默认禁止循环依赖,启动时直接报错,我认为这是好事,逼着大家直面设计问题。非要临时解决,可以用@Lazy在任意一段上加延迟注入,容器注入一个代理对象,真正调用时才去解析依赖。但长期方案还是重构:把公共依赖抽出去,或者使用事件解耦,从根本上断环。
构造器注入的循环依赖无法靠三级缓存解决,因为构造阶段对象根本没创建完,无法提前暴露。prototype作用域的循环依赖同样无解。如果启动日志里出现了循环依赖的报错,先别忙着加@Lazy,静下心看看这两个类的职责是不是边界不清。
4. 配置方式的三次演进:XML、注解到JavaConfig
4.1 XML时代:一切显式声明
Spring早期的配置方式就是一堆XML文件,描述Bean的创建和依赖关系:
<bean id="userDao" class="com.example.dao.JdbcUserDao"/> <bean id="userService" class="com.example.service.UserService"> <constructor-arg ref="userDao"/> </bean>XML的好处是Bean的注册集中在一个文件里,全局一览无余。但它的问题也很明显:类型不安全,改个类名、替换实现类是纯文本操作,重构工具很难帮你检查;文件越写越长,一个中型项目动辄上千行配置;排查问题要在Java代码和XML之间来回跳。现在还能在很多老项目的src/main/resources里看到这类配置,维护它们需要极大的耐心。新项目再写XML定义Bean,我是不太支持的。
4.2 注解时代:扫描加标记
注解驱动是Spring发展史上最关键的一次变化。给类加上@Component、@Service、@Repository、@Controller,再通过@ComponentScan扫描指定包,容器就会自动发现这些类并注册为Bean。依赖关系则用@Autowired自动装配。
@ComponentScan(basePackages = "com.example") public class AppConfig { }注解时代把定义和代码放在一起,开发效率大幅提升。代价是Bean的注册分散在各处,全局的Bean图要靠IDE或工具才能看清楚。用注解时最容易犯的错有两个:一个是忘记加@ComponentScan或者扫错了包,导致Bean根本没注册;另一个是把@Autowired随意一放,不思考合适性。注解不是免死金牌,它只是简化了声明方式,设计原则依然要守住。
4.3 JavaConfig时代:类型安全的配置即代码
JavaConfig把配置变成了Java代码,用@Configuration类加@Bean方法描述Bean:
@Configuration public class DataSourceConfig { @Bean public DataSource dataSource() { DataSource ds = new DataSource(); ds.setUrl("jdbc:mysql://localhost:3306/demo"); ds.setUsername("root"); ds.setPassword("password"); return ds; } @Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); } }相比XML,JavaConfig最大的优势是类型安全:方法签名直接表达依赖,编译期就能发现错误。第二个优势是可以写逻辑,创建Bean中间可以做条件判断、循环、读取外部配置,这些都是XML做不到的。第三个优势是IDE友好,重构、跳转、调试都顺畅。
有一个容易踩的细节:在@Configuration类里的@Bean方法,Spring会通过CGLIB代理保证单例语义,也就是说你连续调用两次dataSource()方法,拿到的是同一个实例。但如果把@Bean方法放到普通@Component类里,那就变成了Lite模式,方法每次调用都会新建对象,单例语义会失效。这个差异非常隐蔽,不仔细阅读源码很容易掉坑。
4.4 现代项目的配置基线
现在新建一个Spring项目,配置方式基本可以套用一个固定组合。业务自己的对象:用@Component派生的注解加构造器注入。第三方库和基础设施:用@Configuration加@Bean方法显式定义。外部配置的绑定:用@ConfigurationProperties这样的方式,把application.yml里的配置项映射成类型安全的配置类。老XML能不碰就不碰,新代码不要再新增XML Bean定义。
再补充一个有用的工具:@Profile和@ConditionalOnXxx。@Profile可以按环境切换Bean,比如dev环境用内存数据库、prod环境用真实数据库;@ConditionalOnXxx则是Spring Boot自动配置的基石,按classpath依赖、配置项、Bean是否存在来决定是否创建Bean。对IoC的理解到这里,基本就够日常使用了。
5. 自动装配底层:@Autowired到底怎么找到Bean的
5.1 查找规则:先类型后名字
@Autowired是自动装配的入口,它的查找规则很多人说不全。完整流程是这样的:先按类型查找,这是第一步;如果类型匹配的候选只有一个,直接注入;如果有多个候选,再按字段名或参数名作为Bean名字做二次匹配;名字能匹配上就注入,匹配不上就会抛出NoUniqueBeanDefinitionException。
举个例子,某个接口有两个实现类:
@Repository public class JdbcUserDao implements UserDao {} @Repository public class MongoUserDao implements UserDao {}此时字段声明成private UserDao userDao,Spring会尝试找名为userDao的Bean,发现都没有这个名字,注入失败;如果把字段名改成jdbcUserDao,Spring就能匹配上JdbcUserDao这个名字。这种“按名字兜底”的行为很容易成为隐性bug的来源,比如团队里有人无意识依赖字段名匹配代码,换个IDE的自动重构把字段名改了,注入就突然失败了。
5.2 注入发生的时机与后置处理器
@Autowired的注入行为不是凭空发生的,它由AutowiredAnnotationBeanPostProcessor这个后置处理器负责。这个处理器实现了InstantiationAwareBeanPostProcessor接口,在Bean实例化后、初始化前,也就是属性填充阶段,解析Bean里的@Autowired、@Value注解并完成反射注入。
BeanPostProcessor是整个IoC容器最核心的扩展机制,它的接口就两个方法:
public interface BeanPostProcessor { Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException; Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException; }初始化前方法在Bean初始化方法执行前调用,初始化后方法在初始化完成后调用。Spring AOP的代理对象就是利用后置处理器生成的——容器创建完原始Bean之后,在postProcessAfterInitialization阶段判断是否需要代理,如果需要就返回代理对象,不需要就返回原Bean。所以一个Bean最终放进容器里的,可能是代理对象,这解释了为什么有时候你按实现类去getBean会失败,按接口去拿却成功。
理解了后置处理器,你就能看懂很多“黑魔法”:比如给某个Bean添加日志增强、给某个类型统一做参数校验,都可以自定义后置处理器实现,而不必侵入业务代码。
5.3 多候选Bean的裁决:@Primary与@Qualifier
当类型候选有多个时,除了名字匹配,还有两个更常用的手段。@Primary表示“默认首选”,多个候选里只有一个标注了@Primary,容器就直接选它;@Qualifier则更精确,直接指定要按哪个名字或限定符来注入。
@Primary @Repository public class JdbcUserDao implements UserDao {} @Service public class UserService { @Autowired @Qualifier("mongoUserDao") private UserDao userDao; }@Primary和@Qualifier配合使用非常灵活:接口层写默认实现,特殊场景用@Qualifier覆盖默认选择。这里有个经验要记住:@Qualifier里写的名字,默认就是Bean的名称,而Bean的默认名称是类名首字母小写。如果想更明确,可以在注解里显式指定Bean名,比如@Repository("mongoDao"),这样@Qualifier("mongoDao")就一目了然。
5.4 条件装配:自动配置的基石
条件装配让“要不要创建这个Bean”可以由上下文动态决定,最典型的就是Spring Boot的自动配置。它的核心就是@Configuration配合@Conditional系列注解:
@Configuration @ConditionalOnClass(name = "org.h2.Driver") public class EmbeddedDataSourceConfig { @Bean @ConditionalOnMissingBean public DataSource dataSource() { // 创建内置数据源 return new DataSource(); } }这段逻辑可以这样理解:classpath里有H2数据库驱动,用户自己又没有定义DataSource,那么自动配置就生效,给你创建一个内置数据源。这个机制实现了“约定大于配置”的核心体验——你引入一个starter依赖,相关Bean就会按需被自动装配;你一旦自己定义了同名Bean,默认配置马上退让,把控制权还给你。
条件装配和IoC的关系需要点透:IoC容器提供了“在哪里创建Bean”的基础设施,条件装配则把“要不要创建Bean”的决策能力也交给容器。这正是Spring容器强大的原因——对象的生成已经不是一个简单的工厂方法,而是一套可编程、可组合、可动态决策的体系。
6. 高频报错与排查思路实录
6.1 NoSuchBeanDefinitionException:最常见的启动失败
如果你是Spring新手,这个异常大概率第一个遇到。报错信息通常是:
NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.service.UserService' available翻译过来就是容器里没有找到UserService这个Bean。原因不外乎几类:类上忘了加@Component、@Service等注解;扫描路径不对,类根本不在@ComponentScan范围内;类被过滤规则排除了;或者依赖的类不在classpath里。还有一个容易被忽略的原因:Bean本身能创建,但创建过程中抛了异常,最终表现为找不到Bean。
排查时先看注解到底加没加,再看包路径是否被扫描到,最后打开启动日志翻一翻有没有更底层的异常。我的习惯是用debug模式在DefaultListableBeanFactory的beanDefinitionMap上打断点,看里面到底有没有目标类的BeanDefinition。一旦确认有没有注册成功,问题范围就缩小了。
6.2 循环依赖导致的启动失败
Spring Boot 2.6以后的报错长这样:
The dependencies of some of the beans in the application context form a cycle: orderService -> inventoryService -> orderService看到这个报错,说明容器在创建Bean时发现了一个依赖闭环。前面说过,默认的循环依赖支持只对单例Bean在Setter或字段注入场景下有效。如果你用的构造器注入,或者Bean作用域是prototype,循环依赖直接无法解决。
我的处理建议是:第一,先重新审视两个类的设计,90%的循环依赖都是因为职责边界模糊。第二,如果确实需要临时止血,在其中一个依赖上加@Lazy,让容器注入代理,到调用时才触发解析。第三,承诺自己这只是过渡方案,下个迭代一定要重构掉。在老项目里我还见过一种循环依赖不属于Bean级别,而是初始化方法里互相调用,比如A的@PostConstruct里调B的方法,B的@PostConstruct里又调A的方法,这种问题排查起来更隐蔽,需要在日志里看初始化顺序才能定位。
6.3 注入歧义:多个实现类怎么办
当一个接口有多个实现类时,@Autowired会抛出NoUniqueBeanDefinitionException,报错会列出找到了哪几个Bean:
expected single matching bean but found 2: jdbcUserDao, mongoUserDao解决方案前面已经讲过:要选默认就加@Primary,要指定某个具体实现就用@Qualifier。这里补充一个容易被忽略的问题:如果你写的是老代码,用了@Resource注解,它的查找规则和@Autowired不一样,@Resource先按名字找,按名字找不到再按类型找。一个项目里混用两种注解,很容易出现“明明有多个实现,却莫名其妙注入成功”的诡异现象。团队里最好统一一种风格,我建议统一用@Autowired,规则更可控。
6.4 静态上下文里拿不到Bean
这类问题的高发场景是工具类。有人会写出这种代码:
@Component public class EmailUtil { @Autowired private static MailSender mailSender; // 永远是null }static字段上的@Autowired不会起作用,因为Spring的依赖注入发生在实例上,不是类上。static字段属于类本身,Spring不会去创建和注入静态字段,所以这个mailSender在运行期一定是null,调用时直接空指针。
正确做法是用一个Spring持有的应用上下文容器,把上下文存到静态字段里,需要时再取Bean:
@Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; @Override public void setApplicationContext(ApplicationContext applicationContext) { context = applicationContext; } public static <T> T getBean(Class<T> requiredType) { return context.getBean(requiredType); } }这个工具类在很多项目里都有,实现思路大同小异。但我更想强调的是,静态工具类越少越好。Spring容器本身管理一切对象,最好的做法是让对象也交由容器管理,把静态方法改造成注入到需要它的类里。确有必要用静态工具类时,再用上面的持有器方案。
6.5 Bean问题排查的一般步骤
遇到Bean相关的问题,我有一套固定的排查流程。第一步,看启动日志的报错摘要,Spring Boot在启动失败时输出的信息已经非常精确,会指出哪个Bean、哪个依赖、哪个环节出了问题。第二步,确认Bean是否注册,用debug模式下断点,或者启动后遍历getBeanDefinitionNames(),把容器里所有Bean名字打印出来。第三步,确认类型匹配,AOP代理会导致你按实现类找不到Bean,而按接口能找到,这不是容器出问题,是代理替换了原对象。第四步,看依赖关系,打开org.springframework的debug日志,能看到每个Bean的创建顺序,循环依赖、依赖缺失都能从日志里顺藤摸瓜找出来。第五步才是上网搜索。按这个顺序,九成问题都能自己定位。
7. 项目实践中的几点个人体会
最后分享几个我在实际项目里沉淀下来的习惯。新写的类一律用构造器注入,配合final关键字,依赖一眼可见,测试也方便。遇到循环依赖的第一反应不是加@Lazy,而是重新审视设计——很多循环依赖本质上是两个类职责划分出了问题。存量代码里大量字段注入的,我不会急着一次性改完,先修新增代码和真正的bug,趁着重构的机会逐步替换。
还有一个排查技巧非常实用:Spring在启动失败时给出的提示其实非常友好,尤其是“APPLICATION FAILED TO START”那段描述,几乎等于把原因写在了脸上。很多人习惯性跳过日志直接搜索,反而绕了远路。静下心把日志从头读到尾,大部分Bean相关的问题都能当场定位。IoC这套东西,背概念很快,真正建立起“容器视角”则需要踩坑和沉淀。希望这篇文章能帮你把这块骨架搭起来,后续再遇到Spring问题,至少知道该往哪个方向想。