资讯动态

Spring源码编译调试环境搭建:从IDEA配置到循环依赖验证

发布时间:2026/10/6 19:42:37 来源:尧图企业网站定制
说了你可能不信我当年第一次看Spring源码不是用IDE“看”的是直接clone到本地用文本编辑器硬读。结果就是遇到接口就直接看实现类遇到类就全局搜是哪个包三天下来进度没推多少还把自己绕得晕头转向。后来搭好了一套IDEA编译调试的源码环境一天能顶上之前一周的阅读量。这篇我把自己踩过的坑和完整的搭建过程都理清楚给你一条能直接照着走的路线。先说清楚这篇文章不是教你“怎么用Spring”而是教你“怎么把Spring源码拉到本地编译通过然后像调试自己项目一样打断点看它内部到底做了什么”。这东西适合两类人——第一类是打算深入学习Spring容器、AOP、事务、循环依赖机制的人第二类是工作中遇到疑难杂症、需要从框架源码层面排查问题的Java工程师。读完你至少能获得两个能力一是在Spring官方源码中自由跳转、不会出现“找不到类”的尴尬二是能准确地在refresh、getBean、getSingleton这些关键方法上打断点观察框架运行时真实调用链。1. 为什么要自己动手编译Spring源码1.1 编译源码和“看源码”差距在哪很多人图省事直接去Github网页上翻源码或者用IDEA反编译jar包里的class。这种方式不是不行但体验非常痛苦——Spring框架里接口和实现类之间存在大量间接跳转比如一个AnnotationConfigApplicationContext你点进去后会发现它在org.springframework.context.annotation包但它的父类又牵扯到GenericApplicationContext、AbstractApplicationContext一堆类反编译出来的代码没有注释、没有泛型签名、也没有//开头的设计说明读起来完全是两回事。把源码编译到本地工程之后IDEA会把你源码文件里的所有注释、注解、泛型全部索引起来Ctrl左键能直接跳转到Spring官方注释上的类或方法断点也能打在源码行号上。调试容器启动时你能像看普通项目一样看到变量值、调用栈、对象状态。这个差距用过一次就回不去了。还有一个容易忽略的点编译好的Spring源码工程里有很多测试用例test包下面全是。Spring官方在tests里准备了非常全面的单测比如BeanDefinitionTests、AbstractBeanFactoryTests。这些测试不是摆设它们是最好的“源码阅读入口”——你不需要自己写main方法直接跑一个官方单测断点跟着它走就能看到框架某个特性是怎么被验证和使用的。1.2 动手前必须想清楚的三个问题在你开始敲任何命令之前建议先确认这三点否则很容易白忙一场。第一个是网络问题。Spring源码本身用Maven和Gradle管理第三方依赖构建时会从Maven中央仓库拉几百MB甚至更多的依赖包。如果你的网络环境访问国外仓库慢这一步就会卡很久甚至直接报超时。解决方案是把仓库镜像替换成国内可用的镜像源我在2.3小节里给出了具体写法照着抄就行。第二个是磁盘空间问题。源码size不大压缩包三百多兆但Gradle构建时会在你的用户目录下缓存依赖~/.gradle/caches这个目录很占空间几百MB到1GB都很正常。再加上IDEA的索引缓存建议预留至少3GB空间别在只剩500MB的硬盘上动手。第三个是时间预期问题。第一次构建完成快的幸运儿可能十分钟搞定慢的、网络差的可能要一个小时以上。这不是失败Spring工程模块非常多它要编译core、beans、context、aop、expression等所有模块每个模块还有一堆测试类。如果你不打算读web和mvc相关的代码完全可以只编译几个核心模块这个我在第3章里专门讲了裁剪方法。2. 版本选型、JDK与Gradle准备2.1 Spring版本、JDK版本、Gradle版本怎么匹配这是整个搭建过程里最容易踩雷的地方版本不匹配后面全是错。我的建议非常简单不要一上来就clone最新master分支用release tag。我用的组合是Spring Framework 5.2.x JDK 11 Gradle 6.x。你不需要自己去下载GradleSpring工程自带gradle wrapper它会自动下载对应版本的Gradle我们只需要配置好JDK就行。看一下我用过的版本搭配Spring版本JDK要求Gradle Wrapper适合场景5.2.x如5.2.8.RELEASEJDK 8/11Gradle 6.x左右经典IOC/AOP/循环依赖研究最合适5.3.xJDK 8/11/17Gradle 7.x左右兼顾老特性和新语法社区资料最多6.xJDK 17Gradle 7.5想研究Spring Boot 3配套的底层代码推荐你用5.2.x或5.3.x主要原因是网上关于Spring源码解析的绝大多数文章、书籍都以这两个版本为主比如讲三级缓存、BeanFactoryPostProcessor、事务切面这类高频主题代码行号都能对得上。如果你直接上6.x很多类已经重构了对照老资料调试时思路容易断。特别提醒一下IDEA的问题。新版IDEA自带的JBR是JDK 17或21但这不影响我们给Gradle指定别的JDK版本。你只需在IDEA里安装一个JDK 11或者本机已有然后在Gradle设置里把Gradle JVM指过去。IDEA本身的运行JVM和Gradle的构建JVM是可以分离的这点很多新手会搞混。2.2 源码下载与仓库加速配置源码获取优先用Git clone不是因为GitHub那个ZIP下载不好而是因为我们要切换tag、对比版本git最灵活。命令很简单git clone https://github.com/spring-projects/spring-framework.git cd spring-framework git checkout v5.2.8.RELEASE如果clone速度太慢可以用镜像站或者直接下载对应版本的ZIP包解压后面我会把用到的版本tag都列出来。拿到源码后别急着在IDEA里开先处理Gradle仓库加速。Spring的构建脚本默认从repo.spring.io和Maven Central拉依赖很多依赖下载极慢甚至超时。这里我推荐用全局init脚本的方式在你的用户目录下新建文件~/.gradle/init.gradle内容如下allprojects { buildscript { repositories { maven { url https://maven.aliyun.com/repository/central } maven { url https://maven.aliyun.com/repository/public } } } repositories { maven { url https://maven.aliyun.com/repository/central } maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/gradle-plugin } } }这个文件会在任何Gradle项目构建时自动生效不需要改Spring源码里任何一行build.gradle。这样做的最大好处是源码工程保持干净以后升级或切分支不会冲突。设置完成后你可以在终端cd到源码根目录先跑一下./gradlew --version验证Gradle wrapper能正常启动。这一步会触发Gradle自身版本的下载看到版本号输出后Gradle这关就算过了。2.3 IDEA导入工程的正确姿势IDEA导入Spring源码不要用“New Project”新建空项目再拖源码进去那样模块关系会乱。正确姿势是直接用IDEA的“Open”功能选择源码根目录IDEA会读取settings.gradle文件识别这是Gradle项目然后自动同步索引。几个关键设置点第一个是Gradle JVM。打开IDEA的Settings - Build, Execution, Deployment - Build Tools - Gradle在“Gradle JVM”下拉框里选择我们准备好的JDK 11。如果下拉框里没有点旁边的“Add JDK”按钮手动指定JDK安装路径。第二个是Gradle distribution选择。同一个设置面板里有个“Distribution”选项务必选“Wrapper”这样IDEA会使用spring源码里gradle/wrapper/gradle-wrapper.properties指定的Gradle版本而不是你自己装的某个版本。第三个是关闭自动导入的弹窗压力。导入大工程时IDEA会疯狂后台构建索引如果你的电脑内存吃紧建议在Settings里把“Build project automatically”关掉等依赖同步完再手动构建。完成导入后IDEA会开始解析所有模块这个过程可能要几分钟右下角能看到进度条。如果报某些模块找不到别急着调错很可能只是依赖还没下载完等它跑完再看。3. 编译实战从源码瘦身到构建成功3.1 裁剪无关模块把构建时间从两小时压到半小时这是全文中最实用的一招。Spring的Gradle工程在settings.gradle文件里默认加载了十几个子模块spring-core、spring-beans、spring-context、spring-aop、spring-aspects、spring-web、spring-webmvc、spring-jdbc、spring-r2dbc、spring-rsocket等。如果你确定主要想研究IOC容器、Bean生命周期、AOP这些核心内容完全可以把spring-web、spring-webmvc、spring-jdbc这些用不到的模块注释掉只保留下面几个include spring-core include spring-beans include spring-context include spring-expression include spring-aop include spring-aspects提醒一句spring-context不是独立存在的它依赖spring-core和spring-beans而spring-expression是spring-context编译时的依赖这几条别拆散。真不想编译spring-aspects也可以去掉它是基于AspectJ的编译期织入模块经常因为AJC编译器兼容问题报错研究运行时增强用spring-aop就够了。打开源码根目录的settings.gradle把不需要的include行注释调然后再导入或刷新Gradle工程。这个操作能省掉很多等待时间而且避免碰到Web模块里那些需要额外下载的依赖坑。3.2 关键构建命令与验证方法源码瘦身之后开始构建。有两种方式我建议新手先掌握命令行方式因为报错信息最直观之后再用IDEA的Gradle面板。在项目根目录的Terminal窗口执行./gradlew :spring-core:compileJava :spring-beans:compileJava :spring-context:compileJava这个命令的意思是只编译这三个模块的主代码。不要用./gradlew build或./gradlew compileJava不带模块前缀那会触发所有模块的构建和测试慢到你怀疑人生。如果IDE提示“Cannot resolve symbol AspectJ”之类的编译错误再补上:spring-aop:compileJava再执行一次。编译成功的标志是BUILD SUCCESSFUL并且在每个模块的build/classes/java/main目录下能看到对应的class文件。此时回到IDEA它会自动识别编译产物代码中那种红色的“找不到类”报错基本就消失了。如果想进一步验证整个容器能跑起来可以到spring-context模块的test目录下随便找一个现有的测试类右键Run。比如AnnotationConfigApplicationContextTests这类测试它能真实启动一个容器并跑若干断言。由于测试类在test代码里执行时Gradle会自动编译相关依赖等于帮你验证了编译环境的完整性。3.3 高频编译错误排查思路把我在搭建过程中遇到过的高频错误整理成一个速查表你在哪一步卡住对着看就行。报错信息原因解决办法Unable to locate a Java Runtime / Gradle JVM not foundGradle没找到JDK在IDEA Gradle设置中指定JDK 11或在环境变量设JAVA_HOMEUnsupported class file major versionJDK版本和Spring版本不匹配检查Gradle JVM是不是确实使用了指定JDK而不是IDEA默认JBRCould not resolve org.springframework:spring-core:...某个模块依赖还没编译先用3.2中的命令编译核心模块再刷新GradleCould not resolve all files for configuration依赖下载失败确认init.gradle镜像配置生效删除~/.gradle/caches中对应缓存后重试Execution failed for task :spring-aspects:compileJavaAspectJ编译插件版本问题在settings.gradle里注释掉spring-aspects模块java.nio.file.AccessDeniedException文件权限问题Linux/macOS下给gradlew加执行权限chmod x gradlew这里特别提醒一个坑病根Gradle JVM和Project SDK别混为一谈。Project SDK设置高版本只是给编译器看的真正构建用的JVM是Gradle JVM。很多人给Project SDK设成JDK 17结果Gradle JVM还在JDK 8然后编译Spring 6.x报各种“invalid source release”其实就是JVM版本不一致。另外如果报的是和Kotlin、coroutines相关的错误那多半是某些模块比如spring-r2dbc或spring-rsocket引入了Kotlin插件。裁剪模块时把这些全部注释掉就能绕开。4. 调试环境搭建让断点真正落在Spring内核4.1 建立一个最小复现工程源码编译成功不代表可以调试我们还需要一个“入口”工程来启动容器。我的做法是在同一个IDEA窗口里新建一个独立的Java Module命名debug-demojava目录下写几个简单文件。先写一个配置类package demo; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; Configuration ComponentScan(demo) public class AppConfig { }再写一个普通的Beanpackage demo; import org.springframework.stereotype.Component; Component public class UserService { public void sayHi() { System.out.println(hi from spring demo); } }最后是启动类package demo; import org.springframework.context.annotation.AnnotationConfigApplicationContext; public class DebugMain { public static void main(String[] args) { AnnotationConfigApplicationContext ctx new AnnotationConfigApplicationContext(AppConfig.class); UserService userService ctx.getBean(UserService.class); userService.sayHi(); ctx.close(); } }关键在于这个Module需要依赖你编译好的Spring源码模块而不是Maven仓库里的Spring jar包。在Project Structure - Modules - debug-demo - Dependencies里点加号选Module Dependency把spring-context、spring-beans、spring-core这几个源码模块加进去。加完依赖后代码里所有import都不会飘红而且点击AnnotationConfigApplicationContext可以直接跳到源码文件里。这样一个“最小可调试环境”就搭建完成了。就这么几行代码背后的容器启动逻辑足够你调试很久。4.2 核心断点地图从refresh出发一旦能启动容器我们就要选好断点入口。Spring整个IOC容器启动的“总控制室”是AbstractApplicationContext.refresh()方法你可以在源码里搜到这个方法它就像一个流水线调度器十几个环节依次执行。我在这个方法第一行打个断点然后Debug启动demo模块。启动后会停在这里。此时刷新你的记忆左右两侧是调用栈和变量窗口点“Step Over”单步跳过可以看到refresh方法内的第一行开始逐个执行。下面这张断点地图能帮你定位到所有关键位置断点位置能看到什么AbstractApplicationContext.refresh()整个容器启动的总流程12个关键步骤obtainFreshBeanFactory()beanFactory创建和BeanDefinition加载入口invokeBeanFactoryPostProcessors()用户定义的BeanFactoryPostProcessor、ConfigurationClassPostProcessor执行registerBeanPostProcessors()注册所有BeanPostProcessor这是AOP、自动代理的关键finishBeanFactoryInitialization()单例Bean实例化和初始化的总入口非常重要DefaultSingletonBeanRegistry.getSingleton()单例Bean获取、三级缓存的第一道入口个人经验不要从refresh第一行就开始疯狂Step Into那样容易陷进几百层无关调用。正确节奏是先在finishBeanFactoryInitialization()打断点因为Bean创建、依赖注入、循环依赖、AOP代理全在它后续逻辑里。然后在你的UserService的构造函数、Autowired字段上分别打断点这样你能直观看到Bean创建的时机。4.3 调试过程中的实用观察技巧调试Spring源码和平常调自己代码有些不一样这里分享三个特别管用的技巧。第一个技巧叫“Smart Step Into”。当你停在getBean这一行时直接Step Into可能跳到一堆JDK内部方法或动态代理里又得step out好几层才出来。IDEA的ShiftF7Smart Step Into可以弹出方法候选列表让你手动选择进入哪个方法这在Spring源码里是个神器。比如你想看doGetBean就指定只进入org.springframework.beans.factory.support包里那个方法一步到位。第二个技巧是观察变量时善用“Inline Debugger”的数据渲染。在调试Spring的Map时比如singletonObjects直接展开会看到一堆内部的Node、Hash数组结构。如果你只想知道当前Map里有哪些key右键变量选“View as Object”或者在Evaluate窗口输入singletonObjects.keySet()这样输出的结果干净利落一眼就能看到现在容器里有哪些单例Bean。第三个技巧是开启日志级别。Spring源码里大量使用了if (logger.isDebugEnabled())这种模式默认INFO级别看不到内部日志。不如直接在debug-demo模块的src/main/resources下放入log4j2.xml或者application.properties把org.springframework级别的日志调成DEBUG。这样即便不打断点也能在控制台看到Bean扫描、注册、依赖注入的完整日志。5. 用断点现场验证Spring三级缓存5.1 三级缓存的三个Map到底在哪个类里很多讲三级缓存的博客都会说到“一级缓存是singletonObjects、二级缓存是earlySingletonObjects、三级缓存是singletonFactories”但要真正理解它们需要知道这三个Map就在DefaultSingletonBeanRegistry类里而DefaultSingletonBeanRegistry是DefaultListableBeanFactory的父类而DefaultListableBeanFactory才是你平时感知到的那个AnnotationConfigApplicationContext内部持有的beanFactory实例。这三个Map的定义大致是这样// 一级缓存存放完全初始化好的单例Bean private final MapString, Object singletonObjects new ConcurrentHashMap(256); // 二级缓存存放早期暴露的Bean还未完成属性填充和初始化 private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); // 三级缓存存放ObjectFactory用于生成Bean的早期引用 private final MapString, ObjectFactory? singletonFactories new HashMap(16);要验证它们在getSingleton(String beanName, boolean allowEarlyReference)方法上打一个条件断点条件写成beanName.equals(userService)然后启动调试。这个方法是三级缓存查找的核心。为了配合做循环依赖验证我们得改一下demo里的Bean让两个Bean互相引用Component public class AService { Autowired private BService bService; } Component public class BService { Autowired private AService aService; }DebugMain里改成获取AService并调用一个方法然后重新启动调试。5.2 getSingleton与createBean的完整调用链这次启动断点条件命中后你会在getSingleton(String beanName, boolean allowEarlyReference)里停住。这个方法的代码逻辑不复杂它做的事依次是先从singletonObjects里取一次拿到了直接返回拿不到而且单例正在创建中再从earlySingletonObjects里取二级缓存也没有就从singletonFactories里通过ObjectFactory.getObject()生成早期引用拿到早期引用后放入earlySingletonObjects并删除singletonFactories中的工厂。真正创建Bean的方法在AbstractAutowireCapableBeanFactory.createBean()而createBean会调用doCreateBean。在doCreateBean里有一个方法叫addSingletonFactory这就是把ObjectFactory放入三级缓存的时机。你可以在addSingletonFactory方法上再打一个断点这样能同时看到“放进三级缓存”和“从三级缓存取”这两个动作。结合调用栈观察整个流程会是这样调用getBean(aService);没有缓存进入createBean(aService);doCreateBean创建AService实例因为还没属性填充所以先把ObjectFactory放入三级缓存populateBean往AService里填充属性发现需要BService调用getBean(bService)同上创建BService实例填充BService属性时发现需要AService于是再次getBean(aService);这次getSingleton(aService, true)会在三级缓存里命中通过ObjectFactory生成AService的早期引用早期引用放入二级缓存BService得到这个半成品完成创建BService创建完成后AService继续完成剩余属性填充与初始化最后放入一级缓存。这套流程用断点跑一遍比看任何文章都直观。关键心得是三级缓存不是为了性能也不是为了省内存它是为了在“对象的属性还没填充完整”的情况下把对象的早期引用先暴露给其他Bean让相互依赖的Bean都能顺利走完创建流程。5.3 断点实战循环依赖是怎么被解开的刚才的断点位置能看清流程但还有一个更精彩的观察点——getEarlyBeanReference方法。这个方法的作用是把三级缓存里的ObjectFactory转换成一个真正的早期对象。在进行AOP代理时这一步会产生一个代理对象。你可以在getEarlyBeanReference方法上打断点然后给UserService或者AService加一个切面比如用EnableAspectJAutoProxy加一个简单的Aspect。再看一遍调试过程你会惊讶地发现当AService还只是个半成品时从getEarlyBeanReference返回的却已经是经过AOP代理的对象了。这里引出网上讨论最多的一个问题“为什么三级缓存存的是ObjectFactory不是对象实例”答案就在这个流程里——如果只存对象实例那么当这个Bean后续需要被AOP代理时早期暴露给其他Bean的引用就只是一个原始对象代理增强永远不会生效。而存ObjectFactory可以延迟到getEarlyBeanReference这一步骤才决定是否应用切面、是否生成代理从而保证被注入方拿到的引用和后续放入一级缓存的最终对象是同一个代理对象。说得更直白一点三级缓存是用来“缓存一个可以生成Bean的方法”而不是缓存Bean本身。断点跑到这一步你自己就能感受到这种设计的精妙。验证完循环依赖后你可以删除切面再把getSingleton上的断点条件改成allowEarlyReference true第二次看下来会发现普通场景下根本不会走三级缓存。只有确实存在循环引用时早期引用分支才被触发。6. 源码调试工程之外的几点提醒写到最后给你几个额外的经验。我在第一次成功调试Spring源码后犯过一个错误——以为全模块编译通过才算环境就绪其实根本没必要。你一旦想深入研究某个功能就编译那个功能涉及的模块把依赖链拉长反而容易引入不必要的变数。还有一点是关于IDEA内存的。源码工程加载后的索引文件非常大如果IDEA经常卡顿去Help - Change Memory Settings里把堆内存调到至少2G以上。工程太大时这个调整不是可选项是必须项。最后是我个人非常推崇的一种学习方式从官方测试用例入手。比如spring-beans模块的test目录下有DefaultListableBeanFactoryTests这样的类里面写了几十个不同场景的测试方法。直接运行某个测试方法再配合断点你看到的不是“XX原理是什么”而是“框架在XX场景下代码走了哪条路径”。这种从测试用例反推实现的学习路径效果远好于从文档或者博客主论推理。动手搭好环境后我建议你第一件事不是去啃大段源码而是随便找一个测试类断点跑起来让代码告诉你一切。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑