Spring Context深度解析:从Bean生命周期到三级缓存与依赖注入
1. Spring Context到底是个什么东西每次看到讲Spring的教程很多同学第一反应都是“又是Bean生命周期那一套背完就忘”。但今天这篇我想换一个角度从Spring Context本身入手把这个框架最核心的容器概念讲透。Spring Context中文翻译过来就是Spring上下文它本质上是Spring框架里那个负责管理对象的大管家——所有被你交给Spring管理的Bean从创建、初始化、依赖装配到最终销毁全程都归它管。很多人用了几年Spring Boot每天写Controller、Service、Repository但对Context的认知还停留在“Spring启动的时候会自动扫包”这个层面。面试被问到三级缓存、BeanFactory和ApplicationContext的区别、循环依赖怎么解决就只会背答案。这篇文章的目标就是把这层窗户纸捅破从实现原理到实际排查问题的方法把Spring Context里真正值钱的那点东西掰开揉碎讲清楚。那这篇内容适合谁看呢如果你已经写过一阵子Spring Boot能跑通基本的接口开发但凡是遇到“Bean找不到”“循环依赖报错”“配置没生效”这类问题就只能靠重启解决这篇文章就是给你准备的。我会从Context的核心职责开始讲然后逐步深入到Bean生命周期、三级缓存、依赖注入的实现细节最后分享一些我在实际项目里排查Context相关问题时的经验和踩坑记录。2. 从BeanFactory到ApplicationContextSpring Context的演进逻辑2.1 BeanFactory最底层的容器雏形Spring Context不是一开始就是现在这个模样的。在最早期的Spring版本里核心容器只有一个非常朴素的接口叫BeanFactory。这个接口定义的能力极其精简本质上就是三件事根据名字获取Bean、判断容器里有没有某个Bean、获取Bean的类型。如果你去看Spring源码BeanFactory接口里那几个方法到现在都还长得很朴素public interface BeanFactory { Object getBean(String name) throws BeansException; T T getBean(String name, ClassT requiredType) throws BeansException; boolean containsBean(String name); boolean isSingleton(String name) throws NoSuchBeanDefinitionException; boolean isPrototype(String name) throws NoSuchBeanDefinitionException; Class? getType(String name) throws NoSuchBeanDefinitionException; }从这个接口就能看出来BeanFactory是一个极其“懒惰”的容器。什么叫懒惰就是你不调用getBean它就不会去实例化那个Bean。在Spring刚诞生的那个年代这种设计是为了轻量和节省资源毕竟那时候企业应用还跑在内存极其宝贵的服务器上。但放在今天这种纯接口级别的容器基本不会有人直接用了因为它的定位太底层了不支持AOP、不支持事件机制、不支持国际化更不用说注解驱动开发了。理解BeanFactory的意义在于它是整个Spring Context的地基。后面要讲的ApplicationContext本质上就是一个功能全面增强的BeanFactory。你把BeanFactory理解成一台只有发动机的裸车能跑但舒适性为零ApplicationContext是装修完的整车发动机还是那个发动机但空调、音响、安全气囊全配齐了。2.2 ApplicationContext加了全套配件的整车ApplicationContext接口继承自BeanFactory同时在它之上扩展了一堆重量级能力。我把最核心的几个扩展点列出来支持国际化消息处理通过MessageSource接口实现不同语言环境下的提示语可以统一管理内置事件发布机制通过ApplicationEvent和ApplicationListener实现观察者模式业务解耦的利器自动注册BeanFactoryPostProcessor和BeanPostProcessor这是Spring扩展机制的灵魂支持AOP的自动代理不需要手动配置代理工厂提供环境抽象Environment把配置文件和系统属性统一管理起来Spring Boot启动时实际创建的Context通常是AnnotationConfigApplicationContext或者ServletWebServerApplicationContext。后者是在Spring Boot Web场景下的具体实现它继承了GenericWebApplicationContext额外管理了Web服务器的生命周期。这也是为什么你启动Spring Boot应用看到Tomcat在8080端口跑起来那个Tomcat的生命周期其实是SerletWebServerApplicationContext在管。2.3 Context启动时到底做了什么Context不是启动完就在那儿发呆的。一次完整的Context启动流程可以概括成下面这几步扫描配置类或者XML配置拿到BeanDefinition对这些BeanDefinition做进一步的加工比如处理占位符调用BeanFactoryPostProcessor允许修改Bean定义实例化所有单例Bean这中间会经过完整的Bean生命周期发布ContextRefreshedEvent事件通知所有监听器“容器已经准备好了”我记得第一次完整看Spring启动日志的时候几千行DEBUG日志铺下来最直观的感受就是这些步骤。后来自己写框架玩尝试用一个简化版容器去管理Bean才真正理解每一步为什么要这样设计。比如BeanFactoryPostProcessor必须在实例化Bean之前调用因为你得先允许别人修改“配方”BeanDefinition再去做菜实例化Bean。调试技巧想直观看到Spring启动时到底加载了哪些BeanDefinition可以在配置类里临时实现BeanFactoryPostProcessor打印beanFactory.getBeanDefinitionNames()。我每次排查“诶我这个Bean怎么没被扫描到”的问题都用这个办法一打一个准。3. 把Bean生命周期吃透从BeanDefinition到消亡3.1 生命周期的完整时间线Bean生命周期是Spring Context最核心的知识点没有之一。面试被问、工作遇到循环依赖、排查Bean初始化顺序问题全都绕不开它。我把一条完整的生命周期线画在脑子里是这样的扫描阶段组件扫描器ClassPathBeanDefinitionScanner发现有Component注解的类包装成ScannedGenericBeanDefinition合并BeanDefinition处理父子BeanDefinition的合并得到最终完整的BeanDefinition实例化前执行InstantiationAwareBeanPostProcessor.postProcessBeforeInstantiation创建实例通过构造器反射创建对象或通过工厂方法创建实例化后执行InstantiationAwareBeanPostProcessor.postProcessAfterInstantiation返回值决定是否继续填充属性属性填充AutowireAnnotationBeanPostProcessor等后置处理器介入完成依赖注入Aware回调处理BeanNameAware、BeanFactoryAware、ApplicationContextAware等接口回调初始化前执行BeanPostProcessor.postProcessBeforeInitializationPostConstruct方法在这里被调用初始化调用InitializingBean.afterPropertiesSet以及XML里配置的init-method初始化后执行BeanPostProcessor.postProcessAfterInitializationAOP动态代理通常在这里产生使用阶段Bean已经躺在单例池里随时等待getBean()拿取销毁阶段Context关闭时执行PreDestroy、DisposableBean.destroy()、destroy-method这里最容易被忽略的是第4步到第6步之间的机制。当Bean还在实例化但还没完成属性填充的时候它其实是一个“生米煮成熟饭但还没放盐”的状态。正常情况下你拿不到它因为它还没被放进单例池但循环依赖的场景下这个“半成品”被特殊机制提前暴露了出去这就是后文要讲的三级缓存存在的理由。3.2 为什么Spring不直接实例化完再填充属性有些初学者会问为什么不能一次性把Bean创建好再处理依赖非要搞得这么复杂实例化一半、填充属性、再初始化关键在于Java对象的创建方式。Java里new一个对象是先在堆里分配内存调用构造器生成一个实例然后你才有机会去设置它的字段值。Spring作为一个帮你管理对象生命周期的框架它能做到的极限就是“先生成实例对象再想办法把该填的依赖填进去”。构造器里能用的依赖必须在构造器参数里传入字段和Setter的依赖则是在实例化完成之后处理的。这就带来一个经典问题如果A和B互相依赖A构造器需要BB构造器需要A那就彻底没解了因为没有任何一方能被先创建出来。这也是为什么Spring官方一直推荐字段注入和Setter注入来处理循环依赖而不推荐构造器注入。构造器注入遇到循环依赖直接抛BeanCurrentlyInCreationException。3.3 初始化回调的执行顺序每次面试被问“PostConstruct、InitializingBean、init-method的执行顺序”总有人记混。这里我直接给出一张我自己整理的顺序表回调方式执行时机实现方式PostConstruct最早注解由CommonAnnotationBeanPostProcessor触发InitializingBean.afterPropertiesSet其次接口方法直接实现init-method或Bean(initMethod...)最后反射调用指定方法拿到这个顺序还不够你得理解背后的设计逻辑。PostConstruct是JSR-250标准Spring只是提供了一个后置处理器来识别它所以它最早InitializingBean是Spring自家接口耦合度高但性能好init-method是纯XML时代配置出来的方案最灵活也最弱。实际项目里我个人强烈建议只用PostConstruct因为它语义清晰、不侵入代码、而且和Spring解耦。实验心得想验证这个顺序不需要看源码。写一个Bean三个回调方法里各打一行日志启动项目一目了然。我每次带新人都会让他们做这个实验比背十遍面试题都管用。4. 三级缓存Spring解决循环依赖的核武器4.1 三级缓存分别存的是什么这是Spring Context里最常被拿来当面试题的点也是很多人背得滚瓜烂熟却说不清“为什么需要三级”的点。先明确三级缓存分别是什么一级缓存singletonObjects存放完整的、已经初始化好的单例Bean二级缓存earlySingletonObjects存放提前暴露的早期Bean引用这些Bean可能还没完成属性填充三级缓存singletonFactories存放ObjectFactory对象通过它能在需要的时候生成Bean的早期引用三个缓存的定位差异用一句话概括一级放成品二级放半成品三级放“生产半成品”的工厂。为什么还要第三级呢因为生成早期引用这个操作不是任何时候都需要执行。如果整个项目没有循环依赖三级缓存里的ObjectFactory压根不会被调用Bean会正常走完生命周期然后放进一级缓存。Spring在获取单例Bean时的寻找顺序是一级缓存 → 二级缓存 → 三级缓存。找到就直接返回如果三级缓存里有对应Bean的ObjectFactory就调用它的getObject()生成一个早期引用放进二级缓存然后从三级缓存里移除这个工厂。这个设计我非常喜欢因为它在不增加额外开销的前提下最大程度地保证了“懒”和“准确”——能用成品就用成品实在需要半成品兜底才提前暴露。4.2 循环依赖的解决过程我给你演一遍假设现在有两个BeanA依赖BB依赖A都是通过字段注入或者Setter注入创建A实例化得到一个A的原始对象此时A的b字段还是null把A的ObjectFactory放入三级缓存继续填充A的属性发现需要B于是去获取B创建B实例化得到B的原始对象把B的ObjectFactory放入三级缓存填充B的属性发现需要A于是去获取A从一级缓存找A没找到到二级缓存也没找到到三级缓存找到了A的ObjectFactory调用ObjectFactory.getObject()得到A的早期引用此时可能已经做了AOP代理把这个早期引用放进二级缓存从三级缓存移除B拿到A的早期引用成功完成属性填充B初始化完成放进一级缓存回到A的创建流程A拿到B的完整Bean完成属性填充和初始化放进一级缓存整个过程最神奇的是第7步。A在被B依赖的时候还只是一个刚实例化完、属性都没填充的“半成品”但通过三级缓存它提前以一个引用的形式出现在B的视野里。Java传递的是对象引用所以B里保存的那个A和最终完成整个生命周期的A是同一个对象如果没有AOP代理介入的话。4.3 为什么非要是三级二级不行吗这个问题是最考验理解的。很多人能背出三级缓存的名字但问一句“为什么二级不够”立马卡住。如果只有二级缓存流程这样走A实例化后直接放入二级缓存B创建时从二级缓存拿到A的早期引用注入后B完成A再从二级缓存拿到B完成自身初始化。看似也行但问题是A的AOP代理什么时候创建在Spring的实际实现中AOP代理通常在Bean初始化完成之后的BeanPostProcessor阶段才产生。如果只有二级缓存A被B提前获取时还是原始对象后面即使A经过后置处理器生成了代理对象B里保存的仍然是原始对象代理失效AOP彻底完蛋。三级缓存的意义就在于允许SmartInstantiationAwareBeanPostProcessor在提前暴露对象的瞬间就返回代理对象。ObjectFactory的getObject()方法内部会调用getEarlyBeanReference而AbstractAutoProxyCreator重写了这个方法返回的是代理对象。这样B拿到的就是一个代理对象后续A完成代理创建后两者是同一个东西AOP功能不受影响。用一句大白话总结二级缓存能解决循环依赖但解决不了循环依赖AOP的组合场景。Spring做设计的时候不可能为了省一个Map放弃AOP支持所以三级缓存是刚需不是过度设计。4.4 哪些循环依赖救不了三级缓存虽强但有两个场景是它救不了的构造器注入的循环依赖A的构造器需要BB的构造器需要A两边都没法先实例化三级缓存里连“提前暴露”的机会都没有原型Prototype作用域的循环依赖原型Bean本身不缓存每次获取都是新实例连一级缓存都进不去更别提三级了实际项目里遇到构造器循环依赖我的解决办法通常是两个要么把其中一方的注入方式改成Setter注入或Lazy延迟加载要么重构设计让依赖方向变得单向。Lazy是一种非常优雅的解法它在注入的时候不是直接给真实Bean而是给一个代理对象等到真正调用方法时才去Context里查找并实例化真实对象相当于把依赖关系的建立推迟到了运行期。5. 依赖注入的底层原理与实战选型5.1 Autowired到底是怎么把Bean塞进来的很多人每天写Autowired但没想过这个注解背后是谁在干活。其实Spring处理Autowired依赖的是一个叫AutowiredAnnotationBeanPostProcessor的后置处理器。它在Bean属性填充阶段介入具体工作流程是这样的遍历当前Bean的所有字段和方法找出标注了Autowired的位置对每个需要注入的依赖调用BeanFactory的resolveDependency方法去容器里找类型匹配的Bean找到多个候选Bean时结合Primary、Qualifier、字段名来决定最终注入哪一个找到后通过反射强制设置字段值setAccessible(true)这个过程中resolveDependency是最核心的方法它内部处理了一堆边缘情况比如Optional、ObjectProvider、Lazy、泛型等。如果你注入的是List 它会找到所有该接口的实现类并按照Order排序后注入一整个列表。这个特性在策略模式里特别常用。5.2 三种注入方式我为什么建议你用构造器老生常谈的问题了但每次都有必要再捋一遍。Spring支持字段注入、Setter注入和构造器注入三种方式的对比方式优点缺点推荐程度字段注入代码最少写起来最快依赖对外不可见测试难替换容易产生循环依赖不推荐Setter注入可选依赖友好可在运行时修改对象可能处于“未完全注入”状态时序上容易踩坑老项目常见构造器注入不可变、必填依赖一目了然、测试友好、天然防循环依赖构造器参数太多时类显得臃肿官方推荐Spring官方文档明确推荐构造器注入核心原因有两个一是保证依赖不可变Bean在被创建的时候依赖就必须全部就位二是方便做单元测试直接new对象传参即可不需要反射。但我自己写业务代码的时候其实挺矛盾的。构造器注入在网络层和工具类里很好用但在Service层如果有五六个依赖构造器就会变得很长。后来在实践中我形成了自己的风格核心必填依赖用构造器注入可选可替换的依赖用Setter注入字段注入只在写测试桩或者临时调试代码时用。5.3 Autowired和Resource的区别别搞混这个问题看着基础实际坑不少。Autowired是Spring的注解按类型注入Resource是JSR-250标准的注解先按名称后按类型。具体来说Autowired默认按类型寻找Bean找到多个候选时通过Qualifier指定名称Resource默认按字段名寻找Bean字段名找不到再按类型寻找Autowired支持构造器注入、字段注入、方法注入Resource只支持字段和Setter注入在只有一个实现类的场景下两者没区别但在有多个同类型Bean的场景下行为截然不同。我处理过一个线上问题一个Service接口有两个实现类代码里用的是Autowired结果Spring启动直接报NoUniqueBeanDefinitionException。当时解决的办法是在注入位置加Qualifier(xxx)或者在某个实现类上加Primary。如果当时用的是Resource并且字段名恰好和其中一个实现类的Bean名称一致就不会有问题。经验之谈我写新项目的时候统一用构造器注入Qualifier不怎么用Resource。但接手老项目时要格外留意Autowired和Resource混用的情况两者的解析顺序不一样很容易出现“换了个环境启动就报错”的诡异问题。6. 实战中常见的Context异常与排查思路6.1 NoSuchBeanDefinitionException八成是扫描范围问题这个异常是最常见的当Spring容器里找不到你想要的Bean时就会抛出来。排查思路基本上是固定的确认类上有没有Component、Service、Respository等注解确认这个类所在包是否在启动类所在包的子包下面确认是不是被ComponentScan排除了用了excludeFilters如果是接口注入确认有没有对应的实现类我遇到最多的情况是第2种尤其是新人在启动类外面建了一个config包里面写了Configuration类但注解了ComponentScan扫了一个不存在的包路径反而把默认扫描搞失效了。排查这种问题最有效的办法就是启动时加-Debug或者直接看启动日志里“BeanDefinitionNames”的打印。6.2 NoUniqueBeanDefinitionException同类型多个实现类怎么选前面提到过Autowired按类型注入时发现同类型Bean有多个就会抛这个异常。解决办法有三个层次在实现类上加Primary设置默认优先选择的那个在注入处加Qualifier(beanName)指定精确的Bean名称改成注入List 或MapString, Interface把选择权交给调用方第三种方式我特别推荐用在策略类场景。比如支付渠道有支付宝、微信、银行卡三种实现你注入一个List 然后按渠道编码从里面选一个代码既清晰又方便扩展。Spring在注入List时会按类型找齐所有实现类并且用Order标注的顺序来排序。6.3 BeanCurrentlyInCreationException循环依赖的报警器这个异常本身就是循环依赖的信号。看异常栈的时候重点看两处一是被创建中的Bean名称二是当前正在创建的那个Bean。比如异常说“Bean with name a has been injected into other beans [b] in its raw version as part of a circular reference”说明A和B直接或间接互相依赖了。遇到这个异常先看依赖链不要急着改代码。把A和B各自的依赖列出来找到是谁形成了环。解决手段我前面说过总结成三句话构造器循环依赖改成Setter或Lazy设计上打破单向依赖实在不行就拆类把互相拉扯的逻辑拆出去独立成新的Bean。6.4 上下文数量不对事务失效的隐形杀手Spring Boot项目里还可能遇到一种很隐蔽的问题Context被初始化了两个导致事务注解失效、AOP不生效、Bean出现两份实例。这通常是配置问题引起的比如在非Spring Boot管理的类里手动new了一个ApplicationContext或者自定义了ServletContextInitializer又和Spring Boot内置的初始化器冲突了。排查方法也不复杂启动后写一个ApplicationRunner打印beanFactory.getSingletonCount()或者列出所有ConfigurableListableBeanFactory里的BeanDefinition名称看看有没有重复的定义。此外留意启动日志里出现两次“Root WebApplicationContext initialization completed”这种类似信息基本就是多上下文没跑了。6.5 配置属性绑不上别总怪Spring Context最后说一个和Context间接相关的常见问题ConfigurationProperties类扫描到了属性也写了application.yml里但值就是注入不进去。往往不是Context的问题而是YAML格式本身有误比如缩进不一致或者映射值格式错误启动日志会提示“yaml: mapping values are not allowed in this context”。排查这种问题时可以先人肉检查YAML缩进再用Spring Boot的Configuration Metadata机制来辅助给配置类加上Validated并给字段标注默认值启动时一旦绑定失败会给出更详细的报错。我曾经为一个多级嵌套配置变量名大小写的问题折腾了一下午后来发现是因为Kebab Case和下划线混用Spring Boot的Relaxed Binding好在支持多种命名风格但前提是你不要在同一条配置里切换风格。7. 扩展一下手写一个迷你版Spring Context7.1 所有人都该做一次的小项目如果想真正把Spring Context掌握扎实我强烈建议抽一个周末自己动手写一个简化版容器。这不是什么高深的事核心就这几步定义一个MyComponent注解实现一个扫描器遍历指定包下所有类找出标注了注解的类把类信息封装成自己的BeanDefinition放入一个Map按BeanDefinition创建实例保存到单例池反射扫描实例的字段对加了MyAutowired注解的字段执行依赖注入支持BeanPostProcessor机制允许用户自定义增强逻辑写完这个迷你容器之后你再回去看Spring的源码那种“原来如此”的感受完全不一样。我为这事写过大概五六百行代码包括一个简单的BeanPostProcessor演示AOP切面做完之后对三级缓存、生命周期这些概念的理解提升了一个档次。7.2 手写过程中的三个关键坑我写的时候踩过几个坑分享一下第一个坑是实例化的时机。一开始我图省事扫描完直接全部实例化结果发现如果有循环依赖直接StackOverflow。后来改成两步走先注册BeanDefinition再按需实例化。实例化时如果发现依赖还没创建就递归去创建依赖。第二个坑是BeanName冲突。Spring的默认命名策略是类名首字母小写我一开始没实现结果两个类同名不同包直接互相覆盖。后来老老实实按SimpleName做小写处理和Spring保持一致。第三个坑是实例的生命周期。我最初把所有Bean都做成单例后来想支持Prototype发现必须在getBean方法里做判断不能直接从单例池取。这一改才真正理解了为什么Spring要把scope设计得这么灵活。7.3 Spring AI时代的Context依然是一切的基础现在很多新项目已经开始上Spring AI我在自己的实践项目里也试了用它对接大模型接口。不少人误以为Spring AI是新的一套东西其实它依然是构建在Spring Boot和Spring Context之上的。你写一个Bean方法注册一个ChatClient这个返回的Client实例仍然是由Context管理生命周期你配置模型供应商的地址和密钥仍然走的是Environment和ConfigurationProperties那套机制。热词里经常有人搜“spring ai alibaba”“spring ai 2.0”其实底层跑的还是Context。换个角度说只要你对Context和Bean生命周期的理解够扎实学习Spring AI的难度会下降一大截。因为你已经知道那些看似神秘的AIClient是怎么被创建、怎么被注入到你的Service里去的剩下的只是一些模型配置和提示词模板的拼装工作。7.4 GraalVM原生镜像对Context的影响最后聊聊一个我个人碰到过的新方向用GraalVM打原生镜像部署Spring Boot应用。这玩意儿对Spring Context是一个很大的冲击因为Spring Context大量使用反射、动态代理、CGLIB字节码增强这些在原生镜像里默认都是不可用的。Spring官方提供了GraalVM原生支持通过构建时处理AOT把反射信息提前写入配置文件但前提是你得保证所有需要反射的类都能被扫描到并记录下来。我试过一次把一个中型的Spring Boot项目打成原生镜像遇到一堆ClassNotFound和反射初始化错误最后靠Spring Boot官方文档里的GraalVM章节逐个补配置文件才跑通。这个过程让我对Spring Context的“反射驱动”有了更深的体会平时开发环境什么都好是因为JVM给了Spring无穷的反射自由到了原生镜像里自由没了才发现Context底层居然有这么多隐式约定。8. 写在最后的调试心得回到文章开头那句话Spring Context确实是Spring框架的心脏。从BeanFactory到ApplicationContext从三级缓存到依赖注入这些概念看起来孤立实际上是一条完整的逻辑链。在调试上下文相关问题时我自己最常用的技巧有三个第一启动时开启DEBUG日志把Context的初始化过程完整拉出来看第二写一个BeanFactoryPostProcessor临时打印所有已注册的BeanDefinition名称排查扫描范围第三善用Spring Boot的Actuator的conditions端点查看所有Bean的创建条件和结果。不要一遇到Bean异常就慌先看异常信息里提到的Bean名称顺着生命周期去查。大多数问题都逃不过两个原因要么Bean没被扫描到要么Bean创建的过程中某个依赖没有就绪。理解了Context的工作原理你的排查手段自然就会丰富起来不再局限于重启试错。如果时间允许我真的建议你亲手写一个迷你版Context那比读十篇源码分析都更有收获。纸上得来终觉浅Spring这种框架尤其如此。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →