ARTICLE DETAIL

资讯详情

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

Spring Framework源码运行机制与Bean生命周期实战解析

Spring Framework源码运行机制与Bean生命周期实战解析 说实话做了这么多年Java开发SpringFramework对我来说已经从“框架”变成了“老朋友”。早期刚入行时我把它当黑盒用遇到问题就百度配置CtrlC、CtrlV跑通了就完事。直到后来线上出了几个诡异故障被迫一行行去翻springframework源码运行链路才真正理解这个框架的设计逻辑。这篇随笔不打算写成教程式文档我想以一个老开发的身份聊聊我对Spring的理解、源码运行时的一些关键节点以及这些知识在实战中怎么救了我。1. 初见Spring它解决的不是写代码问题而是搭骨架问题很多初学者接触SpringFramework时第一反应是“这玩意儿让代码变得更复杂了”——明明一个new就能搞定的对象非要用容器管起来明明是简单的调用非要在中间塞一层代理。这种困惑我太熟悉了因为我当初也是这么想的。1.1 没有Spring的年代代码是怎么腐烂的先聊聊背景。在Spring还没成为主流之前Java企业级开发是典型的“分层架构手写时代”。Service层要调DAO层你得在Service里自己new一个DAO对象DAO要连数据库你得在DAO里自己读取配置文件、创建数据源如果还想加个日志、加个事务那就在每个方法的开头和结尾手动写模板代码。这套写法最大的问题不是“代码多”而是对象之间的耦合关系写死在代码里。今天我用了MySQL明天要换成Oracle我得去每个Service里改DAO的构造逻辑今天我用了本地事务明天要接入分布式事务我得把所有事务开启和提交的代码全局替换一遍。程序越写越大修改的成本越高到后期几乎动一个功能就会牵出一串连锁反应。SpringFramework切入的正是这个痛点它把“对象的创建、组装、生命周期管理”从业务代码中抽离出来交给一个叫IoC容器的东西统一负责。你的Service不再关心DAO怎么创建它只需要声明“我需要一个DAO接口的实例”容器就会在运行时把合适的实现给它。代码里的依赖关系从“硬编码”变成了“声明式”替换实现的时候只需要改配置或者改注解不用动业务代码。1.2 容器与对象从new到被托管的思路转变要理解Spring最关键的一个认知转换是你的类不再是你自己new出来的而是容器养大的。这个转变刚开始会让人很不适应你会在心里嘀咕我自己new对象清清楚楚、明明白白为什么要交给容器交给容器之后对象什么时候创建、什么时候销毁我还能控制吗这里我打个比方。你自己new对象就像自己在家做饭想吃什么做什么一切自主可控但每天都要花大量时间买菜、洗菜、炒菜、刷锅。Spring容器就像一家餐厅你把需求写进菜单配置文件或注解后厨容器按菜单给你上菜。你省下了做饭的时间代价是你得信任后厨的流程——但你依然可以提要求这道菜不要放葱exclude这道菜要少盐参数配置这道菜现点现做prototype作用域。从源码运行角度看这个“信任”不是盲目的。Spring容器启动时会经历一个完整的生命周期解析配置、扫描类、注册Bean定义、实例化Bean、填充属性、执行初始化方法……每一步都有对应的扩展点留给你。理解这些扩展点等于拿到了餐厅后厨的参观券你不但知道菜是怎么做的还能在特定环节插一脚改变菜的成品。这套思路带来的价值在项目规模小的时候不明显一旦面对几十上百个Service、Controller、Repository的协作面对单元测试要替换Mock对象、面对多环境切换配置IoC的优势会放大得非常明显。我个人甚至觉得IoC容器是SpringFramework对整个Java生态最大的贡献AOP、事务、MVC这些功能全都是建立在这个底盘之上的。2. 从源码运行视角看Bean的一生先泼一盆冷水SpringFramework源码量巨大核心容器部分就几万行如果你试图“从头读到尾”大概率会在前两章就放弃。我自己的经验是带着问题去读先关注Bean从定义到销毁的完整路径。这条路径打通了你就掌握了Spring源码运行的主干。2.1 ApplicationContext启动时到底发生了什么很多人写了很久Spring代码其实没搞明白启动那一瞬间发生了什么。我以最常见的AnnotationConfigApplicationContext为例梳理一下源码运行的起点。当你执行new AnnotationConfigApplicationContext(AppConfig.class)时里面其实走了三步行云流水的操作调用无参构造器内部会初始化一个AnnotatedBeanDefinitionReader和一个ClassPathBeanDefinitionScanner。这两个组件是后续扫描和注册Bean定义的关键角色。调用register(Class?... componentClasses)方法把你传入的AppConfig.class注册为一个Bean定义。注意这里只是“登记”还没到“实例化”。调用refresh()方法这是SpringFramework源码里最核心、最庞大的方法之一所有容器的启动逻辑几乎都汇聚在这里。refresh()方法定义了容器启动的12个步骤名字你就算没读过源码也一定听说过prepareRefresh、obtainFreshBeanFactory、prepareBeanFactory、postProcessBeanFactory、invokeBeanFactoryPostProcessors、registerBeanPostProcessors、initMessageSource、initApplicationEventMulticaster、onRefresh、registerListeners、finishBeanFactoryInitialization、finishRefresh。这里我不打算把12个步骤挨个展开讲那够写一本书只挑对整个运行链路影响最大的三个invokeBeanFactoryPostProcessors这一步会执行所有BeanFactoryPostProcessor包括处理Configuration配置类的ConfigurationClassPostProcessor。你的ComponentScan、Import、Bean注解都是在这个阶段被解析并注册成BeanDefinition的。换句话说到这个阶段结束容器里所有Bean的档案才齐全。registerBeanPostProcessors这一步注册所有BeanPostProcessor。注意这里是“注册”不是“执行”。它们会在后续Bean实例化过程中在特定节点被回调。finishBeanFactoryInitialization这一步真正开始实例化所有非懒加载的单例Bean。你写的那些Service、Controller、Repository都是在这一步被new出来并组装好的。我记得第一次在refresh()里打断点跟踪时内心是相当震撼的——原来一个简单的启动动作背后藏着这么精密的编排。这也是我强烈建议每个使用者去读一遍refresh()方法的原因它是理解SpringFramework源码运行的一把总钥匙。2.2 Bean创建的四个阶段与BeanPostProcessor的截获点Bean的创建过程网上有很多版本的说法我习惯把它拆成四个阶段实例化Instantiation、属性填充Populate、初始化Initialization、销毁Destruction。这四个阶段对应着源码里AbstractAutowireCapableBeanFactory的createBean方法链路。先看实例化阶段。默认情况下Spring通过ConstructorResolver选择合适的构造器来创建实例。如果没有显式指定构造器参数就调用无参构造器如果类里只有一个有参构造器Spring会自动匹配参数。这一步走的是反射机制的newInstance所以如果构造器里抛了异常你在启动阶段就会看到BeanInstantiationException。属性填充阶段很关键。Spring会把BeanDefinition里记录的属性值、以及需要自动注入的依赖逐一处理。字段上标了Autowired的会通过AutowiredAnnotationBeanPostProcessor这个后处理器来解析标了Resource的则由CommonAnnotationBeanPostProcessor处理。这个阶段也是循环依赖问题的高发区后面我会单独展开。初始化阶段是大家最熟悉的。如果Bean实现了InitializingBean接口会回调afterPropertiesSet()方法如果Bean注解上配置了initMethod会回调指定的初始化方法如果类上标了PostConstruct注解也会在这个阶段被调用。执行顺序上PostConstruct最先afterPropertiesSet中间initMethod最后。真正让Spring强大的是上面三个阶段之间都嵌着BeanPostProcessor的回调。具体来说实例化之后、初始化之前会执行postProcessBeforeInitialization初始化之后会执行postProcessAfterInitialization。所有实现AOP、代理增强的后处理器都是在postProcessAfterInitialization阶段通过包装原始Bean、生成代理对象来生效的。这里我说一个容易踩坑的点如果你的Bean被代理了那么容器里保存的其实是代理对象不是原始对象。这个差异在日常开发中不显眼但当你写工具类通过context.getBean(SomeClass.class)获取Bean然后想用getClass()拿注解时你拿到的可能是代理类的注解原始类的注解反而被“藏”起来了。2.3 循环依赖处理为什么单例能原型不能循环依赖是Bean生命周期话题里绕不开的经典问题Class A里注入了Class BClass B里又注入了Class A这种情况Spring是怎么处理的答案在“三级缓存”机制里。这三级缓存对应DefaultSingletonBeanRegistry里的三个MapsingletonObjects一级缓存保存成品单例Bean。earlySingletonObjects二级缓存保存已经实例化但还没完成属性填充和初始化的“半成品”Bean。singletonFactories三级缓存保存单例Bean工厂用于提前暴露引用。处理过程大概是这样的创建A时A实例化完毕但还没填充属性Spring就把A的singletonFactory放到三级缓存里“提前曝光”接着A填充属性时发现需要B于是开始创建BB创建过程中填充属性时发现需要A此时B去一级缓存找不到A但可以从三级缓存里通过工厂获取A的提前引用通常是一个原始对象引用然后继续完成B的创建B创建完成后回到AA填充B属性完成A的剩余流程。我说的这个是简化版实际上因为代理的存在三级缓存里存的是“工厂”就是为了确保如果A需要被增强提前暴露出去引用时也能拿到合适的对象。很多源码分析文章用大量篇幅讲这个机制但我建议你只需要记住结论单例Bean默认支持循环依赖原型Bean不支持构造器注入不支持。因为你采用了原型作用域或者构造器注入时Spring根本没法提前暴露不完整的对象只能直接抛BeanCurrentlyInCreationException。实战中我的建议是尽量别主动依赖循环依赖它能运行不代表设计合理。我见过好几个项目循环依赖缠成一团最后改需求时牵一发动全身。如果你真的遇到了循环依赖报错优先考虑重构把互相引用的逻辑拆出来而不是想尽办法绕过异常。3. 请求进来之后Spring MVC运行链路的源码跟踪如果说容器是SpringFramework的心脏那Spring MVC就是它在Web层最亮眼的名片。很多人天天写Controller却不知道一个HTTP请求是怎么一步步变成HandlerMethod里的那个invoke调用的。这部分我带大家走一遍运行链路。3.1 DispatcherServlet的九个组件Spring MVC的核心是一个叫DispatcherServlet的前端控制器它本质上就是个Servlet所有请求都会先经过它。这个类里有九个策略接口分别负责请求处理流程中的不同环节组件职责默认实现HandlerMapping找到处理请求的HandlerRequestMappingHandlerMappingHandlerAdapter执行Handler并适配参数RequestMappingHandlerAdapterHandlerExceptionResolver处理Handler执行中的异常ExceptionHandlerExceptionResolverViewResolver根据视图名解析视图InternalResourceViewResolverLocaleResolver解析用户区域信息AcceptHeaderLocaleResolverThemeResolver解析主题样式FixedThemeResolverMultipartResolver处理文件上传请求StandardServletMultipartResolverFlashMapManager管理重定向参数传递SessionFlashMapManagerHandlerInterceptor拦截请求执行前后逻辑无默认需自行配置这里我想强调一个认知DispatcherServlet本身不干活它只是调度员。收到请求后它把任务分发到各个组件。理解了这个角色定位你再看Spring MVC源码就不会迷路因为你只需要关注两个关键动作这个请求最终交给哪个Handler执行执行前和执行后有没有人做了手脚3.2 HandlerMapping与HandlerAdapter怎么选中你的Controller方法当一个请求进来比如GET /api/users/123DispatcherServlet会先通过HandlerMapping找到对应的Handler。RequestMappingHandlerMapping做的事其实就是拿着请求的路径、方法类型、参数条件去匹配容器里所有标注了RequestMapping或衍生注解的方法。匹配成功后返回的是一个HandlerExecutionChain它里面装着真正的Handler通常是HandlerMethod封装了Controller的Class对象和方法以及所有匹配到的拦截器。这里要注意它返回的不是Controller实例而是方法的描述对象。真正的Controller实例要等到执行阶段才从容器里获取。拿到HandlerExecutionChain之后DispatcherServlet会根据Handler的类型找到合适的HandlerAdapter。为什么需要适配器因为Handler的类型不统一有的是HandlerMethod有的是HttpRequestHandler有的是老的Controller接口。适配器把不同的Handler统一成“可执行”的接口调用方不用关心具体类型。到了RequestMappingHandlerAdapter执行阶段好戏才开始。它会先去解析方法参数PathVariable从路径模板里提取RequestParam从请求参数里绑定RequestBody借助HttpMessageConverter把请求体转成Java对象ModelAttribute从Model里获取……这一套参数解析器加起来有二十多个每个都对应着一种参数注解的解析逻辑。参数解析完成后才真正调用你的Controller方法拿到返回值后再通过返回值处理器处理——ResponseBody会把返回对象通过消息转换器序列化成JSON。3.3 代理与AOP你以为调的是Controller其实是代理说到请求执行就必须聊聊AOP在运行链路中的位置。很多人以为AOP只用在Service层做事务、做日志其实Controller也能被拦截。当你给Controller方法或类加了Aspect相关的切面容器在创建这个Controller Bean时就会在后处理阶段用代理包装原始对象。所以在运行时你调用的handler.handle()看起来是在执行业务方法实际上如果是代理对象会先进入切面逻辑Before先执行然后是Around的前半段接着才是目标方法本身目标方法返回后再执行AfterReturning或AfterThrowing。这里有一个特别反直觉的坑Spring AOP默认用的是JDK动态代理代理对象实现的接口而不是继承类。如果你的Controller实现了一个接口Spring优先使用JDK动态代理代理对象可以直接强转为接口类型但不能强转为具体的Controller类如果你想让AOP作用于类级别的方法调用得用CGLIB代理这个可以通过spring.aop.proxy-target-classtrue开启。Spring Boot 2.x之后默认就开启CGLIB了但如果你还在维护老项目这一点一定要明确否则用Resource按类型注入Controller时可能因为类型不匹配而注入失败。我自己就踩过这个坑有一个老项目Spring Boot 1.5Controller实现了一个接口Service里通过接口类型注入Controller结果启动的时候报“bean type mismatch”。排查了半天最后发现是AOP代理类型的问题。这说明源码运行机制的知识不是纸上谈兵它能帮你省掉大半天的排查时间。4. 源码读懂前后我调过的几个真实事故理论聊了不少这节我分享三个真实的线上故障每一个都是因为一开始没搞懂SpringFramework的源码运行机制才栽的跟头。希望这些反面试错能帮你少走弯路。4.1 用了懒加载导致启动不报错、运行报错有段时间我喜欢在Bean上写Lazy想着加快项目启动速度。结果有一次上线后接口偶发性地报BeanNotYetCreatedException之类的错误而且只在第一次调用某个Service时出现。排查过程很辛苦最终定位到原因是某些Bean被标记为懒加载后在容器启动阶段不会实例化只有在第一次被注入或使用时才创建。如果这个懒加载Bean又依赖了其他非懒加载的Bean而那个Bean又不满足循环依赖的创建顺序就可能在某些路径上出现“用到时还没准备好”的状态。从源码来看Lazy影响的是AbstractBeanFactory里getBean的触发时机。容器在finishBeanFactoryInitialization阶段只初始化非懒加载的单例懒加载的Bean会等到第一次getBean时才走createBean链路。这意味着所有懒加载Bean的实例化异常都会被推迟到运行期。启动时的“干净”换来的是运行时的“定时炸弹”。教训就是懒加载不是一个免费的优化手段它有副作用。如果你的项目启动慢优先排查是不是有Bean做了一堆无意义的初始化而不是无脑加Lazy。真要加也只在明确知道“这个Bean首次使用前不依赖其他初始化逻辑”时加。4.2 单例Bean里的可变状态引发的并发问题这是一个特别经典的案例。有个同事在单例的Service里定义了一个HashMap做缓存没加锁也没做同步控制结果线上高并发场景下多个线程同时读写这个Map最后数据错乱甚至出现死循环导致CPU飙升。从SpringFramework源码运行机制的角度分析默认情况下Bean是单例的容器里只维护一个实例所有线程共享这个实例。getBean返回的是同一个对象那么所有在线程之间“共享”的成员变量就面临和全局变量一样的并发问题。我当时帮他排查的时候翻了一下代码里的Scope注解确认是默认的singleton然后告诉他这不是Spring的bug而是对“单例”语义理解不够透彻。Spring的“单例”说的是“容器中只有一个实例”不代表它内部状态是线程安全的。解决方案是要么把有状态的组件改成无状态的状态通过方法参数传递要么使用ThreadLocal、锁、或者ConcurrentHashMap来保证并发安全。我个人的习惯是Service层的Bean一律保持无状态任何需要跨方法保存的数据都显式传递或放到专门的存储里。这条规矩看起来简单但能防住很大一部分并发故障。4.3 事务不生效的底层原因没有走代理事务失效大概是Spring面试里问烂了的问题但每个真实场景都值得复盘一遍。之前我们有个方法在同一个类内部做自调用一个Service的methodA()调了同一个Service的methodB()methodB()上标了Transactional结果数据库里发现数据没回滚事务根本没起作用。源码层面的原因很清晰Spring事务是基于AOP的AOP生效的前提是方法调用必须经过代理对象。外部调用methodA()时你拿到的是代理对象的引用事务拦截器能够在方法执行前开启事务但methodA()内部直接this.methodB()调用时这个this是原始对象不是代理对象所以methodB()上的Transactional根本不会被拦截器看到事务自然不生效。这类问题在源码阅读后就很容易理解了甚至不用调试你都能猜到AOP拦截是通过代理类把原方法包一层实现的自调用绕过了代理层等于把整个增强逻辑跳过了。网上有各种解决方案比如把methodB()拆到另一个Service里、通过AopContext.currentProxy()获取代理对象、或者改成self-injection把代理注入进来。我自己的习惯是对于事务控制尽量把事务方法放在独立的Service类里跨类调用才走代理。如果必须在一个类里那就在入口方法上标注Transactional而不是在内层方法上标。这个习惯帮我省了不少排查时间。5. 高效学习Spring源码的路线与方法写了这么多最后聊聊怎么高效地学SpringFramework源码。如果说前面几段是“鱼”这段就是“渔”。毕竟谁都知道源码重要但真正能坚持读下去的人不多。5.1 不要从头读从运行的入口和断点开始我第一次尝试读Spring源码时和很多人一样从spring-core的package开始看。看了两天只记住了几个类的名字完全不知道它们在说什么。后来我换了个方法先写一个最小的Spring程序然后在关键位置打断点跟着调试器走一遍。比如一个最简单的程序public class BootStrap { public static void main(String[] args) { AnnotationConfigApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class); UserService userService context.getBean(UserService.class); userService.doSomething(); context.close(); } }在这个程序里我在refresh()、getBean、createBean、doCreateBean这些方法里打上断点一步步往下走。调试器会把真实的调用栈展示出来比看任何文档都直观。很多源码分析的博客其实都是这么写出来的先跑断点再写解读。5.2 带上任务去读带着几个问题看源码漫无目的地读源码效率极低我推荐任务驱动式阅读。比如你最近遇到了循环依赖问题就带着“Spring是怎么处理循环依赖的”这个问题去读DefaultSingletonBeanRegistry和AbstractAutowireCapableBeanFactory相关代码你被AOP坑过就带着“代理对象是在Bean的哪个阶段生成的”去读AnnotationAwareAspectJAutoProxyCreator。阅读过程中记笔记很重要。我一般会用Markdown文件记录那些“原来如此”的瞬间比如BeanPostProcessor最常见的两个子接口是什么Configuration类为什么是CGLIB代理的而普通Component不是refresh()方法里的onRefresh()模板方法有什么用Spring Boot就是通过这个扩展Spring MVC容器。这些问题一旦记录下来就会形成你自己的知识体系比任何别人的总结都有效。5.3 我常用的源码调试技巧与工具最后分享几个我自己实测好用的调试技巧用IDE的Find Usages功能反向检索。比如你想知道BeanPostProcessor哪些地方调用了它用这个功能比全局搜“postProcessBeforeInitialization”高效得多。在AbstractApplicationContext.refresh()单步调试时配合Evaluate Expression查看当时容器里已经注册了哪些BeanDefinition可以帮助你理解后处理器的执行时机。打开Spring的AbstractTraceLogging相关日志级别DEBUG级别输出可以把容器启动过程中每一步的耗时和关键操作打到控制台。记得小时候我用这个方法定位过一个启动慢的问题最后发现是某个Bean方法在初始化时做了网络请求。遇到代理对象时在IDEA的调试窗口里看对象的class属性如果类名里带着$$或者CGLIB字样就说明这是代理。这个方法秒杀一切关于“到底有没有代理”的争论。工具方面我日常主要的调试环境就是IDEA加一个顺手版本的JDK没有特殊插件。读源码本身不需要花里胡哨的东西关键是动手跑、动手断点而不是光看书。很多人担心SpringFramework源码太庞大学不完。我的体会是它真的不需要学完。你把refresh()这条主链路走通了把Bean生命周期和代理机制这两个核心概念吃透了再遇到实际问题时你就有能力定位到该看的类、该断点的行。剩下的都是有了地图之后的局部探索。Spring的边界一直在扩展从SpringFramework到Spring Boot再到Spring Cloud但底层的IoC容器、AOP机制、生命周期管理这些设计思想始终是支撑整个生态的地基。把这层地基摸透了你再看上层那些花哨的自动配置、微服务组件会有一种“看山还是山”的通透感。这不光是技术能力的提升也是debug直觉的质变——遇到问题不再是瞎猜而是知道“应该在哪个环节查什么”。
返回列表