1. 从“手动组装”到“自动装配”:IoC与DI到底解决了什么问题
先从一个几乎所有Java开发者都写过的场景说起。假设你在做一个订单服务,里面需要调用用户服务和库存服务,最常见的写法是这样:
public class OrderService { private UserService userService = new UserService(); private StockService stockService = new StockService(); }看起来没啥问题,但项目一旦变大,麻烦就来了。UserService 的构造函数变了,比如新增了一个必须传递的参数,你是不是得跑到所有 new 过它的地方去改?OrderService 要换成 UserService 的另一个实现类,是不是得重新改代码再重新编译?更不要说单元测试的时候,想给 OrderService 注入一个 Mock 对象,结果发现它内部写死了 new,你根本无从下手。
这个痛点,就是 Spring IoC(Inversion of Control,控制反转)和 DI(Dependency Injection,依赖注入)存在的根本原因。
我先用一句人话把这两个概念讲清楚:IoC 是一种设计思想,把“创建对象、管理对象生命周期”的主动权从你的代码手里,交给一个外部容器;DI 是这种思想的具体落地手段,容器在创建对象的时候,自动把它所依赖的其他对象“塞”进去。
你可以把 IoC 容器想象成一个“对象托管中心”。以前你自己开公司,需要什么岗位的人就自己去面试、招人、签合同、发工资、离职还要办手续——这就是 new 一个对象和等它被 GC 回收的全过程。现在你把这些全部外包给猎头公司(容器),你只需要说“我需要一个懂用户业务的 Object”,容器就会把符合要求的对象送到你手上,你只管用就行,它什么时候创建、什么时候销毁、中间怎么维护状态,统统不用你操心。
搞清楚这个底层逻辑之后,后面看源码、配置 Bean、排查循环依赖,你都会觉得顺理成章。这篇文章我会带你从零开始,把 IoC 和 DI 的原理、三种注入方式、Bean 的生命周期、循环依赖三级缓存、以及实际开发中容易踩的坑全部过一遍,中间还会穿插我踩过的坑和实际项目里的调试思路。
2. 核心概念深度拆解:容器、Bean、装配方式
2.1 IoC 容器的工作流程,其实和“点菜上菜”一样
很多初学者把 IoC 容器想得很玄,其实它的工作流程特别简单,就三步:加载配置、解析注册、注入使用。
第一步是读取配置信息。这个配置可以是 XML 文件,可以是注解,也可以是 JavaConfig 类。Spring 会把这些配置统一解析成一个个 BeanDefinition——你可以把它理解成“对象的简历”,里面写清楚了这个 Bean 的类名、作用域(singleton 还是 prototype)、是否需要懒加载、构造函数参数、属性值、初始化/销毁方法等所有信息。
第二步是注册。Spring 把解析出来的 BeanDefinition 放进一个叫 BeanFactory 的注册表里。注意这里只是“登记在册”,还没有真正创建对象。真正的实例化发生在 getBean() 被调用的时候,或者容器启动完预实例化单例 Bean 的时候。
第三步是注入。容器创建好对象之后,会扫描这个对象依赖了哪些其他 Bean,然后从容器里找到对应的实例,通过构造器、Setter 方法或字段反射的方式装配进去。这个“找依赖并装配”的过程,就是 DI。
整个流程里最容易被忽略的是第一步和第二步之间的“BeanDefinition 覆盖规则”。同一个类型的 Bean 如果配置了多个,默认是后面定义的覆盖前面定义的。我早期就遇到过这种情况:在 XML 里配置了一个 DataSource,又在配置类里用 @Bean 返回了另一个 DataSource,结果容器里最终生效的是配置类里的那个,查了半天才发现是覆盖规则在起作用。
2.2 Bean 的创建时机:单例、原型、懒加载
Spring 默认的 Bean 作用域是 singleton,也就是整个容器里同一个 Bean 只有一个实例。Spring 容器启动的时候,会默认把所有非懒加载的单例 Bean 全部提前创建好,这叫“预实例化”。
这样设计的好处显而易见:启动阶段就把所有“零件”装配完毕,后续请求阶段就不需要再做 Bean 的初始化了,性能更好,而且能尽早发现配置错误。比如你的 Bean 构造函数里抛了个异常,如果没开预实例化,可能要等第一次调用才报错,线上排查起来很头疼。
和 singleton 相对的是 prototype,每次 getBean() 或者注入的时候都会创建一个新的实例。这种作用域适合有状态的对象,比如一次请求内的临时业务对象,不适合 Service、DAO 这种无状态组件。实际项目中 90% 以上的 Bean 都是 singleton,我几乎只在多线程安全要求极严、或者需要隔离状态的场景下才用 prototype。
懒加载(@Lazy)的作用则是延迟初始化。加了 @Lazy 的 Bean 不会在容器启动时创建,而是第一次被使用的时候才创建。这个特性在解决启动时的循环依赖问题上有奇效——后面讲循环依赖的时候我会专门展开。
2.3 依赖注入的三种姿势:构造器、Setter、字段注入
Spring 官方文档里其实强烈推荐构造器注入,我一开始也照做了,但随着项目越做越多,现在我的使用习惯是分场景选择。
构造器注入的优点非常明显:依赖关系在对象创建的时候就固定下来了,绝对不可变;而且能保证 Bean 在创建完成后一定处于完整可用状态,不会出现“对象建好了但依赖还没设进去”的半成品状态。单元测试也方便,直接 new 的时候把 Mock 传进去就行,完全不需要 Spring 容器参与。缺点就是当依赖太多的时候,构造函数参数会非常多,代码看起来比较臃肿。如果一个类的构造器参数超过四五个,你要警惕了,这很可能是类职责过重,该拆分了。
Setter 注入的好处是灵活,可以在对象创建之后动态改变依赖;而且一个类有多个依赖时,你可以选择只注入其中几个。缺点是没办法保证依赖关系不可变,而且如果忘记注入某个依赖,运行时会莫名其妙出现空指针,编译期根本发现不了。
字段注入(@Autowired 直接打在字段上)是现在很多初学者最喜欢的方式,因为写起来最省事。但我不建议在新项目里大量使用这种注入方式,原因有三个:依赖对外完全不可见,外人看这个类根本不知道它依赖了什么东西;和 Spring 容器强耦合,脱离容器就没法对类进行单元测试;而且字段注入无法标记 final,依赖关系是可变的,给了后续代码被偷偷篡改的可能性。当然,在测试类、配置类、或者一些临时脚本里用字段注入问题不大,别全局泛滥就行。
3. 从 XML 到注解:Spring IoC/DI 的演进脉络
3.1 XML 时代是怎么玩的
我最早接触 Spring 3.x 的时候,XML 配置还是主流。当时一个项目的配置文件动辄几百上千行,写起来是真的折磨:
<bean id="userService" class="com.example.service.UserService"> <property name="userDao" ref="userDao"/> <property name="config"> <value>default_config</value> </property> </bean> <bean id="userDao" class="com.example.dao.UserDao"/>这套玩法的好处是配置和代码完全分离,改依赖关系不用改 Java 源码,运维和开发的边界很清晰。但缺点也肉眼可见:配置冗长、Java 代码和 XML 文件之间跳来跳去容易眼花、编译期检查缺失,一个拼写错误就要等容器启动才能暴露。
当时还有个常见的坑是 XML 里配的类路径写错了,类名少写一个字母,Spring 启动直接报 ClassNotFoundException。这种问题写代码的人往往还发现不了,因为 IDE 对 XML 里 string 类型的类路径默认是不做校验的。
3.2 注解驱动:组件扫描 + 自动装配
Spring 2.5 开始推出注解,但真正大规模普及是 Spring 3.0 之后。核心就是两个注解:@Component 及其衍生注解(@Service、@Controller、@Repository),加上 @Autowired。前者负责把类注册成 Bean,后者负责自动注入依赖。
@ComponentScan 组件扫描是这个机制的关键。你告诉 Spring“去这些包下面找带有 @Component 的类”,Spring 就会扫描这些包及其子包,把符合条件的类自动注册成 Bean。这里衍生的一个常见问题是:Spring 扫描包路径的时候,默认只扫描指定包下的类,如果 Bean 所在的包不在扫描范围内,就会出现 NoSuchBeanDefinitionException。排查这个问题的第一反应应该是看包扫描范围是否正确,尤其是启动类的位置,因为 Spring Boot 的启动类默认扫描的就是它所在包及其子包。
@Autowired 的查找逻辑也值得好好理解。默认情况下,Spring 会先按类型(byType)匹配,找到唯一一个就直接用;如果按类型找到多个,就再按字段名或参数名(byName)匹配;还找不到就会报错。搞清楚这个优先级顺序,很多“有两个实现类导致注入失败”的问题就迎刃而解了。
我之前在一个多商户项目里写过这样的代码:
@Autowired private PaymentService paymentService;当时 PaymentService 有两个实现类:AlipayPaymentService 和 WechatPaymentService。容器按类型找发现了两个候选,再按字段名去匹配,发现字段名恰好叫 paymentService,谁都对不上,启动直接报 NoUniqueBeanDefinitionException。解决办法要么把字段名改成 alipayPaymentService(不推荐,太脆弱),要么用 @Qualifier("alipayPaymentService") 明确指定。如果你也遇到类似的报错,不用慌,原因八成就是这个。
3.3 JavaConfig:兼顾类型安全与可读性的现代方案
Spring 3.0 引入了基于 Java 的配置方式,用 @Configuration 标注配置类,用 @Bean 标注配置方法:
@Configuration public class AppConfig { @Bean public DataSource dataSource() { return new HikariDataSource(); } }这种方法最大的优势是类型安全。方法返回类型是编译期确定的,写错了 IDE 直接报错,不用等容器启动。而且可以在配置方法里写任意逻辑,比如根据运行环境创建不同的 DataSource,用 if-else 就能实现,比 XML 里搞 profile 配置灵活得多。
实际开发中现在最常用的组合是 Spring Boot 的自动配置 + 组件扫描 + JavaConfig。XML 并不会完全绝迹,但大多只出现在老系统维护、或者某些特殊场景(比如第三方 jar 包内嵌的 XML 配置)里。
4. 手写一个精简版 IoC 容器:彻底吃透 XML 到实例的流转
4.1 为什么写了五遍 Spring 源码还是觉得没看懂
我一直觉得,真正理解一个框架最好的方式不是反复看别人写的源码讲解,而是亲手把它最核心的机制复刻一遍。Spring 的 IoC 容器源码动辄上万行,硬啃很容易迷失在层层抽象里。但它的骨架其实很稳定,就四个环节:解析配置、放入注册表、按照定义创建实例、创建时完成依赖注入。
我自己在纸上和 IDE 里写过好几版 IoC 容器原型,代码量不大,但写完之后对 Spring 的敬畏感和理解都上了一个档次。下面把我写过的其中一版的核心逻辑分享出来,专门演示 XML 到实例的流转过程。
4.2 实现思路:注册表 + 反射 + 递归注入
先定义最简单的注册表:
public class SimpleBeanFactory { private Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); private Map<String, BeanDefinition> beanDefinitions = new ConcurrentHashMap<>(); }BeanDefinition 里至少要保存类名、是否单例、依赖的属性名列表。解析 XML 的时候,用 JDK 自带的 DOM 或者 SAX 把 标签读出来,填充到 beanDefinitions 里。
创建实例的核心逻辑分两步。第一步是反射实例化:
Class<?> clazz = Class.forName(beanDef.getClassName()); Object instance = clazz.getDeclaredConstructor().newInstance();第二步是遍历当前 Bean 的属性依赖,递归调用 getBean()(如果依赖的 Bean 还没创建,就先去创建它),然后通过反射调用 setter 方法把依赖塞进去。这一步本质上就是 DI。
4.3 递归注入时的顺序陷阱
写这个手写版容器时我踩过一个很有意思的坑:如果 Bean A 依赖 Bean B、Bean B 又依赖 Bean A,递归调用 getBean() 就会无限循环下去,最终栈溢出。
当时我的解决办法是加一个“创建中”的标记集合:getBean() 进来时,先判断当前 Bean 是不是已经在创建中集合里了,如果是就直接抛异常,提示循环依赖。这个朴素的处理方式让我意识到,Spring 处理循环依赖用的“三级缓存”原理,其实也是为了打破这种死循环而设计的——只不过它做得更巧妙,不是直接报错,而是提前暴露早期引用。
所以看到 Spring 循环依赖相关的面试题时,我一点都不慌,因为我知道容器在“创建中”状态到底发生了什么。这个手写版虽然简陋,但把 IoC 的“注册、创建、注入”三大动作完整跑了一遍,每次写完后你对 @Autowired 背后发生的事都会有更具体的画面感。
5. 循环依赖与三级缓存:Spring 进阶绕不开的硬核机制
5.1 什么是循环依赖?为什么构造器注入能天然规避
循环依赖,字面意思就是 Bean 之间互相依赖。A 依赖 B,B 又依赖 A,形成了一个环。这在复杂的业务系统里其实很难完全避免,比如订单服务和物流服务互相查询对方的接口。
Spring 解决循环依赖的思路并不是“没有办法解决就硬解”,而是分情况处理。构造器注入的循环依赖无解,因为创建 A 的时候需要传入 B 的实例,创建 B 的时候又需要传入 A 的实例,两边都无法先创建一个不完整的对象,容器会直接抛出 BeanCurrentlyInCreationException。字段注入和 Setter 注入的循环依赖则可以解决,因为对象可以先被创建出来(属性的值暂时为空),之后再把依赖填充进去。
这就是为什么很多对 Spring 理解深入的人,在实际开发里更倾向于字段注入或 Setter 注入的原因之一:它们天然对循环依赖友好。当然这并不代表你应该刻意制造循环依赖,架构上还是要尽量避免环的存在,但理解这个特性有助于你在面对一些历史遗留代码时知道它能跑起来的原因。
5.2 三级缓存到底在缓存什么
Spring 内部维护了三级缓存来解决“提前暴露早期引用”的问题,每一级的名字和作用如下:
- 第一级缓存(singletonObjects):存放已经完成完整初始化、可以直接使用的单例 Bean。
- 第二级缓存(earlySingletonObjects):存放已经完成了实例化、但还没完成属性填充的“早期单例对象”,即半成品 Bean。
- 第三级缓存(singletonFactories):存放 ObjectFactory 对象,通过它可以在需要的时候提前生成 Bean 的代理引用。
很多人背了这三级的名字,却不理解为什么需要三级而不是两级。要理解这个问题,关键要抓住“AOP 代理创建时机”这个点。
假设 A 依赖 B、B 依赖 A,同时 A 需要被 AOP 代理。如果只有两级缓存(存原始对象 + 最终对象),那么当 B 在创建过程中引用 A 的时候,A 还没有经过代理的步骤,B 拿到的是一个没有被代理的普通 A 对象。之后 A 再被代理,但 B 持有的依然是那个旧的原始对象,代理就失效了。
为了解决这个时序问题,Spring 引入了第三级缓存。当 A 实例化后,会提前把一个 ObjectFactory 放入第三级缓存。B 在创建过程中通过第三级缓存的 ObjectFactory 去获取 A,这个 factory 会根据 A 是否需要 AOP 而决定返回“原始对象”还是“动态代理对象”。如果 A 需要 AOP,B 拿到的直接就是代理对象;等到 A 真正初始化完成,第一级缓存里存的对象和 B 拿到的也能保持一致性。这就是三级缓存存在的深刻原因。
5.3 实际排查循环依赖的经验
我在现网遇到过几次循环依赖报错,报错信息里一般会打出当前正在创建的 Bean 名字,但很多时候不会直接告诉你环由哪几个 Bean 组成,需要自己顺着依赖图找。
我的排查思路是:先从报错信息里拿到那个“正在创建”的 Bean 名字,然后去它的属性里看有没有依赖别的 Bean,一层层往下追,直到追回来形成一个闭环。画脑图或者直接打印依赖关系都行,核心是把环找出来。
找到环之后有三种处理方案。第一种,也是最推荐的,就是从架构层面打破循环依赖:把互相依赖的逻辑抽出来,放到第三个组件里,或者改成一个单向依赖的模式。第二种,如果确认是 Setter/字段注入的循环依赖,可以直接加 @Lazy 注解,让其中一个 Bean 延迟加载,这样能绕开启动时的循环依赖检查。第三种,在极少数情况下用 setter 注入把环保持住,但代价是代码的可维护性变差,不推荐长期保留。
这里有个非常重要的提醒:从 Spring Boot 2.6 开始,官方默认禁止了循环依赖。如果项目是从 2.5 及以下版本升级上来的,启动时可能会因为循环依赖直接报错,需要把 spring.main.allow-circular-references=true 配回去才能恢复旧行为。但我不建议你直接加这个配置,更好的做法是找到循环依赖存在的根因并重构掉,否则带病运行迟早出事。
6. @Autowired 深入:类型匹配、多个候选、必需属性
6.1 @Autowired 的三个核心查找规则
@Autowired 的注入逻辑可以总结成三条查找规则,按优先级依次执行:
- 第一,按类型(byType)查找。容器里只有一个匹配的 Bean,直接注入成功。
- 第二,按名字(byName)查找。按类型找到多个候选 Bean 的时候,会根据字段名或参数名再匹配一遍,命中唯一一个则注入成功。
- 第三,处理多个候选的歧义。如果按名字也找不到,就需要 @Primary 或 @Qualifier 介入,否则抛 NoUniqueBeanDefinitionException。
举一个实战例子。项目里定义了接口 MessageSender,有 SmsMessageSender 和 EmailMessageSender 两个实现,都注册成了 Bean。
@Component public class NotifyService { @Autowired private MessageSender messageSender; }此时 Spring 按类型找到了两个候选,按名字再找 messageSender 这个名字又对不上任何一个实现类名,就会启动报错。如果你的字段名恰好叫 smsMessageSender,那 Spring 按名字能找到 SmsMessageSender,直接注入成功。这个“命名碰巧”看起来很巧妙,但可读性和可维护性都很差,不推荐依赖这种写法。
6.2 @Primary、@Qualifier、@Resource 怎么选
遇到一个接口多个实现的场景,我总结的选型建议是这样的:
- @Primary:适用于“大多数时候我就想用这个实现类”的场景。比如在项目里只有一份默认的 RedisTemplate 或 ObjectMapper 时,@Primary 能避免到处都是 @Qualifier。注意 @Primary 只能标注一个实现,多个 @Primary 依然会报错。
- @Qualifier:适用于不同场景用不同实现的场景。可以把 qualifier 理解成给 Bean 起了一个可区分的别名,注入时显式指定,语义清晰。
- @Resource:是 JSR-250 的标准注解,和 @Autowired 最大的区别是它默认按名称(byName)查找,如果找不到再按类型。在一些国产化、去 Spring 化的项目里,@Resource 的兼容性更好,因为它是 Java 标准规范,被 Spring 支持的同时也被很多其他容器支持。
我自己写新代码时更倾向用 @Autowired + @Qualifier 的组合,因为 @Autowired 支持 required=false(允许注入为 null)以及 @Primary 的同理心,整体语义更贴近 Spring 生态。如果是做通用组件库或者中间件,我会优先用 @Resource,避免和 Spring 的注解深度耦合。
6.3 required=false 与 Optional 注入的妙用
默认情况下 @Autowired 是“必须注入成功”的,容器里找不到对应 Bean 就直接启动失败。这是好事,能防止配置遗漏。但有些场景确实需要“找不到就算了”,这时有两个选择:
@Autowired(required = false) private MessageSender messageSender; // 或者用 JDK8 的 Optional 包装 @Autowired private Optional<MessageSender> messageSender;两者都能在容器里没有 MessageSender 时让应用正常启动。区别在于 Optional 的语义更明确,代码里一眼就能看出这个依赖是可选的,而且可以优雅地做空判断,比判断字段是否为 null 更安全。我一般只在插件化架构、功能开关、可选扩展点这类场景下用 optional 注入,核心业务依赖一律保持默认的强依赖,不让应用在“少了关键组件”的情况下带病启动。
7. Bean 的生命周期:从定义到销毁的完整旅程
7.1 生命周期各阶段梳理
Spring 中一个单例 Bean 的完整生命周期可以分为这么几个阶段,按顺序记忆会更清晰:
阶段一:解析和注册。容器读取配置,生成 BeanDefinition 并登记。
阶段二:实例化。通过构造器或者工厂方法创建对象实例。此时对象已经存在,但属性还没赋值。
阶段三:属性填充。也就是依赖注入发生的地方。容器根据 BeanDefinition 中的属性配置,调用 setter 方法完成赋值。如果实例化后的 Bean 实现了 Aware 接口(比如 BeanNameAware、ApplicationContextAware),也会在这个阶段回调,把 Bean 的名字、容器引用等信息告诉它。
阶段四:初始化。如果有 BeanPostProcessor,会先执行 postProcessBeforeInitialization 方法,然后调用 @PostConstruct 标注的方法(或者初始化方法 init-method),最后执行 postProcessAfterInitialization 方法。AOP 代理的创建就发生在这个阶段,这也是为什么循环依赖需要三级缓存来提前暴露代理的原因。
阶段五:使用。Bean 处于就绪状态,可以被正常注入了。
阶段六:销毁。容器关闭时,如果 Bean 实现了 DisposableBean 接口,或者标注了 @PreDestroy 方法,都会被执行,用于释放资源、关闭连接池、清理线程池等。
7.2 BeanPostProcessor 为什么是 Spring 的“神级扩展点”
如果你认真看 Spring 源码,会发现整个框架的很多核心功能都是靠 BeanPostProcessor 实现的。比如 @Autowired 的注入逻辑就是一个叫 AutowiredAnnotationBeanPostProcessor 的处理器在 postProcessPropertyValues 阶段完成的;@Async、@Transactional 等等也是靠后置处理器生成代理的。
所以你也可以把 BeanPostProcessor 理解成“Bean 从创建到使用之间的一道自定义关卡”。你可以在 postProcessBeforeInitialization 阶段偷偷替换掉 Bean 对象,也可以在 postProcessAfterInitialization 阶段给 Bean 加上代理逻辑。这给了你极强的扩展能力,比如在 Bean 初始化之后自动打印耗时日志、自动给特定类型的 Bean 加监控埋点。
写自定义 BeanPostProcessor 时有个坑要特别注意:BeanPostProcessor 本身是特殊的 Bean,Spring 会在普通 Bean 创建之前就完成它的实例化。如果你的 postProcessor 注入了一个普通 Bean,而那个普通 Bean 恰好又要依赖这个 postProcessor,就很容易绕进循环依赖的坑里。
7.3 初始化顺序的实战排查
我在排查线上问题的时候,经常会被问到这个顺序问题:实现了 InitializingBean 接口、写了 @PostConstruct 方法、又配置了 init-method,到底哪个先执行?答案是这样的:@PostConstruct 先执行,然后才是 InitializingBean 的 afterPropertiesSet 方法,最后才是 init-method 中指定的自定义方法。
这个顺序在网上很多文章里都有提及,但真实项目里容易忽略的是:如果你同时用了 Spring 的 @PostConstruct 和 JSR-250 的 @PostConstruct,注意只有一个生效,两个注解在不同容器里的语义可能不完全一致。再加上 BeanPostProcessor 的 before 和 after 方法,实际执行的链路比很多人想的要长很多。
我给一个实用的排查思路:在生命周期各阶段的关键回调里打点日志,加上当前线程栈。这样能快速看清每个 Bean 的创建顺序和依赖关系,排查初始化失败问题的时候比盲猜效率高得多。
8. Spring Boot 场景下的 IoC/DI:自动配置与常见开发姿势
8.1 Spring Boot 怎么把 IoC 玩出花来的
Spring Boot 的“自动配置”本质上还是 IoC 思想的一种极致应用。你引入一个 spring-boot-starter-web,Spring Boot 就会通过 @EnableAutoConfiguration 加载一大堆 XxxAutoConfiguration 配置类,在配置类里面条件装配了大量默认 Bean。比如引入 Redis 依赖后,Spring Boot 会判断你还没有手动定义 RedisTemplate,就会自动注册一个默认的 RedisTemplate 给你用,实现“开箱即用”。
理解自动配置的关键是看 @Conditional 系列注解。@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty 这些注解控制了“什么时候装配这个 Bean”。遇到自动配置不生效的情况,优先去查这些条件是否满足,而不是一上来就怀疑 Spring Boot 坏了。调试的时候可以在 application.properties 里打开 debug=true,启动日志里会明确告诉你“哪些自动配置生效了,哪些被跳过,以及原因是什么”。
写自定义 Starter 或者封装公共模块的时候,同样会用到自动配置。我建议把自动配置类和业务代码分开包路径,避免被应用自身的组件扫描误扫到,导致容器里出现两份一样的 Bean。
8.2 实战:用 @ConfigurationProperties 批量注入配置值
IoC/DI 在 Spring Boot 里最常见的应用场景之一就是把配置文件的属性批量注入到一个配置 Bean 里,替代传统的 @Value 一个字段一个字段地取。这样既干净又类型安全,还能把复杂配置集中管理。
@Data @Component @ConfigurationProperties(prefix = "shop.payment") public class PaymentProperties { private String appId; private String privateKey; private String notifyUrl; }使用的时候,把 PaymentProperties 注入到服务里:
@Service public class PaymentService { private final PaymentProperties properties; public PaymentService(PaymentProperties properties) { this.properties = properties; } }这里体现的是构造器注入的标准姿势:字段用 final 修饰,依赖不可变,测试时直接传参。@ConfigurationProperties 批量绑定比 @Value 的优势在于:多个相关配置项集中管理、IDE 能提供属性提示、并且支持类型转换和校验。在实际多环境中,我还常常把前缀做成带环境名的,比如 production.payment.url 和 test.payment.url,配合 profile 灵活切换,配置代码完全不用改。
8.3 Spring Boot 启动时 Bean 装配失败的常见排查路径
不管经验多丰富,总会遇到“启动报错,找不到某个 Bean”的情况。我总结了一套百试不爽的排查路径:
第一步,看报错的完整堆栈,找到是哪个类在注入时出了问题。如果错误信息带有 NoSuchBeanDefinitionException,多半就是依赖的 Bean 没被注册。
第二步,确认这个 Bean 是不是真的存在。如果是自定义类,检查有没有加 @Component 或 @Service 注解;如果是通过 XXXAutoConfiguration 装配的,检查自动配置生效条件。
第三步,检查包扫描路径。Spring Boot 启动类默认只扫描所在包及其子包,如果 Bean 写在扫描范围之外,容器永远找不到。把启动类移到项目根包下或者手动指定 scanBasePackages 可以解决。
第四步,如果确实有多个同类 Bean,但没加 @Qualifier,启动报 NoUniqueBeanDefinitionException。按 6.1 节的规则去补 @Qualifier 或 @Primary 即可。
第五步,如果所有条件都正常,检查是不是存在循环依赖,以及 Spring Boot 2.6 之后是否在配置中显式放开了循环引用。如果是历史项目升级,先看 release note 里的破坏性变更。
这套步骤我走下来,绝大多数启动装配问题都能在十分钟内定位。真正麻烦的反而是那种只在你本地能复现、测试环境不报错的诡异问题,这时候往往和环境差异、配置生效顺序有关,按照生命周期和自动配置条件逐一排查,多半能水落石出。
9. IoC 和 DI 的边界:和 AOP、工厂模式的真实关系
9.1 IoC 不只是“对象工厂”
很多人把 IoC 容器和工厂模式混为一谈,觉得 IoC 无非是“用工厂方法创建对象”。这种理解不能说全错,但确实失之偏颇。工厂模式解决的主要是“创建逻辑的封装”,帮你把“创建什么”和“怎么使用”解耦;但 IoC 容器管的事远不止创建,还包括了一整套生命周期管理、依赖解析、作用域管理、代理植入、事件发布等能力。
一个典型的对比是:你自己写个工厂类,你依然需要知道“我需要 ProductA 的时候要调用 FactoryA”;但用 IoC 容器,你只需要声明“我需要一个 Product 类型的依赖”,容器会根据类型、名称、主备策略、条件注解自动做出决策。而且 IoC 容器还会在背后帮你处理很多你根本没意识到的事——比如 Bean 的懒加载、AOP 代理的注入、Bean 销毁时的资源释放。
9.2 IoC 给 AOP 提供了什么基础
AOP(面向切面编程)一直和 IoC/DI 被并称 Spring 的两大核心。但它们不是并列关系,而是“IoC 为 AOP 提供了运行时基础”的关系。AOP 动态代理要起作用,前提是 Bean 的创建权在容器手里——容器在创建完目标 Bean 之后,可以利用 BeanPostProcessor 机制生成代理对象,并把这个代理对象作为最终的 Bean 暴露给使用者。如果你的对象都是自己 new 的、不经过容器,AOP 根本无从下手。
理解了这个关系,再回头去看 Spring 的动态代理就有种豁然开朗的感觉:为什么 @Transactional 只对容器管理的 Bean 有效?为什么自调用 this.method() 会让事务、缓存注解失效?因为代理对象存在于容器中,而 this.method() 调用的是目标对象的原始方法,根本没有走代理链路。这也是 IoC 容器设计中“所有 Bean 必须统一经过容器代理”的根本原因。
我在项目里遇到过一个经典案例:一个 Service 类里方法 A 调用方法 B,B 上有 @Transactional 注解,但调用后事务没生效。排查后发现问题出在 A 内部用 this.b() 直接调用,而不是通过注入的代理 Bean 调用。改法很简单,要么把 B 拆到另一个 Bean 里,要么通过 ApplicationContext 获取代理对象再调用。理解了代理与容器之间的关系,你就能一眼看出这个问题的本质。
9.3 手写 IoC 容器时,AOP 的最小实现思路
如果你想进一步加深理解,可以试着在自写的简易 IoC 容器里实现一个接口级别的 JDK 动态代理。核心逻辑就是:在 Bean 创建并初始化完成后,判断它是否匹配切点规则,如果匹配,就用 Proxy.newProxyInstance() 生成一个代理对象,替换掉原来要放入缓存的原对象。这样调用方拿到的就是代理对象,在方法调用前后就可以插入日志、权限校验等逻辑。
这个过程很直观地展示了 IoC 容器如何通过一个扩展点(BeanPostProcessor)完成拦截逻辑和业务逻辑的解耦。写完之后你会对“Spring 借助 IoC 容器管理代理注入”有更深的理解,以后再看到 @Transactional 或 @Async 失效的面试题,也能从容分析风险点。
10. 知识自查清单:把这些点过一遍,才算真的懂 IoC 和 DI
我用这套思路带过不少新人,每次培训结束都会给一个自查清单,这里也分享给你。你可以逐条检验自己是否真的掌握了 Spring IoC/DI:
- 能不能用一句话说清楚什么是控制反转,什么是依赖注入,两者什么关系?
- 能不能画出 Bean 从解析配置到初始化完成再使用的完整时序?关键回调在哪个阶段执行?
- 知不知道构造器注入和 Setter 注入在循环依赖场景下的区别?为什么构造器注入无法解决循环依赖?
- 循环依赖三级缓存的每一级各自存放什么?AOP 代理创建时机和三级缓存的关系是什么?
- @Autowired 按类型、按名称匹配的完整规则是什么?多个候选 Bean 时如何优雅解决歧义?
- @Primary、@Qualifier、@Resource 三者的使用场景和优先级如何选择?
- BeanPostProcessor 在整个生命周期里处于什么位置?为什么它是 AOP 和 @Autowired 的基石?
- Spring Boot 自动配置的原理是什么?看到一个自动配置不生效,第一步该查什么?
- 对象自己 new 和交给容器管理,在 AOP 场景下有什么本质区别?
这九条如果你都能不翻资料、用自己的话讲清楚,面对大部分 Spring 相关的技术面试和日常开发问题,基本都不会卡壳。如果在某一条上卡住了,建议回到上面对应的章节,带着问题重新读一遍相关的源码和配置,会比单纯背答案有效得多。最后再补充一句,技术这东西,只有自己动手写出来、踩过坑、排查过问题,才能算真正长在自己身上。