
1. 为什么需要IoC容器从一个改到崩溃的老项目说起1.1 一段让人头疼的真实经历先讲一段我早年的经历。那时候接手一个老牌Java Web项目代码里到处是new UserDao()、new UserService()这种写法类与类之间直接“焊死”。一开始觉得没什么无非多写几行代码直到产品经理提了个需求把底层数据库从MySQL换成Oracle。噩梦来了——因为UserDao的实现类被十几个Service直接new出来我整整花了两天时间全局搜索替换、重新编译、反复回归测试最后还是漏了一个地方上线当天差点出事。那之后我意识到一个非常朴素的问题对象之间的依赖关系如果由每个对象自己来创建那系统就是一个互相咬死的齿轮箱牵一发而动全身。真正合理的做法是把创建对象的权利交出去交给一个统一的地方去管理。这正是Spring IoCInversion of Control控制反转容器在做的事情。1.2 IoC翻转的到底是什么很多初学者看到“控制反转”这四个字就头大我换个说法以前你租房得自己看房源、联系房东、比对价格、签合同一套流程全是你自己干现在你找中介把需求告诉他他给你配好房。这里的核心变化不是你不用付钱了而是找房这件事的控制权从你手里转移到了中介手里。在Java世界里也是一样。以前Service类里要自己new Dao()依赖谁、怎么构造都由Service自己说了算这是正向控制引入Spring之后Service只需要在构造器或者字段上声明“我需要一个Dao”容器就会在合适的时机把创建好的Dao实例注入进来。对象不再主动查找依赖而是被动接收依赖这就是控制反转。那DIDependency Injection依赖注入和IoC是什么关系你可以这么理解IoC是一种指导思想“你不应该自己去找依赖”而DI是这个思想在Spring里的落地方式“容器主动把依赖送给你”。两者本质是同一枚硬币的正反面这就是为什么面试题里总爱把“Spring IoCDI”放在一起问。1.3 这篇文章适合谁如果你是个刚接触Spring的Java开发者想知道Autowired背后到底发生了什么或者你写了两三年Spring用了无数个注解但被面试官问到“三级缓存到底解决什么问题”时语塞再或者你正在排查一个莫名的BeanCurrentlyInCreationException这篇文章都值得看完。我会从容器启动讲起一直讲到Bean初始化、循环依赖的兜底机制最后落到实际项目中那些“文档里不会写”的坑上。2. 从BeanDefinition到单例池容器启动时到底做了什么2.1 BeanDefinition每一个Bean的“图纸”很多人觉得Spring容器启动就是从ApplicationContext开始然后Bean就自动创建好了中间过程像个黑盒。其实容器做的第一件事不是创建对象而是收集“怎么创建对象”的信息。这些信息被封装成一个叫做BeanDefinition的东西。你可以把BeanDefinition想象成一张建筑图纸图纸上记录了这个Bean的类全名是什么、是单例还是原型、是懒加载还是启动时就创建、构造器参数有哪些、属性依赖哪些其他Bean、初始化方法和销毁方法分别是什么。Spring拿到图纸之后才会按照图纸去“施工”。图纸上没有的信息容器一概不知道。举个例子你随手写了一个Service注解的类Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao userDao; } }Spring在启动扫描时会先把这个类解析成一个ScannedGenericBeanDefinition它的beanClassName是com.example.UserServicescope是singleton它还记录了构造器上有个参数引用UserDao。直到这一步UserService的对象都还没有被创建出来一切都还停留在“图纸”阶段。2.2 配置方式的三次演变既然要生成图纸就绕不开“Spring怎么知道要解析哪些类”这个问题。我见过最早的Spring项目用的是XML配置一个beans.xml文件里全是bean iduserService classcom.example.UserService/这种标签类多了以后配置文件比代码还长维护成本极高。后来Spring推出了注解驱动用Component、Service、Repository标记类容器按包路径去扫描。再到Spring 3.0之后的Java Config用Configuration加Bean方法显式声明推荐用代码来配置。三种方式本质都在做同一件事告诉容器哪些类要变成Bean以及这些Bean之间怎么关联。只是表达方式不一样。同样的UserService三种写法分别是bean iduserService classcom.example.UserService/Component public class UserService { ... }Configuration public class AppConfig { Bean public UserService userService(UserDao userDao) { return new UserService(userDao); } }我个人的建议是新项目直接走注解扫描加Java ConfigXML那套除非是老项目维护否则不要再去学它。注解的方式对IDE更友好能编译期检查类型重构起来也安全得多。2.3 注册图纸的完整流程整个注册流程我结合源码给你捋一个简化版本容器启动读取配置Java Config类或XML文件或组件扫描路径。通过ClassPathBeanDefinitionScanner扫描指定包下所有带Component包括Service、Repository、Controller等派生注解的类。对每个候选类先生成ScannedGenericBeanDefinition放进一个BeanDefinitionRegistry注册表中此时注册表里存放的是“图纸”。所有图纸注册完成后容器开始遍历注册表逐个调用getBean触发Bean的创建。创建完成的单例Bean最终放进singletonObjects这个单例池也就是常说的第一级缓存ConcurrentHashMap以后再有人要这个Bean直接从池子里拿现成的。这里有个特别容易忽略的细节Bean方法和Component扫描虽然都会生成BeanDefinition但时机不一样。Component扫描是在容器启动的早期阶段通过扫描器完成的而Bean方法的解析是在后续处理Configuration类时才通过ConfigurationClassPostProcessor完成的。这两者处理顺序有严格的前后关系如果将来你手写Spring框架或者看源码这个顺序是理解一堆Bug的关键。3. 依赖注入的三种方式字段、Setter、构造器怎么选3.1 三种注入方式的代码对比搞清楚容器怎么创建Bean之后我们来看DI最日常的使用——把一个Bean注入到另一个Bean里。最常见的注入方式有三种我直接给你对比代码// 方式一字段注入 Service public class OrderService { Autowired private OrderDao orderDao; }// 方式二Setter注入 Service public class OrderService { private OrderDao orderDao; Autowired public void setOrderDao(OrderDao orderDao) { this.orderDao orderDao; } }// 方式三构造器注入 Service public class OrderService { private final OrderDao orderDao; public OrderService(OrderDao orderDao) { this.orderDao orderDao; } }三种方式Spring都支持。但如果你用IDEA开发会发现一个有趣的现象字段注入的写法在IDEA里会被打上警告“Field injection is not recommended”。很多人不理解明明写得最省事为什么不推荐3.2 不推荐字段注入的深层原因字段注入被警告主要是有四个现实问题。第一是依赖被隐藏。字段注入让依赖关系藏在了类内部你光看这个类的构造器和接口根本不知道它依赖了什么。而构造器注入则是“明牌”构造器参数列表里写得清清楚楚。第二是不利于测试。做单元测试时字段注入没法绕开Spring容器直接构造对象因为你无法在测试代码里给private字段赋值除非用反射或者InjectMocks这类Mock工具。构造器注入就简单了直接new OrderService(mockDao)完事。第三是无法标记final。Spring官方文档里有一句话说得特别好使用构造器注入可以保证依赖在对象创建的那一刻就被提供而且字段是final的天然具备不可变性不会出现“Bean创建出来了但依赖还没注入”这种中间状态。第四是带来的循环依赖问题。字段注入让两个Bean互相注引用变得“太容易”代码写着写着就写出A依赖B、B依赖A的循环依赖。其实很多循环依赖在代码设计上根本没有必要只是字段注入写起来太顺手大家都忽略了设计问题。3.3 各种注入方式的选型建议那我自己的项目里到底怎么选给你一个明确的经验场景推荐方式理由必选依赖构造器注入对象创建即就绪不可变易测试可选依赖Setter注入可以允许空值或后期替换实现快速原型/教程演示字段注入代码量最少但仅限临时场景一个类依赖超过四五个重新审视类设计很可能是职责过重该拆分构造器注入唯一让人不爽的地方是如果构造器参数很多写起来比较啰嗦。Lombok的RequiredArgsConstructor注解可以自动生成包含final字段的构造器配合final字段使用代码简洁度和优雅度都兼顾了。这是我目前最推荐的组合RequiredArgsConstructor加final修饰的依赖字段。4. Bean生命周期全流程从找到构造器到初始化完成4.1 实例化之前Spring怎么决定用哪个构造器当容器准备创建一个单例Bean时第一步不是调用new而是“推断构造方法”。这个过程在源码里对应AbstractAutowireCapableBeanFactory的determineConstructorsFromBeanPostProcessors方法由AutowiredAnnotationBeanPostProcessor来执行。具体规则是这样的如果类里只写了一个默认构造器那就用这个默认构造器如果有多个构造器其中某个标注了Autowired(required true)那就用这个标注的构造器如果没有显式标注但有且仅有一个有参构造器Spring也会“默认”用它——注意这个行为其实是Spring 4.3之后才有的便利设定早年版本必须手动写Autowired很多老程序员习惯写注解其实是时代遗留。如果无法判断该用哪个构造器Spring会尝试用无参构造器实例化然后通过字段或Setter去填充属性。这也是为什么你写了一个类既没有无参构造器也没有标注任何有参构造器启动时就会直接报错——Spring根本不知道该怎么给你“搭骨架”。4.2 实例化后属性填充到底是谁干的活选定了构造器之后Spring通过反射创建出对象实例。但请注意此时对象的属性还没有值也就是说如果你有个Autowired private UserDao userDao;此刻的userDao还是null。接下来进入属性填充阶段也就是populateBean方法。这个阶段的幕后主力是各种BeanPostProcessor。其中最关键的是AutowiredAnnotationBeanPostProcessor它会遍历当前对象的所有字段和方法找出带有Autowired、Value等注解的地方然后去容器里按照类型或名字查找依赖查到了再反射赋值进去。这个阶段还顺带处理各种Aware接口的回调比如BeanNameAware会给Bean传入它在容器中的名字ApplicationContextAware会传入容器上下文对象。顺序是先Aware回调再执行BeanPostProcessor的postProcessBeforeInitialization然后PostConstruct接着InitializingBean.afterPropertiesSet最后是自定义的init-method。4.3 生命周期完整顺序表把这些顺序整理成一张表比零零散散地记忆高效得多。这是我整理过无数次的一张顺序表建议直接收藏阶段触发点用途1. 实例化推断构造方法并反射创建对象给对象搭好骨架2. 属性填充处理Autowired、Value等注解注入依赖、配置值3. Aware回调实现BeanNameAware等接口获取容器环境信息4. 前置处理BeanPostProcessor.postProcessBeforeInitialization对Bean做自定义增强5. 初始化PostConstruct→InitializingBean→init-method执行自定义初始化逻辑6. 后置处理BeanPostProcessor.postProcessAfterInitializationAOP代理通常在这里生成7. 使用Bean被放入单例池业务代码调用8. 销毁PreDestroy→DisposableBean.destroy→destroy-method释放资源这里值得强调一点AOP代理诞生在哪个环节在最后一步postProcessAfterInitialization后置处理阶段AbstractAutoProxyCreator会判断当前Bean是否需要被代理如果需要就生成一个代理对象替换掉原来的Bean再放入单例池。这个“先有一个原始Bean、再换成代理Bean”的过程是后面理解三级缓存问题的关键线索请先在脑子里留个印象。5. 循环依赖与三级缓存Spring的兜底机制到底解决了什么5.1 什么是循环依赖什么情况下会触发循环依赖的场景一句话就能说清楚A依赖BB又依赖A两个Bean互相引用形成闭环。常见的比如Service public class ServiceA { Autowired private ServiceB serviceB; } Service public class ServiceB { Autowired private ServiceA serviceA; }在没有特殊处理的容器里这是个死结要创建A得先有B要创建B得先有A。Spring能解决这个问题靠的就是三级缓存。但很多人在这一步会陷入一个误区以为三级缓存是为了解决所有循环依赖其实Spring的机制只能解决“单例模式 字段注入/Setter注入”的循环依赖构造器注入的循环依赖依然会直接报错。5.2 三级缓存分别缓存了什么三级缓存在源码里对应DefaultSingletonBeanRegistry的三个Map级别Map对象存放内容一级缓存singletonObjects已经完成完整初始化的单例Bean二级缓存earlySingletonObjects提前暴露出来的半成品Bean已实例化但未完成属性填充三级缓存singletonFactories存放ObjectFactory工厂对象用于生成半成品Bean的早期引用回到上面的例子创建A时A实例化完成还没填充BSpring先把A的ObjectFactory放进三级缓存此时A是个半成品。然后A开始填充属性发现需要B于是去创建B。B走同样的流程实例化、放进三级缓存、填充属性时发现需要A。B去找A一级缓存没有、二级缓存没有但三级缓存里有A的ObjectFactory于是调用getObject()拿到A的早期引用放进二级缓存赋值给B的属性。B创建完成进入一级缓存A拿到B的引用后也完成自己的填充和初始化进入一级缓存。5.3 一个很多人没想透的问题为什么非得三级既然二级缓存就能提前暴露A的引用为什么还要三级缓存答案就在上面提到的AOP。如果一个Bean最终需要被AOP代理那么它放进一级缓存的不应该是原始对象而是代理对象。但代理对象是在初始化完成后的postProcessAfterInitialization阶段生成的。问题来了如果B在A还没初始化完成时就要引用A拿到的到底是原始对象还是代理对象如果只有二级缓存那么B只能拿到A的原始对象等A完成代理生成后B手里还握着原始对象所有的AOP增强都白做了。为了解决这个时间差Spring的第三级缓存里放的不是Bean本身而是一个ObjectFactory这个工厂里会提前执行getEarlyBeanReference方法如果判断当前Bean需要被代理就在这个早期阶段先生成一个代理对象让B拿到的直接就是代理。这样既保证了循环依赖能解开又保证了AOP增强不丢失。5.4 仍然解决不了的情况和实际应对三级缓存并非万能。我遇到过三类仍然报BeanCurrentlyInCreationException的场景第一是构造器注入的循环依赖。因为构造器注入在实例化阶段就要求依赖对象存在此时连三级缓存都还没机会放根本解不开。解决方案是把某个注入改成Setter或字段注入更好的方案是重新设计依赖关系。第二是原型作用域的循环依赖。三级缓存只对单例Bean有效原型Bean每次获取都新建Spring直接放弃治疗。第三是使用了Async等注解导致的代理失效假象。如果你用Async同时又存在循环依赖有可能出现代理对象没有正确注入的情况本质上是早期代理创建时的时序问题。实战中我更建议的做法是把循环依赖当成一种设计坏味道来对待而不是依赖框架的兜底机制。代码评审时看到循环依赖第一反应应该是拆分职责——比如A依赖的其实是B里的某个方法可以把这个方法抽到一个单独的Helper或EventPublisher组件里让A直接依赖那个组件整个环自然就解开了。6. 依赖注入报错排查链路与个人实战心得6.1 四类最高频的注入报错DI虽然用起来方便但报错信息对新手非常不友好。启动日志里那一大坨堆栈很多人看到就头皮发麻。根据我的经验最常见的就四类NoSuchBeanDefinitionException容器里压根没有这个类型的Bean。常见原因是类没加Component/Service注解或者组件扫描的包路径没覆盖到。NoUniqueBeanDefinitionException容器里有两个相同类型的BeanSpring不知道选哪个。解决方式是加Primary指定首选或加Qualifier(beanName)按名字指定。UnsatisfiedDependencyException依赖无法满足的通用包装异常真正的根因通常嵌套在Caused by里排查时一定要往日志下面翻。BeanCurrentlyInCreationException循环依赖且Spring无法兜底通常是构造器注入或原型作用域场景。6.2 一次真实的NoUniqueBeanDefinitionException排查过程分享一次我实际排查问题的完整链路方便你以后遇到类似问题能按这个思路走。当时启动项目控制台报错expected single matching bean but found 2: orderServiceImpl, orderServiceProxy。这个报错字面意思是找到了两个OrderService类型的Bean。我的第一反应是是不是有两个类都实现了OrderService接口打开扫描包一看果然项目里既有OrderServiceImpl还有一个用CGLIB生成的代理Bean。为什么会有两个往下追发现是因为项目里同时给OrderService加了Service注解和一个手动注册的ProxyFactoryBean两个注册渠道同时生成了同一接口的实现。处理的链路是先排查类名冲突确认没有两个Controller再看是不是有重复扫描路径ComponentScan配了com.example和com.example.service导致同一个类被扫了两次最后定位到ProxyFactoryBean上。这个问题如果用Qualifier确实能压下去但治标不治本根本问题是同一个接口出现了两条Bean注册途径属于配置冗余删掉多余的那个才是正解。6.3 最后分享几个我踩坑之后沉淀下来的使用习惯这些年在Spring项目里摸爬滚打有几个习惯是踩了无数次坑之后才真正养成的分享给你。第一个习惯新代码全部用构造器注入。虽然写起来比字段注入多几行代码但类一旦依赖关系复杂构造器参数列表会把设计问题直接暴露在你的面前。依赖超过五个你自然会去想是不是该拆类了。这种“不舒服感”其实是一种设计的反馈信号。第二个习惯排查报错永远先看Caused by。Spring的异常包装层数极深日志开头那些BeanCreationException基本只是壳子嵌套在里面的具体异常信息才是真正线索。我见过不少同事卡在UnsatisfiedDependencyException上转圈其实往下翻一两屏就能看到根因是SQLSessionFactory没配好。第三个习惯写单元测试时不启动Spring容器。纯构造器注入的类可以直接new出来手动塞Mock对象根本不需要SpringBootTest测试速度能从秒级降到毫秒级。这套模式下构造器注入不只是规范问题更是测试效率问题。第四个习惯对三级缓存和循环依赖理解归理解项目里尽量不要碰。面试官问循环依赖你可以从三级缓存聊到AOP代理这是知识深度但自己写业务代码时如果出现了循环依赖哪怕是字段注入能跑通也要主动重构。为什么因为Spring的循环依赖兜底机制在复杂的代理场景下依然有隐蔽的边界问题与其去赌框架的兜底不如从设计上就绕开。Spring的IoC和DI核心思想其实很简单把创建对象的控制权交给容器把依赖关系交给容器管理。难的是在真正使用过程中积累的那些经验——什么时候用哪种注入、报错了怎么定位、设计上怎么避开循环依赖。希望这篇文章不止帮你理解了底层原理更能让你在下次排查注入问题时少走几步弯路。