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: "thread=main", AbstractApplicationContext.refresh(), line=720 bci=0 main[1] cont Breakpoint hit: "thread=main", DefaultSingletonBeanRegistry.getSingleton(), line=244 bci=0 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 Map<String, 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 Map<String, BeanDefinition> beanDefinitionMap = new LinkedHashMap<>(); protected final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); protected final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(); protected final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(); public void registerBeanDefinition(BeanDefinition definition) { beanDefinitionMap.put(definition.getBeanName(), definition); } }ObjectFactory我直接仿Spring定义了这样一个接口:
@FunctionalInterface public interface ObjectFactory<T> { 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依赖OrderRepository,OrderRepository反过来依赖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之后 |
| 注入进去是null | resolveValue未识别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,那个方向又有一套新的坑可以写。