Spring IOC源码级剖析:Bean生命周期与循环依赖,手写Mini容器
Spring框架里的IOC很多人一开始接触的时候教材上是这么说的控制反转把对象的创建和依赖管理交给容器。听懂了但总觉得隔了一层。实际写代码的时候好像也没觉得有多神奇new一个对象和注入一个对象区别能有多大直到你真正独立去维护一个中等规模的项目或者从零搭建一个工程的时候才会意识到IOC这玩意儿到底帮你扛了多少活。这篇文章不聊虚的从源码和实际项目经验出发把IOC的底层原理、Bean的生命周期、循环依赖的处理逻辑一次说透最后再手把手写一个Mini版IOC核心保证你看完能直接给别人讲明白。1. 内容整体设计与思路拆解1.1 为什么Spring要把控制权收走思考一个问题为什么不用new原因在真实项目里几乎是被逼出来的。比如一个订单服务要调用库存服务如果你在自己的代码里直接new一个库存服务对象那两个模块就死死绑在一起了。库存服务的构造函数一旦变化订单服务就要跟着改如果库存服务需要连接数据库那订单服务在测试环境里还得先把数据库准备好。这不是代码问题是架构问题。IOC把“谁依赖谁”这件事从代码里分离出来放到一个统一的容器里管理。对象之间不再直接找对方而是等容器把依赖送上门。控制权从对象自己手里转移到了容器手里所以叫“反转”。这个思路放到生活里就像你出门吃饭。自己做饭你需要买菜、洗菜、切菜、炒菜、洗碗全程自己控制。IOC就是你直接去餐馆告诉服务员你要吃什么后厨做好了端上来。你不用关心食材是哪来的也不用管厨师是怎么炒的。你只管吃。Spring容器就是这个餐馆。1.2 从一个具体场景反推IOC的价值假设你在写一个支付对接模块有支付宝支付和微信支付两种实现。不用IOC的时候你的业务代码里大概率会长这样public class OrderService { private AlipayService alipayService new AlipayService(); private WechatPayService wechatPayService new WechatPayService(); public void pay(String channel, double amount) { if (alipay.equals(channel)) { alipayService.pay(amount); } else if (wechat.equals(channel)) { wechatPayService.pay(amount); } } }这段代码初看起来没什么问题但后续要加一个新的支付渠道呢比如银联你就得改OrderService的代码加一个成员变量加一个if分支。每来一个新渠道就改动一次核心业务代码这是典型的违反开闭原则。用IOC加上接口设计代码长这样public interface PayService { void pay(double amount); } Service public class AlipayServiceImpl implements PayService { Override public void pay(double amount) { // 支付宝逻辑 } } Service public class WechatPayServiceImpl implements PayService { Override public void pay(double amount) { // 微信支付逻辑 } }业务层通过接口注入配合Qualifier或者Primary指定用哪个实现。以后加新渠道只需要新增一个实现类业务代码一行不用改。IOC的价值就在这里它把“系统里有哪些组件”和“组件之间怎么配合”这两件事从编译器硬编码里解放了出来。2. 核心细节解析与底层原理2.1 IOC容器的本质是一张巨大的Map很多人觉得IOC容器高深说白了就是一个注册表一个Map。Map的key是Bean的namevalue是Bean对象或者Bean的实例化策略。Spring启动的时候做三件事扫描所有带有Component、Service、Repository、Controller注解的类把它们的类信息读取出来根据依赖关系决定实例化顺序把最终创建好的对象放进容器这个Map里等谁需要的时候就给谁。这里有个关键点Spring容器里面存的默认是单例对象。不是每次注入都new一个新的而是同一个对象在容器生命周期内只有一份。这也是为什么Spring Bean默认是线程不安全的因为你拿到的就是那个共享对象。如果你在Service里写了一个有状态的成员变量比如一个计数器多个请求同时操作它数据就乱了。容器存储的这个Map结构实际源码里要比想象中复杂。AbstractBeanFactory里维护了一个singletonObjects的ConcurrentHashMap这就是一级缓存专门存放已经完全创建好的单例Bean。理解了这个Map再去理解Spring的三级缓存就顺理成章了。2.2 Bean的生命周期到底是怎么走的面试聊生命周期真的把它当成一个流程去记很容易背串。换一个角度把它当成一个人从出生到死亡的过程就好记了。Bean出生于类被扫描到容器根据它的构造方法实例化出一个对象。这时候对象还不是一个完整的Bean因为它的属性还没赋值。接着是属性填充Spring会看这个Bean的字段上有没有Autowired等注解如果有就去容器里找对应的依赖对象找到之后通过反射塞进去。然后是初始化阶段。如果Bean实现了InitializingBean接口Spring会调用它的afterPropertiesSet方法如果在配置里指定了init-methodSpring会调用指定的初始化方法。还可以通过PostConstruct注解指定初始化逻辑。这三个的执行顺序是PostConstruct先执行然后是afterPropertiesSet最后是init-method。初始化完成之后Bean就躺进了容器里随时待命供其他对象使用。直到容器关闭Spring会调用实现了DisposableBean接口的destroy方法或者执行配置的destroy-method最后Bean生命结束。这个生命周期里的每一步Spring都留了扩展点。实现BeanPostProcessor接口就可以在Bean初始化前后插入自定义逻辑。AOP的代理对象本质上就是通过BeanPostProcessor搞出来的在Bean初始化完成之后返回一个包装过的代理对象代替原来的Bean。2.3 循环依赖的巧妙解法三级缓存循环依赖是个经典问题场景很简单。A依赖BB依赖A两边互相引用。如果按照普通思路你要创建A就得先创建B要创建B就得先创建A这样就死循环了。Spring用了三级缓存解决这个问题。一级缓存存成品Bean二级缓存存半成品Bean三级缓存存的是工厂。说白了就是先把A的早期引用暴露出去哪怕A的属性还没有完全填充好。B在创建的时候发现自己需要A拿到A的早期引用先把B构建完。然后B把A需要的依赖给填上A继续完成自己剩下的初始化工作。这个设计最巧妙的部分在第三级缓存。三级缓存存的不是一个对象而是一个ObjectFactory。为什么要这么设计因为A可能是一个被AOP代理的对象。如果代理发生在属性填充之前那B拿到的早期引用就应该直接是代理对象。通过工厂Spring把这个判断延迟到了真正需要暴露引用的那一刻这种设计思路很值得借鉴。顺便说一句构造器注入的循环依赖是解决不了的。因为构造器注入在对象还没创建出来的时候就要找依赖这时候连早期引用都拿不到。遇到这种情况把其中一个依赖改成setter注入或者字段注入就好了。3. 实操过程与核心环节实现3.1 动手写一个Mini版IOC容器概念说了一堆不如直接上手写一个迷你版IOC把核心逻辑跑通。我自己在本地搭了一个Demo工程大概150行Java代码把扫描、实例化、依赖注入、单例存储都实现了。这里分享一下核心的设计思路和关键代码。几个核心类一个Autowired注解、一个Component注解、一个BeanDefinition类、一个IOC容器类。流程分三步扫描指定包路径下的class文件找出带Component注解的类解析之后放进BeanDefinitionMap遍历所有BeanDefinition反射实例化对象并完成依赖注入把成品放进单例池。扫描的部分用ClassPathScanner思路是把包路径转成文件路径递归遍历目录找到所有.class文件用反射的Class.forName加载进来。实例化部分比较直接调用构造方法public Object createBeanInstance(Class? clazz) { try { // 获取无参构造器通过反射创建实例 return clazz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException(Bean实例化失败: clazz.getName(), e); } }依赖注入靠遍历字段。拿到Bean的所有Field检查有没有加上Autowired注解如果加上了就根据字段类型从容器里找对应的Bean找到之后设置进去public void populateBean(Object beanInstance, Class? clazz) { Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { Autowired autowired field.getAnnotation(Autowired.class); if (autowired ! null) { Object dependBean getBean(field.getName()); if (dependBean ! null) { field.setAccessible(true); field.set(beanInstance, dependBean); } } } }容器核心的getBean方法先从单例池里查查不到就通过createBean方法创建。createBean方法里先实例化再填充属性然后放到单例池里。这就是一个简化版的Spring一次完整创建流程。把这个跑通之后你会发现IOC的核心其实就三个动作收集类的信息、创建对象、管理依赖。Spring只是在这个基础上加了极其丰富的扩展机制和容错处理。3.2 如何验证自定义IOC容器正确运转写完框架写两个测试类验证。一个UserService里面有一个UserRepository类型的字段加上Autowired。一个UserRepository类加上Component。然后写启动类public class Application { public static void main(String[] args) { // 扫描指定包路径 ListClass? classes ClassScanner.scan(com.example.demo); // 初始化IOC容器 MiniIocContainer container new MiniIocContainer(classes); container.refresh(); // 从容器中获取Bean并调用方法 UserService userService (UserService) container.getBean(userService); userService.sayHello(); } }如果输出UserRepository created.和Hello, userService with repository.就说明整个过程闭环了UserRepository先被实例化UserService实例化时通过依赖注入拿到了UserRepository对象。跑通这个流程之后再回去看Spring源码会有一种豁然开朗的感觉因为你已经自己走过了一遍从零构建容器的路。4. 常见问题与排查技巧实录4.1 Bean不是你想的那么简单启动报错的几种情况最常见的启动报错是NoSuchBeanDefinitionException翻译过来就是找不到Bean。出现这个问题的原因多半是以下几种扫描的包路径不对组件不在扫描范围类上没有加Spring的注解或者类被加了注解但Autowired注入的接口有多个实现类Spring不知道该选哪个。接口多实现这个问题我踩过坑。写了一个消息发送接口有短信发送和邮件发送两个实现类都不加Primary。我在Service里直接Autowired接口类型启动直接报错。后来在其中一个实现类上加了Primary表明这是首选问题解决。如果希望更精确地在不同场景选择不同实现可以用Qualifier(smsSender)指定Bean的名称。还有一类问题是循环依赖导致的启动失败。Spring Boot 2.6之后默认禁止了循环依赖启动的时候直接报错让你改代码。这实际上是好事逼着你把代码结构理清楚。如果确实需要保留可以在application.yml里设置spring.main.allow-circular-references: true但我个人不建议到处加这个配置那等于把保护机制关了。4.2 作用域选错引发的Bug案例Scope注解决定Bean的生命周期范围。单例singleton是默认的容器里面只有一个实例。原型prototype是每次获取都新建一个对象。还有request和session这两个作用域分别是在一次HTTP请求和一次会话中有效。我之前见过一个真实案例。某模块要做一个基于ThreadLocal的上下文工具按照设计每次请求进来都要创建全新的上下文内容这个类被定义成了单例Bean。结果现场测试的时候发现用户A的数据被用户B看到了。排查下来就是单例Bean配合ThreadLocal产生了脏数据。单例Bean在容器里只有一份ThreadLocal的数据依赖线程隔离但Tomcat的线程池是会复用线程的线程复用的时候ThreadLocal里的旧数据没有清理就串了。这个问题的正确解法有两种一是把Bean作用域改成request或prototype保证每次请求都是新对象二是不用成员变量存状态数据改成方法参数传值。我后来给的建议是第二种因为改作用域会影响依赖注入方式改成方法传值最干净也最容易测试。4.3 方法注入 vs 字段注入的选型关于Spring推荐的注入方式业内一直有争论。字段注入写起来最简洁用Autowired往字段上一加就完事了。但它是通过反射直接修改字段值的在单元测试里你需要用MockBean或者反射工具把依赖塞进去否则没法构造一个完整的对象。更关键的是字段注入的方式让类对外暴露的依赖完全不可见如果这个类有一天要在没有Spring的环境里用你根本不知道它需要哪些依赖。构造器注入是我在项目里比较推荐的方式。依赖在创建对象的时候就必须提供保证了对象一旦创建出来它的依赖一定是齐全的。测试也方便直接在构造函数里传Mock对象就可以了。Spring官方文档也推荐构造器注入尤其对于强制依赖。Setter注入适合可选依赖的场景就是某个依赖没有也能跑只是部分功能不可用。这种情况用Setter注入可以在运行时动态替换实现。4.4 配置类里自己new出来对象不走IOC有这样一个反模式在带有Configuration的配置类里通过new关键字创建Bean然后直接用这个对象而不是通过容器注入。比如Configuration public class BadConfig { Bean public UserService userService() { return new UserService(new UserRepository()); } }这段代码把UserService放到了容器里但new UserRepository()这个对象并没有交给容器管理。如果UserService里面还注入了其他Bean那这些Bean不受容器管理生命周期和代理都会出问题。比如事务注解失效AOP代理失效因为这些功能都是Spring通过容器里的代理对象实现的你自己new出来的那个对象根本没有代理。正确做法是UserRepository也定义为一个Bean然后在Bean方法里通过参数传入或者干脆直接注入Configuration public class GoodConfig { Bean public UserService userService(UserRepository userRepository) { return new UserService(userRepository); } }这样UserRepository也是受容器管理的。项目里如果遇到“明明加了事务注解但事务不生效”这类问题先检查是不是存在手写new绕过容器的情况。4.5 常见问题速查表问题现象常见原因排查方向启动报NoSuchBeanDefinitionException包扫描路径不对、类缺少Spring注解、接口多实现无指定检查扫描路径确认组件注解检查是否配置了Primary或Qualifier构造器注入的循环依赖启动失败A依赖BB依赖A改成setter注入或字段注入事务注解不生效对象通过手写new创建未走代理逻辑确保所有对象都从容器获取请求A的数据串给了请求B单例Bean持有有状态变量线程池复用去掉成员变量状态改方法参数传递或调整作用域同一接口注入多个实现报错未指定注入哪个Bean用Primary标首选实现用Qualifier按名称选中修改配置不生效Bean是单例初始化时已经加载重启应用或使用RefreshScope动态刷新5. 工具选型与配置策略5.1 注解配置 vs XML配置的取舍Spring早期是纯XML配置每一个Bean写一段bean标签一个大项目可能有上千行XML维护起来相当痛苦。后来引入了注解配置Java类里面直接标注Autowired、Component把类型的注册和依赖关系都放在代码里IDE可以自动跳转比在一堆XML里找配置体验强太多。我的建议是新项目一律用注解配置XML只在极少数场景下保留。比如你在给别人提供SDK不希望对方的组件扫描扫描到你的内部Bean用XML和Import配合可以限制暴露范围。再比如某些老系统架构里有动态配置对象需求的场景XML用bean标签定义Meta信息会更直观。除此之外就不要再用XML了。5.2 依赖注入的几种风格对比注入方式写法优点缺点字段注入Autowired直接标注字段最简洁对测试不友好外部不可见依赖容易违背单一职责Setter注入在Setter方法上加注解支持可选依赖可动态换实现注入时机不确定依赖可能缺失构造器注入在构造函数上加注解依赖必须在创建时给全适合强依赖和不可变对象类构造参数多时略显冗长IDE用的LombokRequiredArgsConstructor可以解决如果一个类的构造器参数超过五个你就要停下来想一想这个类是不是承担了太多职责。构造器参数多有两个信号一个是职责过重另一个是依赖设计太细碎。优先把相近的依赖聚合成一个对象再传入比如多个和数据库相关的参数可以合并成一个RepositoryContext这样设计更优雅。5.3 关于懒加载与Bean的作用域配合Spring默认是容器启动的时候就把所有单例Bean都创建好这叫饿汉式加载。有些Bean启动时创建比较耗时又不一定被用到可以考虑加Lazy注解让它在第一次被用到的时候再创建。但Lazy不是随随便便就能用的。它有个副作用如果Bean A标记了Lazy且依赖了Bean B那么Spring会给A生成一个代理对象在真正调用A的方法时才去初始化A并且获取B的引用。这个代理对象在跨类调用的时候会有性能开销对于高频调用的核心Bean不建议加Lazy。实测下来把Lazy用在外围的、备用的、连接池等初始化成本较高的Bean上面比较合适。用在核心链路里的Bean反而会因为代理逻辑影响性能。没必要的就不用加。6. 关于IOC的几点心得体会IOC这个东西真正理解它的标志不是你记住了它的定义而是你遇到问题的时候能想到它是怎么参与工作的。我印象最深的一次是排查一个系统启动时反复卡在某个Bean初始化的问题。当时那个Bean在初始化方法里连了一个外部系统外部系统响应慢启动就卡住了。当时的思路是把外部系统的连接改为异步同时把那个Bean标记为懒加载系统启动速度一下子恢复了正常。这时候我才真正意识到IOC容器不只是“管理对象”这么简单它管理的是整个应用的生命周期节奏。另一个经验是关于ApplicationContextAware通过实现这个接口可以在任何地方获取容器拿到任意Bean。这个能力很强大但也容易被滥用。有些人习惯在工具类里用ApplicationContext.getBean()直接拿Bean写起来方便但这样就把依赖关系隐藏了对代码的可测试性和可维护性都有伤害。我现在的原则是工具类尽量通过构造器传入依赖实在需要穿透Container的场景才用getBean获取。对于刚接触Spring的开发者建议先自己动手写一个MiniIOC不用多复杂能把扫描、实例化、注入这些流程串起来就行。自己写过一遍之后再看源码才不会迷失在层层抽象里。最后分享一个小技巧调试Bean创建顺序的时候常用BeanPostProcessor配合日志输出。在postProcessBeforeInitialization和postProcessAfterInitialization里打印Bean的名称这样能直接看到每个Bean创建和完成的先后顺序比对着文档靠脑补高效太多。