资讯动态

深入理解Spring IOC:从控制反转思想到容器与依赖注入

发布时间:2026/10/13 11:36:59 来源:尧图企业网站定制
十多年前我第一次接触Spring时最不理解的就是“IOC”这三个字母。一个变量赋值原本写new就行凭什么要让容器来管后来多做了几个项目被业务逻辑的耦合拍过几次脸才明白IOCInversion of Control控制反转和它背后的依赖注入机制解决的从来不是“怎么写代码”而是“怎么让代码在变化里活下去”。这篇文章我尽量不抄概念从一个实际开发者的角度把Spring IOC从思想、容器、Bean生命周期到循环依赖、常见坑完整讲一遍。无论你是准备面试还是刚接手一个老Spring项目想弄明白内部逻辑又或者只是想把注解背后的原理补全这篇都值得你慢慢看。1. 为什么需要IoC先解决问题再谈思想很多教材上来就甩定义“IOC就是控制反转把对象的创建和依赖关系的维护交给Spring容器。”这话没错但没说到痛点上。我们得从没有容器时的代码长什么样说起。1.1 没有容器的时候代码是怎么被“焊死”的假设你在做一个订单服务里面需要调用平台通知服务来发消息。最省事的写法就是public class OrderService { private MessageService messageService new MessageService(); public void createOrder(Order order) { // 业务逻辑... messageService.sendMessage(您的订单已创建); } }看起来没有问题但问题出在实际项目里。消息服务可能要换成短信、邮件、站内信多种实现测试的时候想用一个不会真的发短信的Mock实现甚至MessageService的构造方法本身还依赖了Redis、配置中心等一堆东西。这时候你每次改实现都要打开OrderService改那一行new改完重新编译如果依赖链再长一点改一处牵动几百个类都不是夸张。Java里一个类“主动”去new出另一个类本质是把两个类的生命周期和实现方式焊死在了一起。这不是IOC思想所排斥的“实践语法”而是排斥这种顶层业务逻辑与底层基础设施高度耦合的组织方式。耦合本身不可怕可怕的是耦合方向失控上层业务依赖下层细节下层一换上层全改。1.2 控制反转是怎么发生的IOC的关键词是“反转”。在传统代码里对象获取依赖的主动权在本类手里——自己创建、自己管理生命周期。反转之后这个主动权交到一个“容器”手上每个类只负责声明“我需要什么依赖”由容器在合适的时机把自己管理的对象“注入”进来。还是那个订单服务public class OrderService { private final MessageService messageService; public OrderService(MessageService messageService) { this.messageService messageService; } }看到没有OrderService不再new任何东西它只是通过构造器表达“我不关心你怎么给我但我需要MessageService”。MessageService是短信、邮件还是Mock实现由外部配置决定。这种把依赖关系的装配从类内部剥离出来的方式就是控制反转。用一个生活化的例子老式收音机要换电池你得打开后盖自己装现在的设备直接插充电器充电器适配什么电源是由外部适配器决定的。你的业务类就是那台设备IOC容器就是充电器它负责把电流调成设备需要的规格再送进去。1.3 IoC和DI的关系别再搞混很多人把IOC和依赖注入Dependency InjectionDI当成同一个东西严格说DI是IOC的一种实现方式。IOC是一种设计思想它的核心是控制权的转移DI是具体的手段通过构造器、Setter、接口等方式把依赖给到对象。Spring选择用DI来实现IOC你使用Spring时写的大多数配置和注解其实都是在做依赖注入。理解到这一层就够了你不需要在面试里咬文嚼字但得能说清“IOC是思想容器是载体DI是落地手法”。有了这个底座后面看BeanFactory、BeanDefinition、循环依赖都会顺很多。2. 容器到底是什么BeanFactory与ApplicationContextIOC思想说得再漂亮没有容器落地都是空谈。Spring的IOC容器简单说就是一个“对象工厂注册表生命周期管理器”。你告诉容器有哪些类、它们的依赖关系容器负责创建、管理、销毁并在合适的时候注入。2.1 BeanDefinition容器里存的不是对象而是“配方”先抛一个容易误解的点容器启动时并不是立刻new出所有Bean对象而是先读配置把每个Bean的类名、作用域、是否懒加载、构造器参数、属性值、初始化方法等信息存成BeanDefinition。BeanDefinition就是Bean的“配方”容器根据配方再决定什么时候烘焙怎么烘焙。比如XML配置bean idmessageService classcom.example.MessageService scopesingleton/Spring解析后并不是马上执行new MessageService()而是生成一个GenericBeanDefinition它的beanClassName字段记录为com.example.MessageServicescope字段为singleton。直到某个地方真正调用getBean(messageService)或者容器初始化阶段预实例化单例Bean时容器才会反射创建实例。明白这一点你就明白为什么Spring能实现“懒加载”也明白为什么XML里写错了一个类名有时候启动不报错等到getBean时才崩——因为容器压根还没走到实例化那一步。2.2 两个核心接口该怎么选BeanFactory是IOC容器的顶层接口定义了getBean、containsBean、isSingleton、isPrototype这些最基础的能力。ApplicationContext是BeanFactory的子接口在这个基础上扩展了国际化、事件发布、资源加载、环境解析等能力。关系可以这么记接口角色典型实现BeanFactory最底层容器管对象创建与装配XmlBeanFactory已废弃ApplicationContext企业级容器带AOP、事件、资源加载ClassPathXmlApplicationContext、AnnotationConfigApplicationContext实际开发中我几乎从不直接使用BeanFactory。ApplicationContext才是日常容器因为Spring的自动配置、切面、事件机制全部建立在ApplicationContext提供的扩展能力上。如果面试官问“两个接口的区别”你重点强调ApplicationContext继承了BeanFactory并增加了更多企业特性即可。2.3 一个最简单的容器启动过程用代码走一遍感受最直接。最简单的容器启动本质上只有三步// 1. 加载配置这里用注解配置类 AnnotationConfigApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class); // 2. 从容器获取Bean OrderService orderService context.getBean(OrderService.class); // 3. 使用 orderService.createOrder(); // 4. 关闭容器释放资源 context.close();AppConfig长这样Configuration public class AppConfig { Bean public MessageService messageService() { return new SmsMessageService(); } Bean public OrderService orderService(MessageService messageService) { return new OrderService(messageService); } }new AnnotationConfigApplicationContext(AppConfig.class)这一行容器内部做的事比你想象的多得多扫描配置类上的注解、注册BeanDefinition、调用BeanFactoryPostProcessor处理配置、预实例化所有非懒加载的单例Bean、处理依赖注入、执行BeanPostProcessor……这一整套流程才换来你后面一行getBean能拿到一个直接用、依赖已经组装好的对象。我建议每个刚开始接触Spring的人都去断点跟一遍refresh()方法。我第一次跟的时候发现里面触发事件、注册处理器、提前实例化Bean的流程密密麻麻但跟完一次之后对Spring的敬畏感直接拉满以后再遇到启动报错也不会一头雾水了。3. 依赖注入三种姿势怎么选不后悔Spring支持三种依赖注入方式字段注入、Setter注入、构造器注入。每个人都有倔强但是经历过几个烂项目以后我在新代码里只用构造器注入。为什么理清楚你就明白。3.1 字段注入写起来最爽坑最多Service public class OrderService { Autowired private MessageService messageService; }这是网上最常见的写法加了Autowired一行注解搞定舒服。但它的代价是隐藏的。字段注入是类实例化完成之后由容器通过反射强行给私有字段赋值。这样会带来几个现实麻烦单元测试很别扭。你想直接new OrderService()再塞一个Mock进去不行字段私有只能靠反射或者不得不继承Spring上下文。这等于让简单测试变成了重量级测试。依赖关系对调用方不透明。构造器参数表一眼能看全依赖字段注入的属性列表只能把类字段全看一遍才知道遗漏了谁出了问题很难发现。绕过了构造器把“必须依赖”和“可选依赖”混为一谈。一个一万行代码的Service类字段注入的依赖可能散落七八个谁用谁都不知道不作为参数传进去的话类是否完整初始化了。字段注入还有一个很容易踩的隐性坑它能注入null。你没看错如果容器里没有匹配的Bean而required被设为false注入的就是null结果业务跑到一半才出现NullPointerException排查起来非常痛苦。3.2 Setter注入适合可选依赖和动态调整Service public class OrderService { private MessageService messageService; Autowired public void setMessageService(MessageService messageService) { this.messageService messageService; } }Setter注入的对象依赖是实例化之后才推进来的好处是允许对象先创建一个默认状态再接收外部依赖。早期Spring的XML配置大量使用它因为XML那套property赋值本质就走Setter。它的缺点也很明显Bean可以被创建出一个不完整的状态。如果你的Setter是必填的那在没有被调用的期间这个Bean就是一个残缺的对象。CGLIB动态代理某些场景下会触发两次Setter调用处理不好还会出诡异问题。所以Setter注入我只建议留给那些“有默认实现、可替换、非必需”的依赖比如数据源模板、可选的通知组件。3.3 构造器注入我的默认选择Service public class OrderService { private final MessageService messageService; public OrderService(MessageService messageService) { this.messageService messageService; } }构造器注入有三个不可替代的优势依赖不可变final对象一创建就是完整状态测试不用容器直接new就行。只要容器能提供全部参数Bean就是合法可用的这是最接近面向对象本意的做法。Spring官方文档也一直在推荐构造器注入因为强制依赖必须完整传递可选依赖才考虑Setter。而且一旦你习惯了构造器注入你会自然发现一个好处类一旦有了很多粒度很细的S效验依赖构造器参数会越来越多这会逼你意识到类职责过载应该拆分了。这是一种在长期上有益的“代码坏味道体检”。比较下来选择逻辑并不复杂维度字段注入Setter注入构造器注入依赖不可变性不可不可可单元测试便利性差需要反射一般好类创建完整性可能不完整可能不完整必然完整循环依赖天然支持支持支持不支持职责边界提示无无有多说一句很多人问“构造器注入会不会让代码太啰嗦”。项目里配合Lombok一个RequiredArgsConstructor就能把所有final字段对应的构造器生成好代码并没有变多收益却实打实拿到了。4. Bean生命周期从定义到销毁每一步都有钩子我在面试中喜欢问Bean生命周期因为这一个问题能看出一个人是背过八股还是真的动过手。Bean生命周期远不止“实例化-属性填充-初始化-使用-销毁”这么简单它里面藏着大量扩展点理解了它很多“神秘问题”的答案就直接浮出水面。4.1 完整生命周期时间线一个普通单例Bean的完整生命流程从容器角度可以拆成下面这些阶段根据BeanDefinition实例化通过构造器反射创建对象但这时属性还没赋值。属性填充容器把通过依赖注入得到的Bean值、直接配置的属性值装配到Bean里。感知阶段如果Bean实现了BeanNameAware、BeanFactoryAware、ApplicationContextAware等接口容器会回调把名字、工厂、上下文传进来。前置处理执行所有BeanPostProcessor的postProcessBeforeInitialization方法。初始化依次执行PostConstruct注解方法、InitializingBean.afterPropertiesSet()、自定义init-method。后置处理执行所有BeanPostProcessor的postProcessAfterInitialization方法。这一步是AOP代理生成的关键位置很多代理对象都是在这里被替换掉的。使用Bean对象被交到各处引用正常工作。销毁前执行PreDestroy注解方法、DisposableBean.destroy()、自定义destroy-method。有人会觉得这几步很细碎但实际排查问题时稍微错一点顺序就会引发连锁反应。我踩过最典型的坑是一个Bean在PostConstruct里预加载缓存结果发现缓存数据依赖的依赖对象还没完全初始化好。为什么因为如果那个依赖对象自身也处在生命周期初始化阶段他不一定保证所有嵌套扩展点执行完了。这时候要捋清楚谁在哪个阶段做了什么别指望顺序“想当然”。4.2 初始化扩展点的优先顺序先把初始化阶段的顺序固定下来这是也是面试最爱拷问的执行次序扩展点用法1PostConstruct初始化前做业务准备推荐2InitializingBean.afterPropertiesSet()Spring专有接口灵活性一般3Bean(initMethod init)基于配置指定回调方法不侵入代码一个Bean可以同时定义三种初始化方式Spring会按上面顺序依次执行。PostConstruct是最推荐的位置因为它是标准注解也不依赖Spring专有的接口。4.3 BeanPostProcessor整个Spring魔法的大本营如果说“实例化”是Bean成为一个对象那么“BeanPostProcessor”就是给这个对象装配灵魂的地方。BeanPostProcessor本身是回调接口Spring容器在Bean初始化前后都会调用所有实现类Component public class LogBeanPostProcessor implements BeanPostProcessor { Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof OrderService) { System.out.println(订单服务初始化完成可以记录日志或者做代理增强); } return bean; } }注意这里的关键点postProcessAfterInitialization返回的对象会被覆盖原对象。所以很多框架比如AOP、事务就是在这里对Bean做CGLIB动态代理然后把代理对象塞回去。如果你在代码里某个阶段对Bean做了引用缓存却不小心引用到了代理生成前的原始对象那么后续看到的方法调用行为和预期不一致多半就是这个原因。平时如果遇到“我明明在配置里加了切面但就是不生效”先不要急着怀疑切点写错了去看一下你的Bean是不是已经被其他BeanPostProcessor“拦截”或替换过了或者Bean初始化顺序导致处理器还没注册就提前实例化了。这种事我在老项目里排查过不止一轮。5. Bean作用域默认单例比你想的复杂Spring的Bean默认作用域是singleton也就是容器内同一个Bean定义只有一个实例。绝大多数Service、Mapper、Controller都适合单例因为它们是无状态或者只读的。但一旦涉及状态问题就来了。5.1 作用域种类与选择作用域说明使用场景singleton默认全容器一个实例无状态Service、DAOprototype每次getBean都创建新对象带状态对象、临时计算任务对象request每个HTTP请求一个实例Web层一次请求内共享状态session每个HTTP会话一个实例会话级用户信息application整个ServletContext一个实例全局共享配置日常业务里90%的Bean用默认单例就够了。prototype并没有想象中那么常用因为容器并不会管理它的完整生命周期销毁方法不会自动触发用多了反而自己背包袱。5.2 单例注入原型依赖的经典难题这个场景我在很多项目里见过一个单例的OrderService注入了一个原型作用域的TaskRunner期望每次处理任务都用新的TaskRunner。但实际跑起来你会发现TaskRunner永远是同一个实例。原因很简单依赖注入发生在OrderService实例化阶段容器只注入了一次之后OrderService就一直拿着那个旧引用。不是说容器没能力再给你新对象而是因为它已经注入过了没有再重新注入的动作。5.3 解决方案对比解决这个问题至少有三条路直接用ObjectProviderT延迟获取Service public class OrderService { private final ObjectProviderTaskRunner taskRunnerProvider; public OrderService(ObjectProviderTaskRunner taskRunnerProvider) { this.taskRunnerProvider taskRunnerProvider; } public void createOrder() { TaskRunner runner taskRunnerProvider.getObject(); runner.run(); } }通过ApplicationContext手动getBean。可行但不推荐等于把容器当Service Locator用侵入性太强也难测试。设置Scope(value prototype, proxyMode ScopedProxyMode.TARGET_CLASS)给原型Bean加CGLIB代理让每次方法调用都路由到一个新实例。这个方式最省心但代理粒度会带来一定的理解和调试成本我一般只在低频率调用的场景用。我自己的原则优先用ObjectProvider因为它语义清晰依赖关系直观也不会引入代理魔法更重要的是它天然支持“没有Bean时返回默认值”“多个候选时按顺序获取”这些扩展场景。6. 循环依赖三级缓存是怎么绕过去的循环依赖是Spring面试里绕不开的话题也是很多人在项目里真实碰到的启动失败原因。先说结论Spring通过一套三级缓存机制默认支持单例字段/Setter注入场景下的循环依赖但构造器注入的循环依赖以及非单例作用域下的循环依赖容器无法自动解决。6.1 什么是循环依赖以及为什么构造器注入会失败循环依赖就是A依赖BB也依赖A。比如Component public class A { private final B b; public A(B b) { this.b b; } } Component public class B { private final A a; public B(A a) { this.a a; } }容器创建A时发现需要构造器参数B创建B时又发现需要构造器参数A。两边都等对方先完成谁也没法先落地最终启动报BeanCurrentlyInCreationException。为什么字段注入没事因为字段注入是在对象实例化完成之后才填充依赖的。A可以先“半成品式”地存在容器缓存里B注入A的时候直接拿缓存的那个半成品先把B完整创建完到回头填充A的属性时B已经完整了A也就完成闭环了。用白话概括构造器注入要求对象一出生就是完好的所以没法用半成品兜底。6.2 三级缓存的流转过程Spring维护三个Map官方名称通常叫一级缓存singletonObjects存最终完成的单例Bean。二级缓存earlySingletonObjects存提前暴露的半成品Bean只实例化未完成注入。三级缓存singletonFactories存一个ObjectFactory用于生成半成品Bean的早期引用通常是为了配合AOP代理。还是A和B互相依赖的流程创建AA的构造器执行完对象的“壳”有了但属性还没填充。容器把A放入三级缓存生成一个可供提前获取A早期引用的工厂。填充A的属性发现要注入B于是去创建B。创建B的过程也会把B放进三级缓存。B填充属性时发现要注入A此时从三级缓存中拿到A的早期引用如果需要代理会在这里生成代理。B创建完成进入一级缓存。回到A的属性填充把完整的B注入给A。A完成所有初始化再进入一级缓存。注意三级缓存里存的并不是Bean本身而是ObjectFactory。它存在的意义是当A需要被AOP代理时早期引用就应该直接是代理对象而不是在最后阶段才统一生成代理否则B里持有的A引用和最终代理A就会是两套对象。为了这个“引用一致性”三级缓存才多写了一层。6.3 循环依赖的坑与设计建议能绕过去不代表应该放任。循环依赖本身是设计问题“请把依赖方向理顺”才是根治方法。我在项目里常做的是先看两个类是不是真的互相依赖还是其中一方把职责放错了层。比如A需要BB又需要A的部分能力往往应该把公共能力抽给第三个对象或者让B不再依赖A只依赖A暴露的一个回调接口。还有一些必须警惕的场景构造器循环依赖不会自动解决启动就会报错。prototype作用域的循环依赖不会自动解决因为每次取都是新对象缓存缓存不住。一旦启用了AOP循环依赖的早期引用会变成代理对象可能导致一个Bean内部感知到自己是个代理从而在一些自身调用场景出现诡异行为。所以我的经验是别把三级缓存当成漏洞修补器能重构就重构能抽接口就抽接口。循环依赖最多出现在老代码、坏味道和过渡服务化拆分里新代码里如果再交叉依赖Review就直接打回去了。7. 实战避坑清单这几条是我在这些年用Spring被折腾过、也帮别人擦过屁股的真实场景整理成一份速查遇到同类问题可以直接翻。7.1 多个实现类不知道注入哪个Autowired private PaymentService paymentService;当PaymentService有两个实现类时Spring会报告NoUniqueBeanDefinitionException。解决办法三选一在实现类上标Primary指定默认在注入点按名称用Qualifier(wechatPayService)或者干脆把多个实现注入ListPaymentService按业务规则轮询。我遇到过最脏的写法是为了跳过启动报错在一个类上同时加Primary又在别的注入点加Qualifier最后名字和类型全乱了。其实最佳实践是注入集合让容器把全部实现按序给进来再由上层策略选择。7.2 非得用字段注入结果单元测试跑不起来凡是短小的示例都喜欢用字段注入但当你同事把一个字段注入的Service丢进单测时你要么Mock各种内部依赖要么干脆启动整个Spring上下文。我见过一个老测试类因为服务里五个字段注入测试之前得new一个AnnotationConfigApplicationContext来装配整个容器测试跑一次三四秒最后没人愿意维护。真正的解法只有一个从源头改成构造器注入。7.3 final字段配合构造器注入的两个注意点如果你的实体类通过构造器注入又声明了final字段Spring在代理这类Bean时会有一定风险。有些代理方式无法给final字段重新赋值使用时要确认你的AOP框架对final的兼容性。用了Lombok的RequiredArgsConstructor后记得把构造器参数名保留在编译字节码里-parameters否则某些老版本解析参数名失败你得靠Qualifier显式指定。7.4 泛型注入的坑Spring其实支持ResolvableType级别的泛型注入比如Autowired private RepositoryUser userRepository;容器能根据RepositoryUser这个泛型类型去匹配对应的Bean。如果有RepositoryUser和RepositoryOrder两个Bean靠类型可以分辨。但如果你处理的是子类泛型匹配规则会变得很微妙尤其在CommonMaps、泛型擦除的参数列表中经常出现竟然匹配到两个实现或者完全匹配不到的报错。我最无语的一次是一个简单接口两个实现类分别给了不同的泛型实参Spring竟然能找到Bean但到了方法参数注入时又提示找不到。后来才发现方法参数是裸类型MapString, T泛型被部分擦除。解决办法是换成长得清清楚楚的具体类型做注入别跟泛型擦除硬刚。7.5 同类内部调用导致代理失效Spring的AOP代理对外部调用生效对同类内部方法调用不生效。如果你在一个Service里有public void doPay() { this.doRealPay(); }切面挂在doRealPay上但内部通过this调用代理对象没有机会劫持这次调用切面也就静默失效了。我遇到过一次代码里日志、事务、耗时统计全挂了排查半天发现就是内部调用惹的祸。解决方式是拆到另一个Bean里去调用或者自己注入一个代理引用。如果实在改不了结构可以用AopContext.currentProxy()拿当前代理对象不过一来不优雅二来要配置exposeProxytrue属于最后手段。8. 手写一个轻量IoC容器验证你的理解讲了这么多可能有人还是觉得容器是“黑盒”。我建议你去手写一个最小的IoC容器不用考虑生命周期那么多复杂钩子只要覆盖三个核心能力注册Bean定义、创建单例、依赖注入。下面这个示例就足够说明一切public class MiniIocContainer { private final MapString, Object singletonObjects new ConcurrentHashMap(); private final MapClass?, Object byTypeIndex new ConcurrentHashMap(); public void register(Class? clazz) throws Exception { String beanName lowerFirst(clazz.getSimpleName()); Object instance clazz.getDeclaredConstructor().newInstance(); singletonObjects.put(beanName, instance); byTypeIndex.put(clazz, instance); } public void injectDependencies() throws IllegalAccessException { for (Object bean : singletonObjects.values()) { for (Field field : bean.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(MyInject.class)) { Object dependency byTypeIndex.get(field.getType()); field.setAccessible(true); field.set(bean, dependency); } } } } public T T getBean(ClassT clazz) { return clazz.cast(byTypeIndex.get(clazz)); } private String lowerFirst(String name) { return Character.toLowerCase(name.charAt(0)) name.substring(1); } }这个版本虽然简陋但它把IOC容器的最核心逻辑展示得非常清楚一个Map存对象一个Map存类型索引反射走字段注入。你再回去看Spring那套复杂流程就会发现Spring不过是把这种基本逻辑扩展了无数倍加了BeanDefinition、加了后置处理器、加了作用域、加了代理生成、加了各种条件装配。底层思路没变变的是工程的严谨性和可扩展性。如果你能继续往这个迷你容器里加“构造器参数解析”、“BeanPostProcessor模拟”、“Bean方法配置”那说明你对Spring的理解已经深入到了骨架层面。之后再去看Dubbo、Seata这些框架如何自己手写Spring自动装配也不会觉得多神秘了。我个人的体会是IOC这东西用它很容易懂它得踩坑。把控制权交给容器的本质是把“依赖关系”作为系统的一等公民来治理。原子化的Bean、显式的注入点、生命周期钩子都在帮你构建不那么混乱的代码边界。如果你刚开始学别急着背三级缓存和生命周期八股先动手写一个Service把它拆成接口和实现再换个实现类跑一遍你就会发现“改配置比改代码安全”这句话的力量。等你有过几次“明明代码看着没问题但容器就是不按我想的来”的经历后再回来看这篇文章很多地方就有了新的共鸣。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑