这个系列的第十四篇我原本打算挑个“看起来高级”的话题比如链路追踪或者异步并发。但最后还是决定把Spring IoC和DI这两个最基础的东西拿出来重讲一遍。原因很简单我见过太多写了两三年Java的人张口就是Autowired但你要是问他容器到底是怎么把这个依赖塞进来的Bean创建时经历了哪几步循环依赖为什么能解决又为什么不推荐解决他多半会愣住。这不是嘲讽因为我当年也是这样——背了一堆面试题真正上手排查问题时才发现自己其实是在黑盒上操作出了问题只能靠猜。所以这篇就把Spring IoC和DI从“用”讲到“懂”从为什么要反转控制讲到容器内部的工作链路再讲到实际项目里最容易踩的坑最后我还会把自己写过的一个迷你IoC容器拆出来给大家看。无论你是刚开始学Spring还是已经用了一段时间想补齐原理这篇文章应该都能给你一些新东西。1. 为什么好好的new不用偏要搞IoC容器1.1 一个改到崩溃的支付模块先讲一个我早年参与过的项目。业务方一开始只接了一个支付渠道代码写得很“直白”public class PayService { private ChannelAService channelAService new ChannelAService(); public void pay(double amount) { channelAService.pay(amount); } }这套代码在只有一个渠道的时候运行得非常好。直到半年后业务要求接入第二个渠道问题就来了所有调用new ChannelAService()的地方都要改PayService、RefundService、SettleService……你永远不知道代码里还有多少个地方偷偷new了它。全局搜索一下改了十几处编译、测试、上线结果第二天产品经理说“第二个渠道只在一部分客户里灰度老客户还是走渠道A。”于是你只能再把代码翻出来加一堆if判断把“到底用哪个渠道”写死在业务逻辑里。这个时候所有人都意识到了不对劲但改动已经像蛛网一样铺开了。这个场景不是什么极端案例而是传统new方式必然走向的死路对象之间的依赖关系完全由使用方自己维护使用时直接写死具体实现导致系统改一个组件就像动一次手术。那怎么办把创建和选择实现类的控制权交出去谁用谁拿用的时候不关心具体是谁。这就是控制反转IoC思想最初的出发点。1.2 反转的到底是什么很多人一听“控制反转”这四个字就觉得高深其实用大白话说就是一句话原来你创建对象、组装对象、销毁对象全靠自己动手现在把这个活儿交给一个容器容器根据你的配置或注解自动把对象创建出来并把它依赖的东西填进去。举个生活化的例子。以前用老式台灯灯泡是拧上去的灯座决定了灯泡的接口规格你想换个别的牌子的灯泡还得担心接口不匹配。后来用新式灯具插座是标准化的你插什么灯泡都行灯具本身并不关心灯泡内部怎么发光。Spring IoC容器就是那个标准化的插座你的业务类就是灯泡容器负责提供电力依赖灯泡只管发光业务逻辑。这里的“反转”具体指什么从对象的角度看以前是对象自己找依赖现在是容器主动把依赖送给对象从编码的角度看以前你写死具体实现现在你只声明接口由外部环境决定使用哪个实现。这种外部化的配置能力让人可以做到“同一个业务代码接不同的外部服务”改的只是配置而不动代码。1.3 容器和工厂到底差在哪如果之前的代码让你觉得“自己new不行那做个工厂类不就行了”比如写一个PayFactory把创建逻辑集中起来这确实能解决一部分问题public class PayFactory { public static PayService create() { return new PayService(new ChannelAService()); } }但工厂模式解决了“创建逻辑集中”的问题却解决不了“依赖关系复杂时的生命周期管理”问题。举个例子一个Bean依赖另一个Bean另一个Bean又依赖第三个BeanBean之间还有来回引用、需要延迟创建、需要代理增强……这种关系一旦多起来手工搭工厂类会膨胀得非常快而且没有一个统一的Bean实例复用机制每次都要重复构建。IoC容器本质上是一个“配好清单就能自动生产并组装”的超级工厂它把对象从定义到销毁的完整生命周期都接管了创建时机、作用域、依赖填充、初始化回调、代理增强、销毁顺序。工厂模式只是重复造轮子容器的价值在于一个统一的管理模型。这也是为什么Spring能成为Java后端事实标准的原因——它解决的不是“今天要接一个支付渠道”而是“系统规模变大之后依赖关系不再可控”这个普遍问题。2. IoC容器的工作链路从BeanDefinition到Bean对象2.1 启动时的盘点扫描、注册和那本“菜单”很多人把Spring启动想象得很玄其实拆开看就是一套标准流程先把所有被容器管理的类“登记在册”然后再按册子逐个生产。第一步叫扫描与注册。Spring会通过ComponentScan指定的包路径去扫描class文件凡是标注了Component、Service、Repository、Controller这些注解的类都会被解析成BeanDefinition然后注册到BeanDefinitionRegistry里。BeanDefinition这个名字听起来陌生你可以把它理解为“Bean的配方”里面记录了Bean的类名、作用域singleton还是prototype、是否懒加载、初始化方法、属性值、构造器参数等信息。这里有一个关键点此时容器并没有创建任何Bean对象它只是在做“食谱登记”。真正的大厨还没开始做菜。所以如果有配置错误比如类名写错了启动时报错的时间是在“解析配方”阶段而不是“创建Bean”阶段不一定要看错误性质。配方法的格式错误会在解析时暴露但依赖缺不缺失要等到实例化去了才知道。2.2 一个Bean是怎么被“造”出来的当代码里第一次调用getBean(orderService)容器才开始全流程生产这个Bean。整个过程可以简化成下面几步检查singletonObjects缓存里有没有现成的实例有就直接返回根据BeanDefinition拿到类的全限定名通过反射推断构造方法并实例化往实例里填充依赖属性注入或构造器注入执行Aware回调接口比如让Bean拿到自己的名字、拿到ApplicationContext调用BeanPostProcessor的前置处理对实例做增强调用初始化方法PostConstruct或自定义initMethod调用BeanPostProcessor的后置处理此时AOP代理会在这里被创建把成品放进单例缓存返回给调用方。我把其中两个最容易出错的位置再展开说一下。第一步的“推断构造方法”是很多初学者忽视的点。如果一个类只有一个构造器那没什么好讨论的直接拿它实例化。但如果类里有多个构造器Spring就得猜你想用哪一个。Spring会按优先级来选择先找带Autowired注解的构造器如果有且只有一个就用它如果没有看是否存在唯一一个“默认无参构造器”再不行就看有没有参数数量最多的构造器能从中解析出依赖来。这个猜测过程一旦出错最常见的错误就是NoSuchMethodException或者UnsatisfiedDependencyException。我自己通常在类里只保留一个构造器不给自己“猜谜”的机会。第三步的依赖填充是DI的核心发生地点。容器扫描到Autowired注解时会按类型去容器里找匹配的Bean找得到就填找不到就抛异常或者根据requiredfalse跳过。这也是后面第四章要专门讲的“多个实现时到底选哪个”的根源。2.3 BeanFactory和ApplicationContext不是一回事还有一个很常见的误解把ApplicationContext和BeanFactory混为一谈。BeanFactory是Spring容器最底层的接口定义了getBean()、containsBean()这些最基础的能力你可以把它理解为“刚能通电的插座”。而ApplicationContext在它之上做了大量增强比如支持事件发布、国际化、资源加载以及会自动注册BeanFactoryPostProcessor和BeanPostProcessor这也是为什么日常开发都用ApplicationContext系列实现而很少去碰底层BeanFactory。实际项目中你写的SpringApplication.run()返回的ApplicationContext默认底层是DefaultListableBeanFactory它同时实现了BeanFactory和SingletonBeanRegistry。用好这层认知以后看到“ApplicationContext能做什么”就不会含糊它能创建、管理Bean还能监听事件还能拿资源但核心工作仍然是Bean的注册与生产。3. 依赖注入的三种姿势和我的最终选择3.1 构造器注入依赖全部摆在明面上构造器注入是Spring官方历来推荐的方式。写起来非常直观Component public class OrderService { private final PayService payService; public OrderService(PayService payService) { this.payService payService; } }这个方式的优点在于字段用final修饰一旦创建就不可变不会出现“Bean还在运行时依赖被偷偷换掉”的问题。所有依赖通过方法签名暴露出来一个类依赖什么东西看构造器就能一目了然。测试的时候也最方便直接new OrderService(mockPayService)就完成了一个最朴素的单元测试不需要启动Spring也不需要反射。有些同学嫌构造器参数多一个类五六个依赖构造器写出来很长于是改用字段注入。但实际上构造器参数多本身就是一种设计味道说明类可能承担了太多职责应该拆分而不是换注入方式。我的经验是一个类如果超过四五个构造器参数我会停下来想想是不是该拆类了而不是抱怨构造器写法。3.2 Setter注入灵活但容易让人忽略依赖Setter注入长这样Component public class OrderService { private PayService payService; Autowired public void setPayService(PayService payService) { this.payService payService; } }它最大的特点是“先创建Bean后补依赖”允许在创建完成后再传入依赖也允许某个依赖在特定场景下不传。但这也正是它的问题所在Set方式让每个字段随时都有被替换的可能Bean创建的完整性和稳定性不如构造器注入同时依赖关系被拆散到各个set方法里阅读代码时不容易一眼看出这个类需要什么。我自己只有在写一些“可选项”时才会考虑Setter而且会配合Autowired(required false)来控制。但说实话这种场景在现在的项目里已经很少见了因为Java 8的Optional和Spring的ObjectProvider能提供更优雅的替代方案后面会提到。3.3 字段注入看着舒服但坑藏得最深再来看最常见的字段注入Component public class OrderService { Autowired private PayService payService; }代码最短看着最舒服所以成为很多人默认的选择。但它的缺点恰恰藏在“舒服”里依赖被藏了起来类对外暴露的信息只有“我有PayService”但具体怎么得到的、能否在测试时替换全都不清楚。单元测试时必须借助Spring容器或者用反射强制设置私有字段才能让这个类独立工作。我见过不止一个项目用字段注入写出了一堆“离不开Spring”的类最后做单元测试时要么mock半天要么干脆放弃单测。与其这样不如在写类的时候多花几行用构造器把依赖说清楚。三种方式用一张表对比会非常直观对比维度构造器注入Setter注入字段注入依赖可见性最好依赖在签名中中等分散在各set方法最差藏在注解后面不可变性final字段不可变可变可变单元测试复杂度直接new传入即可需调用set方法要反射或启动容器循环依赖支持不支持支持支持过多依赖时的信号构造器变长倒逼重构不明显不明显所以我的项目默认约定是能用构造器注入就用构造器注入Autowired字段注入只允许出现在极少数兼容老代码的地方。4. 装配过程中最容易踩的四个坑4.1 接口有多个实现时容器到底注入哪一个这是Spring用着用着必然遇到的一个问题。场景通常是先写了一个接口然后接了A、B两个实现代码里想注入接口结果启动时报NoUniqueBeanDefinitionException。抛这个异常的本质是Spring按类型找Bean时找到了两个不知道该选哪个。解决方式有三种。第一种给其中一个实现加Primary表示“默认优先选我”Service Primary public class ChannelAService implements PayService { }第二种在注入处用Qualifier精确指定名字Component public class OrderService { private final PayService payService; public OrderService(Qualifier(channelBService) PayService payService) { this.payService payService; } }要注意Qualifier里的名字默认是类名首字母小写ChannelBService对应channelBService。第三种既然有了多个实现干脆把他们都注入进来由业务层统一管理Component public class PayDispatcher { private final ListPayService payServices; public PayDispatcher(ListPayService payServices) { this.payServices payServices; } }这个做法在策略模式的场景下非常好用比如把多个实现按渠道类型分组代码里根据不同渠道key选一个来调。我实际项目里用的最多的反而是这种“集合注入”因为它天然支持插件式扩展新增一个实现类不需要改核心调度代码。需要注意的是Autowired按类型注入时如果目标对象带泛型比如ListPayServiceSpring也会识别这个特性在一些事件监听、策略分发场景里非常实用。4.2 Autowired和Resource到底差在哪这个区别几乎是每次面试必问但很多人只是背答案Autowired先按类型再按名字Resource先按名字再按类型。实际用起来它们的差异其实会影响代码的健壮度。Autowired是Spring提供的注解核心匹配逻辑是按类型byType。在类型匹配只有一个候选时一切顺利多个类型匹配时才会降级到按名字匹配但那个“名字”通常很难直观控制。Resource是JDK的标准注解它优先按字段名或指定的name去匹配byName找不到再按类型匹配。所以当场景是“接口有多个实现我想指定某一只”时Resource反而更直接Component public class OrderService { Resource(name channelBService) private PayService payService; }不过如果你已经全面拥抱构造器注入那这两个注解的争执其实已经不太重要了因为构造器参数配合Qualifier就能解决一切指定问题。记住一个原则普通单一实现随便选多实现指定某个优先用Qualifier或Resource(name...)。4.3 循环依赖容器能救的只有一种而且还被官方嫌弃了循环依赖大概是Spring装配里面最有“流量”的话题了。所谓循环依赖就是A依赖BB又依赖A。Spring在默认单例模式下对这个坑是能做部分补救的原理是三级缓存。先看一个经典例子Service public class AService { Autowired private BService bService; } Service public class BService { Autowired private AService aService; }如果是Setter注入或字段注入Spring能启动成功因为容器允许提前暴露“半成品”。那三级缓存具体是怎么回事我用大白话解释一级缓存singletonObjects存放已经完全创建好的成品Bean二级缓存earlySingletonObjects存放“还没初始化完但已经能拿出来用的早期Bean”也就是半成品三级缓存singletonFactories存放能生成早期Bean的工厂等真正要用到这个半成品时才会调用工厂生成并移到二级缓存。当创建A时容器发现A需要B于是先去创建B创建B时又发现B需要A如果此时A还没完成容器就从三级缓存里拿到A的早期引用塞给B让B先完成创建B创建完后再回填给A最终A也完成初始化。整个过程就是“提前曝光一个不完整但可用的对象”。但注意这套机制只对“非构造器注入”的单例Bean有效。如果是构造器注入造成的循环依赖Spring在推断构造方法时根本没法拿到对方对象的引用因为构造阶段连半成品都没有所以只能直接报错。正因如此从Spring Boot 2.6开始官方默认禁止了循环依赖启动时就会提示你使用spring.main.allow-circular-references去显式打开。我在实际项目中遇到循环依赖时的处理原则很简单先识别它是不是“设计坏味道”。真正合理的做法是拆服务、引中间层、用ObjectProvider延迟获取而不是依赖三级缓存来续命。容器能救你一次不代表你应该主动往坑里跳。4.4 静态工具类里拿不到Bean是很多人卡过的大坑这个坑也很经典。你写了一个工具类想在静态方法里用别人注入好的Mapper或Service于是直接在类里写Autowired private SomeMapper someMapper;结果运行时报空指针。原因很简单静态字段不属于任何一个Bean实例Spring在创建Bean时根本不会去管静态字段的注入。你标注了Autowired也没用它只对实例字段有效。一种常见的解决方案是在Spring管理的组件里用PostConstruct把实例赋给静态变量Component public class SpringContextHolder { private static SomeMapper someMapper; Autowired private SomeMapper injectedMapper; PostConstruct public void init() { SpringContextHolder.someMapper this.injectedMapper; } public static SomeMapper getSomeMapper() { return someMapper; } }还有一种更普适的做法自己实现一个ApplicationContextAware组件把ApplicationContext保存下来静态方法里随时通过getBean()拿任何一个Bean。我实际项目中通常两种方案都保留一个用于读取常用依赖一个用于按需动态获取。但退一步讲静态方法里有业务依赖往往说明设计值得商榷。把业务逻辑放进Spring管理的Service里让静态工具类保持“无状态纯方法”通常会让项目干净很多。5. 自己手写一个迷你IoC容器把黑盒彻底拆开5.1 迷你容器要做什么学Spring IoC的时候我曾经花了两个晚上写了一个只有几百行的迷你容器。写完之后的收获比看十篇源码解析都大。我建议你也动手做一次不需要完整复刻Spring只要做到这几件事就够能扫描指定包下带Component注解的类能把扫描到的类注册成“BeanDefinition”这里简化为一个Map能根据定义创建单例实例能扫描实例里的Autowired字段完成依赖注入能提供一个getBean(String name)方法。你会发现Spring庞大的核心机制本质上就是对这几件事的各种细节强化。5.2 一段能跑起来的核心实现下面是我当时写的简化版本只保留了最核心的逻辑public class MiniSpringContainer { private final MapString, Object singletonObjects new ConcurrentHashMap(); private final MapString, Class? beanDefinitionMap new ConcurrentHashMap(); public void scan(String packageName) throws Exception { // 1. 扫描包下所有Class找到带Component注解的类 // 真实项目里用类路径扫描工具这里用反射简化 SetClass? classes doScan(packageName); for (Class? clazz : classes) { if (clazz.isAnnotationPresent(Component.class)) { String beanName lowerFirst(clazz.getSimpleName()); beanDefinitionMap.put(beanName, clazz); } } } public void createAndInject() throws Exception { // 2. 第一遍先创建所有实例解决简单的循环依赖问题 for (Map.EntryString, Class? entry : beanDefinitionMap.entrySet()) { Object instance entry.getValue().getDeclaredConstructor().newInstance(); singletonObjects.put(entry.getKey(), instance); } // 3. 第二遍逐个执行字段注入 for (Object bean : singletonObjects.values()) { injectDependencies(bean); } } private void injectDependencies(Object bean) throws Exception { for (Field field : bean.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(Autowired.class)) { Object dependency singletonObjects.get(field.getName()); field.setAccessible(true); field.set(bean, dependency); } } } public T T getBean(String name) { return (T) singletonObjects.get(name); } }这个实现虽然简陋但已经能让你体验到“控制反转”的真正感觉你在代码里写的是接口或实现类的字段容器负责把对应的Bean实例填进来调用方完全不需要关心对象是怎么创建的。我特别想强调第二遍注入这个设计。我第一版写得不够好扫描到一个类就立刻实例化再注入结果A依赖B时B还没创建直接空指针。后来我改成“先全部实例化再统一注入”这是一个关键转折。虽然真正的Spring用的是三级缓存但“先放实例再填属性”这种思路给了你一个很好的起点去理解它为什么需要提前暴露半成品。5.3 写完迷你容器后我才真正看懂Spring的设计哲学手写容器最大的价值不是让你造一个能用的框架而是让你站在设计者的角度去想问题为什么要用BeanName作为key因为从使用方来说一个可读的字符串比一个Class对象更友好为什么要区分定义和实例因为定义是一次性的、轻量的而实例是要常驻内存的为什么要有懒加载、原型作用域因为不是所有对象都需要在启动时创建也不是所有对象都需要共享。这些问题的答案在Spring文档里都是干巴巴的一句话但要真正消化必须自己踩过一遍“为什么不能直接new”的坑亲手写一遍容器才能真正建立起“容器思维”。我后来带新人的时候都会安排一个周末的小练习不看Spring自己实现一个能注入依赖的迷你容器。做完这个练习的人再去学BeanPostProcessor、Configuration这些Spring高级特性接受速度和理解深度完全是两个档次。最后分享一个我在写这个迷你容器时最深的体会真正理解控制反转的那一刻不是你给某段代码加了Autowired的那一刻而是你发现——原本决定“对象怎么来”的代码从业务类里彻底消失了它被统一收编到容器里。那之后你的目光就可以从“怎么new对象”上升到“怎么设计方案”这个转变值得每一个Java后端花时间去完成。