用JDB调试Spring 6.0源码,手写ROM框架复现控制反转与三级缓存 Spring 6.0发布以后整个生态的热度几乎都集中到AOT编译和GraalVM原生镜像上但作为一个用了快八年Spring的老开发我更关心的是这套核心IoC容器在跨过大版本之后到底稳不稳设计逻辑有没有变化。所以我把目标定得很具体用JDK自带的JDB调试器在Spring 6.0源码上一行行看启动流程再照着它的核心思路手写一套可以定制扩展的ROM框架Runtime Object Mapping运行时对象映射。整个过程走完最大的感受是Spring的复杂不在某一处高深的算法而在它把扩展点全部打开之后代码分叉变得极多可一旦你主线拎得清三级缓存、控制反转、循环依赖处理这些都是可以用几百行代码复现的东西。这篇文章就是我完整记录怎么用JDB追Spring源码、怎么手写ROM框架的过程适合正在啃Spring源码、准备面试或者想从零手写容器的朋友参考。1. 为什么Spring 6.0时代还要手写一个ROM框架1.1 Spring 6.0变了什么AOT很热闹IoC还是那套老底子Spring 6.0对很多人的第一印象是JDK 17、AOT编译、GraalVM原生镜像。AOT这个概念说通俗点就是把原来运行期才做的部分分析工作挪到编译期提前做掉让应用启动更快、内存占用更小。比如一个Configuration类里注册了哪些BeanDefinition本来要等到容器refresh时扫描解析现在AOT阶段就能预处理成静态列表启动的时候直接照着列表装配就行。但这里有个非常关键的事实AOT并没有推翻Spring的容器模型。进入运行时之后对象还是要通过BeanFactory来实现实例化、依赖注入、生命周期回调三级缓存和循环依赖的处理逻辑也还是原样保留。换句话说Spring 6.0的新特性更像是给容器装了一台提前备菜的中央厨房真正的炒菜流程还是原来那口锅。所以想理解Spring 6.0的运行原理重点反而是把IoC容器这层老底子彻底看透。这也是我为什么选择在6.0源码上做调试——既不会脱离新版本环境又能看到最核心的不变部分。1.2 ROM框架是什么给对象装配写一份工单系统ROM是我给自己手写的框架起的名字全称Runtime Object Mapping重点在Mapping。它要解决三件事第一把散落的对象定义注册到一张清单上第二根据清单自动创建对象并把它们之间的引用关系自动接起来第三当出现A引用B、B又引用A这种循环依赖时不能直接死循环崩溃。如果打个生活化的比方它就像一个餐厅后厨的工单系统每道菜的做法先登记在台账上BeanDefinition后厨按单出菜实例化哪道菜需要哪些配菜由系统帮你配齐依赖注入个别菜品之间有来回引用时后厨先把半成品放到暂存台等配菜齐了再恢复完整流程。ROM框架没打算替代Spring它的价值在于用一个极小的内核把Spring最关键的控制反转机制重新跑一遍跑完之后再回头看Spring源码很多之前背过的结论就自然串起来了。1.3 为什么用JDB而不是IDE断点很多人显示在IDE里调试Spring源码拉一个极长的调用栈一层层往底层翻看一会儿就晕了。IDE断点当然方便但对我这种经常要在无图形界面环境里排查问题的人来说命令行调试器才是刚需。JDB是JDK自带的调试器属于JPDA调试体系的一部分不需要任何IDE只要class文件带调试信息、源码路径指向正确就能完成断点、单步、打印变量、查看线程栈这些全套操作。用JDB还有一个额外的好处它会让你把注意力完全集中在调用链和变量值上而不是被IDE那棵巨型调用树带跑偏。尤其是追Spring启动流程这种链路很深的场景JDB的locals、print、up、down这几个命令配合起来能非常精细地观察每一帧的状态。我把常用命令整理了一下stop at设行断点stop in设方法断点cont继续执行next单步跳过step单步进入stepi单步到字节码级别print打印变量locals查看当前帧局部变量where查看线程栈。这套组合拳足够应付99%的源码调试需求。2. 用JDB追Spring启动链路从refresh()到getSingleton()2.1 准备带调试信息的环境要在JDB里看源码行号和局部变量第一个硬性条件是class文件必须带调试信息。javac默认带上行号但不会带局部变量表Spring官方发布出来的jar包通常只保留行号局部变量信息是拿不到的。所以如果你直接拿发行版jar调试很可能会发现能进断点但locals打不出变量或者源码行号对不上。我采用的方式是本地把spring-framework源码拉下来用Gradle构建出带-g的class文件然后调试时通过-sourcepath把源码目录指过去。操作命令大概是这个路子javac -g -d ./classes -cp ./lib/spring-context-6.0.x.jar:./lib/spring-beans-6.0.x.jar \ Demo.java jdb -sourcepath ./spring-framework/spring-context/src/main/java:./spring-framework/spring-beans/src/main/java \ -classpath ./classes:./lib/* Demo这个-sourcepath参数是很多人忽略的关键。jdb提示Source not found八成就是这里没配对。我在调试前还会准备一个最简启动类就用AnnotationConfigApplicationContext加一个空配置类保证能从零开始跑完整个容器启动链路。2.2 断点怎么设四个最关键的位置启动链路很长断点不能乱撒撒多了眼睛不够用。我固定打四个点第一是AbstractApplicationContext.refresh()方法入口这是整个容器的总开关第二是AbstractBeanFactory.getBean(String)入口这是所有对象创建请求的必经之路第三是AbstractAutowireCapableBeanFactory.doCreateBean()这里是实例化、属性填充、初始化三大核心动作的主战场第四是DefaultSingletonBeanRegistry.getSingleton(String, boolean)在这里可以看到一级、二级、三级缓存按什么顺序判断。在JDB里设置断点可以这样写stop in org.springframework.context.support.AbstractApplicationContext.refresh() stop in org.springframework.beans.factory.support.AbstractBeanFactory.getBean(java.lang.String) stop in org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean stop in org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(java.lang.String, boolean)有个细节Spring里重载方法很多getBean有一个String参数版本、也有String加class的版本。JDB对重载判定比较死板方法名后面最好把参数类型写全不然容易弹“歧义”提示。doCreateBean如果你担心签名字符串写不对也可以直接在IDE里抄到方法所在行号用stop at 类名:行号。2.3 实测现场三级缓存这条链子是怎么走的从refresh()一路继续会经过finishBeanFactoryInitialization()然后进入preInstantiateSingletons()这一步会遍历所有非懒加载的单例BeanDefinition并逐个调用getBean()。断点命中时我用print和locals观察执行现场大致是这样Breakpoint hit: threadmain, AbstractApplicationContext.refresh(), line720 bci0 main[1] cont Breakpoint hit: threadmain, DefaultSingletonBeanRegistry.getSingleton(), line244 bci0 main[1] locals实际变量名和行号会因源码版本略有不同但流程是一致的先查singletonObjects一级缓存没命中就查earlySingletonObjects二级缓存再没命中则进入singletonFactories三级缓存取ObjectFactory。一旦从工厂拿到对象这个产物会被立即放进二级缓存同时从三级缓存移除。这个顺序意味着缓存判断是有优先级的成品优先半成品次之工厂兜底。我在doCreateBean()里观察到的顺序也很关键原始对象实例化完成之后会在populateBean()属性填充之前先调用addSingletonFactory()把自己包成一个工厂放出去。这就是“先占坑再填肉”为的是让循环依赖的另一方能够提前拿到一个尚未完成初始化的引用。这一步在Spring 5.x时代如此Spring 6.0依然如此AOT并没有改变这个时序。2.4 一次调试反推三个设计意图用JDB跑了几轮之后我整理了三个值得反复琢磨的设计点。第一个Spring为什么需要三级缓存而不是二级。如果直接把原始对象放到二级缓存那AOP代理场景下对象就提前定型了后续代理后置处理器就没了包装机会。三级缓存放的是ObjectFactory可以在生产对象时临时决定返回原始对象还是代理对象把决策时机延迟到真正有人需要的时候。第二个构造器注入为什么解决不了循环依赖。因为构造器注入在实例化阶段就要求把依赖对象传给构造函数而此时此刻自己本身还没创建完根本没法提前暴露一个“可引用的半成品”。只有字段注入或者setter注入才能先实例化再填充才有机会让三级缓存生效。第三个BeanPostProcessor为什么如此重要。populateBean()和initializeBean()都预留了后置处理器的调用口Spring AOP的AbstractAutoProxyCreator就是在这两个口子上对Bean做代理包装的。理解了这一点再去手写框架就明白扩展点应该放在哪里。3. ROM框架核心实现Bean注册、依赖注入与三级缓存3.1 模块划分与基础类ROM框架我控制在四个核心类BeanDefinition负责描述对象长什么样RomRegistry负责注册表和缓存池RomContainer负责整体装配流程ObjectFactory是一个函数式接口负责延迟创建对象。为什么拆成四个类而不是堆成一个工具类因为跟着Spring源码走一遍就会发现职责分离是容器设计的基本功。注册表管存储容器管流程定义管元数据工厂管延时决策。将来想扩展懒加载、作用域、代理注入都有自己的落点。一旦全堆在一个类里后续每加一个功能就要动那个大类的核心代码改出问题都不知道是哪条链路引起的。BeanDefinition第一版我只设计了最朴素的字段public class BeanDefinition { private final String beanName; private final Class? beanClass; private final MapString, Object propertyValues new HashMap(); public BeanDefinition(String beanName, Class? beanClass) { this.beanName beanName; this.beanClass beanClass; } public void addProperty(String name, Object value) { propertyValues.put(name, value); } public Object getProperty(String name) { return propertyValues.get(name); } public Class? getBeanClass() { return beanClass; } }propertyValues里既放基本类型的字面量也放依赖的beanName。第一版统一当作Object存注入时再判断到底是字符串值还是Bean引用。3.2 注册表与三级缓存池RomRegistry仿照Spring的DefaultSingletonBeanRegistry准备了三个Map一级缓存singletonObjects存成品对象二级缓存earlySingletonObjects存提前暴露的半成品三级缓存singletonFactories存延迟创建工厂。同时还有一个beanDefinitionMap存对象的配置元数据。public class RomRegistry { protected final MapString, BeanDefinition beanDefinitionMap new LinkedHashMap(); protected final MapString, Object singletonObjects new ConcurrentHashMap(); protected final MapString, Object earlySingletonObjects new ConcurrentHashMap(); protected final MapString, ObjectFactory? singletonFactories new ConcurrentHashMap(); public void registerBeanDefinition(BeanDefinition definition) { beanDefinitionMap.put(definition.getBeanName(), definition); } }ObjectFactory我直接仿Spring定义了这样一个接口FunctionalInterface public interface ObjectFactoryT { T getObject(); }用ConcurrentHashMap倒不是第一版就必须并发而是Spring源码里这些map本身就是并发安全的。写框架养成这个习惯后面万一有人放在多线程环境下跑不至于上来就出并发问题。3.3 三级缓存的读写循环依赖的关键getSingleton是整个缓存策略的门面判断顺序严格按照JDB观察到的链路来一级缓存优先二级缓存其次三级缓存最后兜底。代码长这样protected Object getSingleton(String beanName) { Object singleton singletonObjects.get(beanName); if (singleton null) { singleton earlySingletonObjects.get(beanName); if (singleton null) { ObjectFactory? factory singletonFactories.get(beanName); if (factory ! null) { singleton factory.getObject(); earlySingletonObjects.put(beanName, singleton); singletonFactories.remove(beanName); } } } return singleton; }写入端的关键操作是addSingletonFactory。必须在对象实例化完成之后、属性填充开始之前调用把“半成品”通过工厂提前暴露出去。protected void addSingletonFactory(String beanName, ObjectFactory? factory) { synchronized (singletonObjects) { if (!singletonObjects.containsKey(beanName)) { singletonFactories.put(beanName, factory); } } }这里还有一个我特意模仿Spring的细节三级缓存里的工厂返回的不一定就是原始对象。我在doCreateBean里预留了一个getEarlyBeanReference的扩展点将来接代理逻辑时可以让工厂返回值变成代理对象这就是Spring允许“提前引用被代理”的关键机制。3.4 依赖注入getBean触发和引用判断依赖注入的核心逻辑其实很直白填充属性时发现某个属性值对应一个已注册的BeanDefinition名字就调用getBean(name)去把目标对象拿出来再塞进去。递归调用如果设计得不好会死循环好在三级缓存已经把“正在创建中的Bean”提前暴露了所以A依赖B、B依赖A时可以互相拿到对方的半成品等两边都填充完链路自然闭合。我第一版用JDK原生反射来做字段赋值没引入任何第三方工具protected void populateBean(String beanName, Object bean, BeanDefinition definition) { definition.forEachProperty((name, value) - { Field field findField(bean.getClass(), name); field.setAccessible(true); Object resolvedValue resolveValue(value); field.set(bean, resolvedValue); }); } protected Object resolveValue(Object value) { if (value instanceof String name beanDefinitionMap.containsKey(name)) { return getBean(name); } return value; }resolveValue里面的判断逻辑很关键如果属性值是个字符串先看它是不是注册表里的某个beanName是则去取bean否则按字符串字面量注入。这样做虽然简化了Spring的PropertyValue和类型转换器但对第一版跑通循环依赖场景完全够用。3.5 后置处理器给ROM留出代理和生命周期扩展口一个没有扩展点的容器只能算玩具。Spring之所以能包打天下很大程度靠BeanPostProcessor这个口子。ROM里我也加了一个最简接口public interface RomPostProcessor { default Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; } default Object postProcessAfterInitialization(Object bean, String beanName) { return bean; } }doCreateBean完整串起来是这个顺序protected Object doCreateBean(String beanName, BeanDefinition definition) { Object bean instantiate(definition); addSingletonFactory(beanName, () - getEarlyBeanReference(bean)); populateBean(beanName, bean, definition); bean initializeBean(beanName, bean); addSingleton(beanName, bean); return bean; }initializeBean内部按顺序调用Before处理器、执行初始化回调、再调用After处理器。这样一套流程下来普通对象创建、循环依赖处理、后置处理器扩展就都覆盖了。以后想接日志、上下文注入、代理包装都能找到明确插入点。3.6 完整装配示例OrderService和OrderRepository互相依赖不炸为了验证框架真的能跑我写了两个相互依赖的类OrderService依赖OrderRepositoryOrderRepository反过来依赖OrderService。注册和装配代码如下public class OrderService { private OrderRepository orderRepository; public void setOrderRepository(OrderRepository orderRepository) { this.orderRepository orderRepository; } } public class OrderRepository { private OrderService orderService; public void setOrderService(OrderService orderService) { this.orderService orderService; } } public class Main { public static void main(String[] args) { RomContainer container new RomContainer(); container.register(OrderService.class); container.register(OrderRepository.class); container.refresh(); OrderService service container.getBean(orderService, OrderService.class); System.out.println(service.getOrderRepository()); } }运行之后没有抛StackOverflowError日志里能看到A先暴露工厂、B拿到A半成品、B完成后A再拿到完整B的过程。我当时看到这个输出时松了一口气因为这是整个ROM框架里最难啃的一块缓存时序、递归装配、循环依赖三者必须同时成立缺一个都不行。4. 手写与调试过程中踩过的坑4.1 JDB提示Source not found这个坑出现概率非常高。原因基本三种class文件编译时没带-g-sourcepath路径不对源码版本和class版本不一致。我一开始直接从发行版jar调试断点能进去但行号对不上源码list命令打出来的根本不是对应的那行代码。解决方式就是我前文提到的自己本地构建带调试信息的class文件把源码目录准确指给-sourcepath。如果你用IDE反编译源码来对照JDB同样可能“看不懂”因为反编译出来的代码和原始源码行号映射已经错位。原则只有一个调试符号、源码路径、源码内容三者版本必须完全一致。4.2 循环依赖死循环暴露工厂的时机比顺序更重要我仿写时第一次犯的错是把addSingletonFactory写在了属性填充之后。结果一跑A、B互相引用的测试直接StackOverflowError。原因分析下来很清楚如果填充阶段才暴露工厂B注入A时getBean(A)会再次进入doCreateBean(A)但A此刻还在填充阶段它的工厂还没放出去于是再次去组装B无限套娃。修正方案就是严格模仿Spring的时序instance完成之后立刻addSingletonFactory无论当前Bean是否是循环依赖的一员。这是一条写死不能动的顺序约束也是整个手写过程里最深刻的教训。4.3 代理对象伪装成目标对象后来我尝试在ROM里接入简单代理逻辑发现一个诡异的现场getBean返回的对象类型看起来不是目标类但字段内容又是对的。排查后意识到代理对象和原始对象在容器里是两种不同的身份Spring在BeanPostProcessor的after阶段用ProxyFactory生成了代理类所以singletonObjects里存的其实已经是代理对象。判断依赖是否相同不能靠要看beanName调试时看到类型不对也别急着怀疑缓存写错先确认后置处理器是否在正确时机产生了新对象。4.4 常见问题速查表现象可能原因排查方向JDB源码找不到sourcepath不对或class无-g检查编译参数补全sourcepath循环依赖StackOverflow工厂暴露时机过晚把addSingletonFactory前移到instance之后注入进去是nullresolveValue未识别bean引用检查beanDefinitionMap里的name是否一致后置处理器不生效接口没在doCreateBean流程内被调用检查Before/After的调用位置返回对象类型与目标类不一致代理在after阶段替换了Bean按beanName做判断不要用比较对象4.5 用JDB读主链路用IDE读分支调试源码这件事我发现最好的策略是组合使用主线流程用JDB在命令行里单步追因为能强制你记录变量和调用栈分支逻辑比如某种特殊注解的解析、某个条件装配的判断再回到IDE里设断点利用IDE的图层浏览。两种工具互补记住这个策略你查Spring问题的速度会提升一个档次。我个人实际操作中的体会是调试Spring源码和手写ROM框架这两件事合在一起做比单独做任何一件效果都翻倍。JDB负责回答“Spring为什么这么设计”ROM负责回答“这个设计我能不能复现”等两边对上了你对三级缓存、控制反转这些概念的理解就不再是背结论而是真真切切长在了手底。如果你也打算试建议直接从AbstractApplicationContext.refresh()开始打断点断点控制在五个以内先看主链再看分支一天时间基本能把启动主链路摸完。后续我还会把代理扩展和销毁流程也补进ROM那个方向又有一套新的坑可以写。