ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring IoC核心原理:从控制反转到三级缓存与手写容器

Spring IoC核心原理:从控制反转到三级缓存与手写容器 做 Java 开发的基本绕不开一套组合Spring Spring Boot。而在 Spring 这套体系里IoC 是绝对的地基。我见过不少工作两三年的人面试时能背出“控制反转”四个字但问起 BeanFactory 和 ApplicationContext 的关系问起三级缓存为什么必须设计成三级就会明显卡壳。这篇内容的目标只有一个把 IoC 的原理讲透再把原理落成你能自己动手做的代码最后整理一份面试向的问答笔记方便你查漏补缺。IoC 本身不复杂复杂的是它延伸出来的一整套机制。你不妨把 Spring 容器想象成一家中央厨房各个菜品由厨房统一采购、统一备料、统一出餐你要吃饭只需要跟服务员下单。传统写法里每个菜都是你自己买菜、洗菜、炒菜做完还得自己洗碗。IoC 做的事就是把这些“杂活”全部收编进了容器。下面我从概念开始一步步拆到源码机制再到手写一个迷你容器帮你把这条链路彻底打通。1. IoC 到底是什么它解决了什么问题1.1 “控制反转”四个字怎么理解很多教程喜欢举“组装电脑”的例子以前你想用一台电脑得自己挑 CPU、显卡、内存一一插到主板上这叫自己控制组装过程后来你直接找品牌机厂商说“我要一台能剪辑视频的”剩下的配置由厂商搞定这叫控制反转。这个类比基本准确但还不够贴近代码。更直白的说法是对象的创建权和绑定权从调用方手里移交到了容器手里。调用方不再写new ServiceImpl()不再管理ServiceImpl内部又依赖了谁而是声明一句“我需要一个 Service”容器就会把完整可用的对象送到你手上。专业术语里这叫做依赖注入Dependency InjectionDI。依赖注入一般有四种姿势构造器注入、Setter 注入、字段注入比如Autowired标注在字段上和方法参数注入。其中构造器注入最推荐因为它能保证对象一旦创建出来所有依赖就已经就绪不会出现“半个对象”的状态。字段注入写起来最简洁但单元测试时得先反射塞字段容易让人忽略依赖关系的重要性。1.2 一直 new 会造成什么局面你可能觉得我写了几年代码天天new也没出过大事。那是因为你的项目规模还没到一定量级。一旦系统变大直接new会带来三个很现实的问题。第一是耦合扩散。假设你的订单服务里这么写public class OrderService { private UserService userService new UserService(); private InventoryService inventoryService new InventoryService(); private CouponService couponService new CouponService(); }今天UserService的构造器从无参变成了需要一个UserRepository你就要跑来改OrderService明天CouponService又要依赖一个远程配置中心你还得改。改到后面你会发现系统改一个底层实现牵动十几个上层类修改成本成倍增长。第二是兼容场景不好切换。用new写死实现类之后测试时想用一个 Mock 对象替代真实的数据库服务就没法在运行期动态切换。你得把代码改成接口再想办法找个地方把实现类“塞”进去——这其实就是你手工造轮子的第一步也是 IoC 容器替你做的第一步。第三是实例的生命周期没人统一管理。谁负责创建、谁负责销毁、什么时候初始化、什么时候释放连接如果全靠业务代码写很容易出现资源泄漏或者重复创建。容器接管之后单例 Bean 由容器统一持有销毁时有序调用 destroy 方法开发者的心智负担会小很多。2. Spring 容器的两大支柱BeanFactory 与 ApplicationContext2.1 BeanFactory所有容器的老祖宗BeanFactory是 Spring 容器的根接口它承担了最核心的职责根据 Bean 定义生产并管理 Bean 实例。它的getBean(String name)方法是最原始的取 Bean 入口所有高级容器最终都绕不开它。你可以把BeanFactory看成一家小作坊能生产豆子Bean但生产流程比较朴素。它默认采用延迟初始化策略也就是你调用getBean()的时候它才真正去创建实例。这在 Spring 早期资源敏感的场景下很有意义但在日常 Web 项目里我们通常更希望容器启动时就把所有单例都预创建好尽早发现配置错误。BeanFactory还有一个很关键的能力管理 Bean 的父子关系。HierarchicalBeanFactory支持子容器先查自己的 Bean查不到再向父容器要。这在框架整合场景里很常见比如 Spring MVC 中父容器管理 Service、DAO子容器管理 Controller子容器可以访问父容器的 Bean反向则不行。2.2 ApplicationContext站在祖宗肩膀上的高级容器ApplicationContext继承了BeanFactory但它是绝大多数项目里真正使用的容器。它比小作坊多了四件事预初始化单例、事件发布、资源加载、国际化消息支持。这四个能力对应了四个接口ConfigurableApplicationContext管理生命周期ApplicationEventPublisher做事件监听ResourceLoader加载配置文件MessageSource处理 i18n。Spring Boot 项目里常见的AnnotationConfigApplicationContext就是ApplicationContext的实现类。日常开发中我们感慨“Spring 好强大”的那些体验其实大多来自ApplicationContext而不是最底层的BeanFactory。比如你在启动日志里看到Root of context hierarchy启动完成后容器已经把所有能预创建的单例都建好了又比如你在任意 Bean 里注入ApplicationContext就能用它发布一个自定义事件监听方立刻感知到。这些便利全靠这个高级容器。下面这个表格可以帮你快速对比两者维度BeanFactoryApplicationContextBean 创建时机懒加载getBean 时才创建默认启动时预创建单例事件发布不支持支持资源加载能力有限统一 ResourceLoader国际化不支持支持内置增强无集成 BeanPostProcessor 等机制实际使用框架内部、嵌入式场景绝大多数应用场景2.3 BeanDefinition容器的“建造图纸”你有没有想过一个问题容器怎么知道该创建哪些 BeanBean 是单例还是原型构造参数是什么这个信息容器是靠BeanDefinition保存的。BeanDefinition 就是 Bean 的图纸。里面记录类名、作用域、是否懒加载、初始化方法、销毁方法、属性值等元数据。Spring 启动时大概会经过这么几件事扫描配置文件或注解把一个个BeanDefinition注册进BeanDefinitionRegistry。遍历所有BeanDefinition调用BeanFactoryPostProcessor做修改完善。真正实例化前容器会检查是否存在InstantiationAwareBeanPostProcessor允许你拦截实例化过程。对于正常单例 Bean进入创建流程塞进一级缓存singletonObjects。理解了BeanDefinition你就能想通很多问题。比如为什么配置类上的Configuration(proxyBeanMethods false)能提升启动速度——因为不用生成 CGLIB 子类去拦截Bean方法少做了很多额外工作但代价是同一个Bean方法被多次调用时可能返回不同实例。3. Bean 生命周期与三级缓存Spring 最精妙的部分3.1 Bean 生命周期全景图Spring 文档里没有直接给你一张大图但源码流程很清晰。一个普通单例 Bean 从创建到销毁大致经历以下阶段容器读取BeanDefinition根据类信息构造实例。属性填充把配置好的属性值、Autowired依赖注入进去。处理各种Aware接口如BeanNameAware、BeanFactoryAware、ApplicationContextAware。调用BeanPostProcessor#postProcessBeforeInitialization。调用初始化逻辑PostConstruct方法、InitializingBean#afterPropertiesSet、配置的initMethod。调用BeanPostProcessor#postProcessAfterInitialization这一步也是AOP 代理生成的最佳时机。Bean 进入就绪状态供业务调用。容器关闭时依次执行PreDestroy、DisposableBean#destroy、自定义destroyMethod。之前有同事问我为什么PostConstruct会执行InitializingBean里的方法也会执行这两者到底什么顺序这里明确说一下PostConstruct在InitializingBean#afterPropertiesSet之前执行。实际上Spring 内部通过CommonAnnotationBeanPostProcessor拦截了postProcessBeforeInitialization在那里调用PostConstruct而InitializingBean则是在初始化阶段由invokeInitMethods调用。两者出现顺序别搞反。3.2 三级缓存为什么能兜住循环依赖循环依赖是最常被问的原理之一。典型场景是 A 依赖 BB 又依赖 A。Spring 单例 Bean 为什么能处理这种循环引用靠的就是三级缓存。三级缓存的三个 Map 分别是缓存作用存放内容一级缓存singletonObjects存放创建完成、可用的成品 Bean最终实例二级缓存earlySingletonObjects存放提前暴露的半成品 Bean早期实例可能还不是最终代理三级缓存singletonFactories存放 ObjectFactory用于生成早期引用工厂对象而不是直接对象创建 A 的时候Spring 会先把 A 的ObjectFactory放进三级缓存singletonFactories然后才开始填充属性。填充时发现 A 需要 B于是去创建 BB 又需要 A此时 B 从三级缓存里拿到 A 的ObjectFactory调用getObject()得到一个提前暴露的 A 实例放进二级缓存同时把三级缓存里的工厂移除。B 用这个半成品完成自己的创建最终返回给 AA 再把属性 B 填上A 也走向完整。整个流程里二级缓存的存在让提前公开的引用可以复用而三级缓存里的工厂是为了在必要时生成代理对象。源码里最核心的getSingleton逻辑大概长这样protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }看到这个结构你就明白了先从成品缓存查查不到再看是否正在创建中二级缓存查不到才动用三级缓存里的工厂生成早期引用。3.3 构造器循环依赖和原型循环依赖为什么救不了三级缓存不是万能的。构造器循环依赖就处理不了。因为构造器注入发生在 Bean 实例化之前没有对象可供提前暴露。A 的构造器需要 BB 的构造器需要 A两边都还没创建出半成品自然无从解套。解决办法一般是改用 Setter 或Lazy打破一环或者在设计上避免这种结构。原型作用域的循环依赖同样处理不了。原型 Bean 每次获取都创建新实例Spring 根本不会把没有创建完的原型 Bean 放入任何缓存因此无法相互感知。你写了A - B - A的原型依赖运行时会直接抛出BeanCurrentlyInCreationException。还有一个典型误区Spring Boot 2.6 开始默认禁止循环依赖。实话讲Spring 官方并不鼓励你把循环依赖当作正常设计因为它往往意味着职责没有拆干净。如果你确实需要兼容旧的循环依赖写法可以配置spring.main.allow-circular-referencestrue但更好的是重构依赖关系让调用方向保持单向下沉。4. 手写一个迷你 IoC200 行代码理解容器4.1 思路与类结构设计理论说多了容易飘我建议你亲手写一个迷你 IoC。写完之后再回头看 Spring你会觉得大多数源码都在你的射程范围之内。这个小项目的目标很简单支持包扫描、支持MyComponent注册 Bean、支持MyAutowired字段注入、支持单例 Bean 缓存。核心类就三个MyApplicationContext容器入口负责扫描、创建、缓存。ClassPathScanner扫描指定包下所有类。两个自定义注解MyComponent和MyAutowired。我先定义注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MyComponent { String name() default ; }Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MyAutowired { }然后写容器核心。这里面最考验人的是“扫描指定包下所有类”原理是ClassLoader读取资源目录再递归遍历文件名最后用Class.forName加载。public class ClassPathScanner { public SetClass? scan(String basePackage) throws Exception { SetClass? classes new HashSet(); String path basePackage.replace(., /); EnumerationURL resources Thread.currentThread() .getContextClassLoader().getResources(path); while (resources.hasMoreElements()) { URL url resources.nextElement(); File dir new File(url.toURI()); scanDir(dir, basePackage, classes); } return classes; } private void scanDir(File dir, String packageName, SetClass? classes) throws Exception { File[] files dir.listFiles(); if (files null) return; for (File file : files) { if (file.isDirectory()) { scanDir(file, packageName . file.getName(), classes); } else if (file.getName().endsWith(.class)) { String className packageName . file.getName() .substring(0, file.getName().length() - 6); classes.add(Class.forName(className)); } } } }4.2 核心容器实现MyApplicationContext内部维护两个 Map一个放BeanDefinition信息一个放单例成品。扫描完成后对所有标注了MyComponent的类做注册然后在创建阶段做字段注入。public class MyApplicationContext { private MapString, Class? beanDefinitions new HashMap(); private MapString, Object singletonObjects new HashMap(); public MyApplicationContext(String basePackage) throws Exception { new ClassPathScanner().scan(basePackage).forEach(clazz - { if (clazz.isAnnotationPresent(MyComponent.class)) { String beanName decapitalize(clazz.getSimpleName()); beanDefinitions.put(beanName, clazz); } }); } public Object getBean(String beanName) throws Exception { if (singletonObjects.containsKey(beanName)) { return singletonObjects.get(beanName); } // 缓存里没有则新建 Object instance createBean(beanName); singletonObjects.put(beanName, instance); return instance; } private Object createBean(String beanName) throws Exception { Class? clazz beanDefinitions.get(beanName); if (clazz null) { throw new RuntimeException(Bean not found: beanName); } Object instance clazz.getDeclaredConstructor().newInstance(); injectFields(instance, clazz); return instance; } private void injectFields(Object instance, Class? clazz) throws Exception { for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyAutowired.class)) { field.setAccessible(true); // 按属性类型匹配一种简单的实现 Class? fieldType field.getType(); Object dependency singletonObjects.values().stream() .filter(fieldType::isInstance) .findFirst() .orElse(null); if (dependency null) { String depName decapitalize(fieldType.getSimpleName()); dependency getBean(depName); } field.set(instance, dependency); } } } private String decapitalize(String name) { return Character.toLowerCase(name.charAt(0)) name.substring(1); } }这里我刻意没有加入循环依赖处理和BeanPostProcessor因为那一层一旦加进来代码量会膨胀三倍。但这个版本已经足够演示 IoC 的本质容器统一注册、统一创建、统一注入。4.3 测试效果自己写一个服务验证我写两个类来测试一个UserService一个OrderService让OrderService依赖UserService。MyComponent public class UserService { public String getUser() { return itwanger; } }MyComponent public class OrderService { MyAutowired private UserService userService; public String createOrder() { return order for userService.getUser(); } }入口测试public class Main { public static void main(String[] args) throws Exception { MyApplicationContext context new MyApplicationContext(com.demo); OrderService orderService (OrderService) context.getBean(orderService); System.out.println(orderService.createOrder()); } }运行结果会输出order for itwanger。这时候你回头理解 Spring脑子里就会自动映射出几个概念beanDefinitions对应BeanDefinitionRegistrysingletonObjects对应一级缓存injectFields对应属性填充new ClassPathScanner().scan()对应包扫描。原理再复杂内核也就是这条路。注意真正的 Spring 要处理循环引用、作用域、代理、生命周期钩子、条件装配、BeanPostProcessor 等一大堆扩展点。手写迷你容器只用于建立心智模型不要在真实项目里重复造轮子。5. 实战踩坑与排查技巧实录5.1 字段注入导致 null 的典型场景很多新手都会遇到一个诡异问题Autowired注入的字段明明是有的但运行时就是null。最常见原因有三个。第一这个 Bean 不是由 Spring 管理的。你可能在普通new出来的对象里注入了AutowiredSpring 看不到它自然不负责注入。Spring 容器只对自身管理的 Bean 做依赖注入。第二静态字段注入失败。Autowired不能直接注静态字段Spring 会注入成功但为 null因为静态变量不属于任何实例。要注入静态依赖得通过 Setter 或PostConstruct里手动赋值。第三类没有被扫描到。默认扫描范围是从启动类所在包开始的如果你的某个类放在别的包目录却没有额外指定ComponentScan容器就不会把它注册成 Bean。排查这个问题的思路我也分享一下先看BeanFactory里有没有这个 Bean再看创建顺序和实例类型最后确认注入点本身是不是容器管理的类。用 IDE 的 Debug 观察 Spring 容器对象里的singletonObjects是最快的方式。5.2 Configuration 里 Bean 方法执行两次这个坑在面试题里经常出现。假如你在Configuration类里定义了一个Bean对象然后又在这个方法内部手动调用了另一个Bean方法为什么最终结果是同一个实例因为Configuration注解会让类被 CGLIB 代理Spring 在代理逻辑里去缓存查询同一个 Bean 不会重复创建。但如果把Configuration换成Component同一个Bean方法被调用两次结果就是两个不同实例。源码里就体现在ConfigurationClassPostProcessor是否增强了配置类。所以设计上要让人一眼看懂依赖我建议直接通过参数注入来获取依赖不要依赖方法内部二次调用。5.3 循环依赖触发却报 BeanCurrentlyInCreationException有时候你明明读写过三级缓存相关文章但项目还是抛了BeanCurrentlyInCreationException。这时候先别急递归分析一下报错日志里的Singleton bean currently in creation。大概率是下面四种情况构造器循环依赖不在三级缓存处理能力之内。循环依赖链路里有一个 Bean 是原型作用域。Async注解触发了代理提前生成和循环依赖叠加后踩到 Spring 的校验逻辑。Spring Boot 2.6 及更高版本默认禁止循环依赖需要在配置里显式允许。我一次次遇到的是Async 循环依赖的组合。服务 A 调用服务 BB 又回到 A且其中一个方法标了Async。这种情况即使单例 Bean也会因为代理机制变得很棘手。建议把Async拆到独立接口或者新增中间层不要让异步方法和循环依赖挤在同一个对象里。5.4 高频问题速查表问题原因解决方案注入的字段是 null对象不是容器管理的或静态字段注入或扫描范围不对交由 Spring 管理避免静态字段注入调整包扫描同一 Bean 被调用两次得到不同对象配置类没有被 CGLIB 增强使用 Configuration 而非 ComponentBeanCurrentlyInCreationException构造器循环依赖 / 原型 Bean 循环依赖 / 默认禁止循环依赖改用 Setter 注入、加 Lazy、开启 allow-circular-referencesPostConstruct 里注入的对象还未初始化初始化顺序判断错误确认下游 Bean 的初始化顺序必要时用 DependsOn 显式指定单例 Bean 内状态被多线程共享单例天然只实例化一次把可变状态抽到方法局部变量使用无状态 Bean6. 面试高频问答与学习建议6.1 面试官最爱问的十个问题我把历年面试中围绕 IoC 的高频问题整理成一个清单每个附上极简答题思路你可以对着自查什么是 IoC控制反转是一种设计原则将对象创建和依赖绑定交给容器管理DI 是它的实现方式。BeanFactory 和 ApplicationContext 的区别一个懒加载一个预加载后者支持事件、国际化、资源加载功能更丰富。Spring 如何解决循环依赖单例 Bean 场景下三级缓存配合提前暴露构造器注入和原型作用域的循环依赖不支持。为什么是三级缓存而不是二级兼顾“提前暴露引用”和“必要时生成代理”延迟创建代理保证最终注入的是完整对象。Bean 生命周期中有什么钩子PostConstruct、InitializingBean、initMethod、BeanPostProcessor、PreDestroy、DisposableBean、destroyMethod。Autowired 和构造器注入选哪个优先构造器注入保证不可变和依赖完整字段注入适合快速原型但可读性和可测性较差。Bean 作用域有哪些singleton、prototype、request、session、application、websocket。单例 Bean 线程安全吗单例本身不保证线程安全要避免共享可变状态无状态 Bean 天然安全。Configuration 和 Component 有什么区别Configuration 会被 CGLIB 增强单例语义更强Component 不增强。如何手写一个 IoC包扫描、BeanDefinition 注册、反射实例化、依赖注入、单例缓存这就是最小闭环。回答这些问题时别只背概念尽量加一句“我之前在项目里遇到 xxx就是因为 xxx 机制”这样面试官会觉得你是真用过而不是只会背八股。6.2 我给学习者的三个实操建议如果你现在正处于“看懂了但记不住”的阶段我给你的建议很朴素但都是我验证过有效的方法。第一把 Spring 的源码当目录来查而不是当书来读。遇到一个问题就直接定位对应类。想了解 Bean 创建看AbstractAutowireCapableBeanFactory想了解循环依赖看DefaultSingletonBeanRegistry想了解配置类增强看ConfigurationClassPostProcessor。带着问题看源码效率远高于从头读到尾。第二动手做一遍手写 IoC。不用做得像 Spring 那么完整像我上面那样能扫描、能注入、能缓存就足够加深理解。写完你自然能回答“为什么容器需要 BeanDefinition”“为什么缓存 Map 要放在这里”这类问题。第三做一次“从 Spring Boot 项目启动到接口调用”的演示。在一个空 Spring Boot 项目里手动打断点观察容器启动时单例 Bean 的创建过程再观察BeanPostProcessor被哪些内部组件调用。当年我就是靠这套连点成线的方法把原本割裂的知识点串起来的。我个人从这些实操里最大的体会是IoC 不是“背出来”的知识而是“用出来”的经验。当你有一天能不看文档自己解释清楚容器注入的完整链路你就不会再恐惧 Spring 相关的任何面试题了。
返回列表