十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从源码编译OpenJDK:深入理解JVM构建流程与二次开发实践

从源码编译OpenJDK:深入理解JVM构建流程与二次开发实践 1. 源码结构速览从顶层到核心模块OpenJDK的源码组织和我们平时写的业务项目完全是两个维度。我在第一次拉下完整源码的时候第一反应是这玩意儿从哪看起。这里先说一个最重要的认知从JDK 9开始OpenJDK采用了模块化结构整个src目录下的组织方式与此前版本有本质区别。如果你之前看过JDK 8的源码布局那套经验在JDK 11、17、21这些版本上只有一部分仍然适用。打开OpenJDK源码的顶层目录最先看到的是这些一级目录bin/存放构建过程中使用的脚本工具。build/默认的编译输出目录里面按照配置名称区分不同的构建结果。doc/官方文档的Markdown源码。make/整个构建系统的核心里面是大量的makefile和构建脚本这块是编译原理在大型项目中的真实范本。src/全部源码所在下面按模块java.base、java.desktop、hotspot等划分。test/JDK自带的测试套件规模非常大光是jdk目录下的测试用例就有上万条。configure顶层的一个脚本用于生成构建配置。这个文件是整个编译流程的起点。真正值得花时间研究的是src/目录。它内部的模块划分直接映射了JDK的模块系统。以JDK 17为例src/下面可以看到java.base最基础的类库包括java.lang、java.util、java.io这些我们每天都在用的包以及native方法的实现。java.desktopAWT和Swing相关的图形界面类库。java.managementJMX管理扩展。java.rmi远程方法调用。java.security.*安全相关的模块。jdk.compilerjavac编译器的实现。注意编译器本身也是用Java写的。jdk.jvmJVM相关的管理接口实现。hotspotJVM虚拟机的C源码。这是整个OpenJDK最底层、最核心的部分。其中hotspot目录值得单独拿出来说因为JVM的垃圾回收器、类加载器、即时编译器JIT、解释器全都在这里。它内部又分为cpu/按CPU架构划分的代码里面能看到x86、aarch64、riscv等子目录每个架构一套实现。os/按操作系统划分的代码有linux、windows、macos等平台目录。share/平台无关的共享代码JVM的主体逻辑几乎都在这里。所以想研究JVM内存管理去hotspot/share/gc下面看各垃圾回收器的实现想研究类加载机制去hotspot/share/classfile想看JIT编译器去hotspot/share/compiler以及hotspot/share/optoC2编译器。各目录的功能边界非常清楚只要你理解了这套划分逻辑后续阅读源码就会少走很多弯路。2. 为什么值得自己编译一次OpenJDK市面上的JDK发行版很多Oracle JDK、Eclipse Temurin、Azul Zulu、Alibaba Dragonwell等等直接下载安装包确实省事。但如果你愿意花半天时间把源码编译一遍收获不是装了个JDK这么简单。第一编译过程本身就是在学习构建一个大型C项目的完整流程。OpenJDK的构建系统用的是Autoconf加Makefile的组合上层还有大量的Shell脚本和Python脚本辅助。这套构建系统处理了跨平台、跨架构、依赖检测、工具链兼容等一连串复杂问题。你平时做项目如果遇到构建问题不知道从哪排查把OpenJDK的构建配置读一遍对构建系统的理解会上升一个层级。第二很多性能调优和问题排查的场景需要你修改JDK源码来验证想法。举个例子你想测试修改GC参数对延迟的影响或者想自己加一行日志输出看看类加载的具体过程这时候手里有一份能编译的源码就是必须的。我在分析一次线上Full GC问题时就是通过在hotspot/share/gc/g1下面临时加了调试输出重新编译之后才定位到具体的触发点。第三源码级别的阅读效率远高于反编译字节码。虽然你可以在IDE里通过反编译工具查看JDK类库的实现但那些工具还原出来的是Java层代码JVM底层的C实现完全看不到。而且反编译代码没有注释、没有原始命名读起来非常痛苦。源码里每一处关键逻辑都有完整的注释说明还有大量的设计文档JEP相关链接阅读体验完全不同。那么什么样的人适合折腾这事我的建议是对JVM内部实现感兴趣的Java开发者尤其是做性能优化、故障排查方向的人。需要基于OpenJDK做二次开发的产品团队。想深入理解编译器、链接器、构建系统原理的开发者。研究操作系统适配、国产CPU平台适配的工程人员。如果只是写业务代码那用现成的发行版就行了不必花这个时间。3. 编译前的环境准备与关键概念在真正动手编译之前有几个概念必须提前搞明白否则配置阶段会非常痛苦。3.1 引导JDKBoot JDK是什么OpenJDK的编译有一个很有趣的循环依赖问题JDK的很多工具比如javac是用Java写的编译这些工具需要一个已经可用的JDK。这个用来引导编译过程的JDK官方称之为Boot JDK。OpenJDK官方文档里对Boot JDK的版本有明确要求。以JDK 17的源码为例引导JDK的版本要求是JDK 16或更新版本。官方在make/conf/version-numbers.conf里有对应的配置项DEFAULT_VERSION_FEATURE不同分支的引导版本要求也会区别。简单说编译哪个版本的JDK引导JDK的版本不能低于它前一个版本。在实际操作中我发现一个经验引导JDK尽量用官方发行版不要用自己之前编译的版本否则出现问题很难判断是自己代码的问题还是引导JDK的问题。我第一次编译JDK 17时图方便用了系统自带的JDK 11做引导结果configure阶段直接报版本不匹配排查了好一阵子。3.2 系统依赖与工具链OpenJDK编译依赖的工具和库各个平台不太一样。就拿Linux环境来说你需要提前安装构建工具gcc、g、make、autoconf开发库freetype字体渲染、cups打印服务、libX11X Window系统、libasound2音频、libxrandr屏幕分辨率管理等其他工具zip、unzip、file、pkg-config之所以需要这些库是因为JDK不仅仅是纯计算逻辑它还包含了图形界面AWT/Swing、声音支持、打印服务等功能。即使你最终的目标环境不用图形界面默认构建配置仍然会尝试编译这些模块。我的建议是配置阶段不要急着禁用模块先把整体跑通后面需要精简了再说。3.3 硬件资源要求这是一个非常现实的约束。OpenJDK的编译是个重体力活C编译器的内存消耗非常大。我测试下来2核4G内存的云服务器编译JDK 17完整版本会很吃力大概率OOM建议只编译java.base等少数核心模块或者把并行度调低。4核8G内存可以完整编译但要做好等30分钟以上的心理准备。8核16G及以上比较理想的编译环境全量构建基本在10到20分钟以内。如果你用的是Windows环境且想用官方推荐的构建方式推荐开启WSLWindows Subsystem for Linux在Ubuntu里完成编译。如果你还是想直接用Windows本机编译也能支持但是需要安装Visual Studio的C工具链然后按照官方文档里的cygwin或msys2方案来配置整个过程要比Linux环境痛苦不少。4. 源码下载与版本选择4.1 获取源码的渠道OpenJDK的官方源码托管在 hg.openjdk.org Mercurial仓库同时也提供了Git镜像。官方Git镜像的地址是https://github.com/openjdk/jdk对于JDK 11及以后的主线仓库。如果你偏好用Git操作我推荐这样直接用git clone https://github.com/openjdk/jdk.git但要注意这个仓库默认分支是最新开发版通常对应下一个发布版本。如果想要具体发布版比如JDK 17需要切到对应的标签git checkout jdk-17.0.99或者直接拉对应分支git clone --depth 1 --branch jdk-17.0.99 https://github.com/openjdk/jdk.git--depth 1是为了浅克隆只拉取最新一次提交省时间省空间。完整的JDK仓库非常巨大包含了几十年的提交历史如果不需要查看历史记录浅克隆足够。4.2 版本分支怎么选OpenJDK每六个月发布一个新特性版本同时每季度发布一次安全更新。常见的版本选择策略LTS长期支持版本JDK 11、17、21都属于LTS对应jdk-11.0.x、jdk-17.0.x、jdk-21.0.x分支。如果你的目的是学习或者生产环境适配优先选LTS。最新特性版本比如JDK 22、23等能体验到最新语法和API但不建议生产环境直接用。主分支master开发版每天都在变适合看热闹不适合稳定学习。我的建议是选一个LTS版本深入研究。就以JDK 17为例它是目前使用最广泛的生产版本之一网上资料多遇到问题好找答案而且它足够新模块化、ZGC、虚拟线程预览等关键特性都已经具备或接近成熟。我还注意到OpenJDK官方对于不同版本下载提供的构建产物通常只保留了最近的几个版本比较老版本需要到Edison等第三方归档站去找这个对直接下载使用的人影响较大。但如果从源码编译则没有这个顾虑这也算是自己编译的一个额外优势。5. configure配置详解每个参数背后的门道进入源码根目录后第一步是运行configure脚本生成构建配置。这个脚本是整个构建系统的问路石它的参数决定了后面make做什么。5.1 最基础的配置命令直接在源码目录下执行bash configure首次运行时它会自动检测系统的工具链、库、CPU架构等。如果一切正常最终会生成一个build/配置名称/目录同时输出一份完整的配置摘要。配置名称通常是linux-x86_64-server-release这样的格式包含了平台、架构、JVM模式和优化级别。如果只是想快速测试常用配置如下bash configure --enable-debugrelease --with-jvm-variantsserver这个配置的含义是构建release版本、只构建Server模式JVM。Server模式是默认的生产模式它采用C2即时编译器偏向长时间运行的服务器应用做极致优化。有时候为了调试需要构建fastdebug版本bash configure --enable-debugfastdebugfastdebug会加入大量的断言检查assert、额外的日志输出和调试符号是本地分析JVM问题的利器。有几次线上有问题我在本地编译fastdebug版本加上-XX:PrintGCDetails一类的参数很快能定位问题。当然fastdebug的性能比release慢不少不能直接上生产。5.2 关键参数逐项解释以下是我在实际构建中觉得最常用的几个配置参数附带说明每个参数的实际用途参数作用我的建议--enable-debug设置调试级别release无调试、fastdebug带断言、slowdebug最慢带完整调试符号日常用release排查JVM问题用fastdebug--with-jvm-variants选择JVM模式server、client、minimal、zero等桌面或通用场景选server即可--with-boot-jdk指定引导JDK路径默认自动检测找不到时手动指定--with-native-debug-symbols是否生成native调试符号需要gdb调试时打开--with-native-debug-symbolsinternal--disable-headful禁用图形界面相关的模块服务器环境可以加能减少编译时间--with-freetype指定freetype库路径系统没装freetype时可以用--with-freetype/usr/local/freetype指定--with-zlib使用系统zlib还是内置zlib默认使用内置保持一致性--with-extra-cflags附加C编译flag比如加-marchnative做本机架构优化--with-jvm-features控制JVM的功能特性如zgc、shenandoah默认已经包含绝大多数按需增减举个例子如果你想在JDK 17里启用ZGC除了默认开启的之外还可以通过--with-jvm-featureszgc显式指定。不过实际上在平台支持的情况下JDK 17的ZGC默认就是开启的这个参数更多用在自定义裁剪时。还有一个值得留意的参数--with-target-bits用来指定是编译32位还是64位。现在的服务器基本都是64位环境默认就是64位一般不需要主动设置。5.3 configure阶段常见失败我第一次编译的时候经常挂在各种前置条件检测上。最典型的几种情况configure: error: No acceptable C compiler found in $PATH没有安装gcc或g需要先安装工具链。configure: error: Boot JDK is not available没装引导JDK或者路径不对。设置JAVA_HOME或者用--with-boot-jdk指定。configure: error: FreeType is required缺少freetype开发库。在Ubuntu上是libfreetype6-dev包。这些东西本质上都是环境依赖问题根据提示逐个安装即可没有太多技术含量但确实需要耐心。6. make编译实战从零构建完整JDK执行完configure后就进入编译阶段了。这里有一个容易被忽略的重要信息configure只是生成了配置文件和makefile并不编译任何东西。真正的编译从make命令开始。6.1 常用make命令最标准的全量构建命令是make images这个命令会编译完整的JDK镜像包括bin/下的工具java、javac、jar等、模块镜像lib/modules、运行时库等产物在build/配置名称/images/jdk目录下这就是一个可以直接使用的JDK。实际编译时可能需要指定配置。如果目录下只有一套配置make会自动识别。如果有几套不同的配置需要指定make images CONFlinux-x86_64-server-release另外还有一些常用的编译targetmake clean清理之前的编译产物。make hotspot只编译JVMhotspot模块增量修改C代码后的常规操作。make java.base-java-only只编译java.base模块的Java代码适合快速验证或测试Java层代码修改。make jdk只编译jdk各模块。make test运行测试套件。其中make hotspot是个高频操作。我在调试JVM源码时经常修改一个C文件然后只跑make hotspot几秒钟就完成增量编译效率比全量快非常多。6.2 控制并行度OpenJDK的构建系统默认会按CPU核心数开并行任务但全靠默认值有时候会出问题。比如在内存较小的机器上并行度太高容易导致内存耗尽或者编译器崩溃。这时候可以显式限制make images JOBS4JOBS是构建系统专用的并行度参数等价于make的-j选项。我的经验是每个编译任务大约需要1到2GB内存所以4核8G的机器用JOBS4比较稳妥8核16G可以放开了跑。另外OpenJDK的构建系统有个很好的设计它支持断点续编。如果编译过程中某个模块报错修复问题后重新执行同样的make命令构建系统会跳过已经完成的部分不会从头再来。这意味着你在改代码反复编译时不需要担心每次都全量构建。6.3 验证编译产物编译完成后先做一个快速验证./build/配置名称/images/jdk/bin/java -version正常情况下输出类似openjdk version 17.0.9 2023-10-17 OpenJDK Runtime Environment (build 17.0.90) OpenJDK 64-Bit Server VM (build 17.0.90, mixed mode, sharing)这说明JDK镜像构建成功。你还可以跑一个简单的Java程序验证echo public class Hello { public static void main(String[] args) { System.out.println(Hello OpenJDK); } } Hello.java ./build/配置名称/images/jdk/bin/javac Hello.java ./build/配置名称/images/jdk/bin/java Hello如果输出Hello OpenJDK就说明这个自产JDK从编译器到运行时都是正常的。6.4 最小化安装到系统构建出来的JDK是一个独立的目录理论上可以手动拷贝到任意位置使用。以/opt/jdk-17为例sudo mkdir -p /opt/jdk-17 sudo cp -r ./build/配置名称/images/jdk/* /opt/jdk-17/ # 配置环境变量 export JAVA_HOME/opt/jdk-17 export PATH$JAVA_HOME/bin:$PATH这里有个细节images/jdk下的目录结构已经非常接近官方发行版的目录结构包含bin/、lib/、conf/、legal/等目录直接拷贝到系统路径下即可使用不需要额外的转换。7. 编译安装过程中的高频坑与排查思路7.1 configure报错速查表报错信息原因解决方案No acceptable C compiler found缺编译工具链安装gcc、gUbuntu:apt install build-essentialBoot JDK is not available没找到引导JDK安装合适版本的JDK或--with-boot-jdk/path/to/jdkRequired tool pkg-config is missing缺pkg-configapt install pkg-configFreeType is required缺字体库apt install libfreetype6-devCUPS header files not found缺打印服务库apt install libcups2-devX11 headers not found缺X11开发头文件apt install libx11-dev libxext-dev libxrender-dev libxrandr-devALSA headers not found缺音频支持apt install libasound2-devCannot find java in PATH引导JDK的bin目录未在PATH添加export PATH$JAVA_HOME/bin:$PATH后重试configure: error: Unable to find the JDKJDK路径不对或者不是一个有效的JDK根目录确认路径下存在bin/javac文件这类问题的核心就一句话读懂configure的报错缺什么补什么。绝大多数环境问题都可以通过安装对应的开发包解决。7.2 make编译失败内存不足的典型场景在我进行编译测试的云服务器上内存不足导致的失败是最常见的。现象是这样的编译过程中某几个C文件编译特别耗时然后进程被OOM Killer杀掉或者GCC直接报internal compiler error。解决方案优先级控制并行度make images JOBS2增加swap空间如果临时测试可以用dd创建一个swap文件顶上。升级内存配置在真实服务器上长期做构建最低建议8G。另外一个内存大户是链接阶段。hotspot的C代码在链接成一个动态库时内存峰值经常冲到几个G。如果链接阶段总是挂掉可以尝试减少并行度让链接任务单独跑。7.3 C编译报错的排查技巧如果你修改过hotspot下的C代码编译报错时一定要看第一行错误不要被后面几百行模板错误吓到。OpenJDK里用了不少模板元编程特别是垃圾回收相关的代码一个模板实例化报错可能带出几十条相关错误信息。我的经验是先看第一个error:大多数情况下真正的根因在第一个错误里。如果是编译时宏相关的报错检查你的修改是否影响了预处理分支。如果是链接错误undefined reference基本是漏了某个符号的定义或者函数签名不匹配。7.4 编译时间优化建议全量编译一次JDK 17在比较好的机器上大约10分钟左右但如果机器一般可能要半小时以上。有几个优化手段使用ccache缓存编译结果OpenJDK官方支持--with-ccache选项启用后增量重复编译会快很多。关闭不必要的模块--disable-headful可以跳过图形、音频等模块的编译在服务器场景下很实用。只编译需要的target如果只是调试用make hotspot代替make images速度提升非常明显。使用较新的GCC/G版本老版本编译器编译相同代码的时间差距很大我实测在10.0以上的GCC比8.x快接近30%。7.5 现场实录一次慢编译导致的排查有一次我在本地编译JDK 17机器配置是8核CPU加8GB内存结果configure后运行make images时大量并行任务导致内存耗尽系统无响应。当时以为卡死了其实并没有。解决办法是强制重启后重新执行同样的命令这次预先用free -h查看了内存把JOBS降到了4编译就顺利完成了。这个过程的教训是如果你的机器比8G内存低或者并行度设置太高提前在配置阶段就要想好JOBS如果已经配置好了但没有限制并行度的入口也可以通过环境变量MAKEFLAGS控制。8. 从编译到二次开发你的源码能做什么编译安装只是第一步真正有价值的是基于这套环境做二次开发。8.1 修改JDK类库并生效如果你修改了Java层的类库代码比如java.base里的某个类编译后就能直接使用。举个例子假设你在src/java.base/share/classes/java/lang/String.java里加了一个方法执行make java.base-java-only然后使用编译出来的java命令运行测试程序就能直接调用新加的方法。这在阅读源码时做写Demo验证猜想的场景非常实用。8.2 修改JVM底层并观察效果如果你想观察JVM某个内部结构比如对象头布局、GC日志输出可以直接修改hotspot下的C代码加入自己的打点输出然后执行make hotspot。在JDK 17里我经常改hotspot/share/oops/klass.cpp或hotspot/share/gc/shared/collectedHeap.cpp这类文件加上一些printf或者log输出编译后在测试程序里观察输出行为。这里要注意的是hotspot模块编译出的libjvm.so体积很大几个G的调试版本都有编译时间也比Java层代码长不少。所以做C改动时尽量避免大范围改动每次只动一个文件增量编译速度会快很多。8.3 读懂JVM启动的完整链路有了可编译的源码你可以顺着java命令的入口一路看下去从src/java.base/share/native/libjli/java.cC语言的启动器到JVM的入口hotspot/share/runtime/java.cpp再到类加载、初始化、执行。这条链路一旦打通你对JVM如何启动的理解会远超任何一本Java虚拟机书籍带来的深度。9. 值得深入阅读的关键源码位置结合我自己的阅读经验给刚开始接触OpenJDK源码的人一份按图索骥的清单src/java.base/share/classes/java/lang/Object.java一切类的父类JNI方法大多在这里声明。src/java.base/share/classes/java/lang/ClassLoader.java类加载器的核心实现。src/java.base/share/classes/java/util/concurrent/ThreadPoolExecutor.java线程池的核心逻辑比任何博客上的解析都准确。src/java.base/share/native/libjava/Object.cObject对象的native方法入口。hotspot/share/classfile/classFileParser.cppJava类文件class文件的字节码解析器读JVM规范时对照这个文件看会非常直观。hotspot/share/interpreter/bytecodeInterpreter.cpp字节码解释器的核心循环。hotspot/share/gc/g1/g1CollectedHeap.cppG1垃圾回收器的主体逻辑。hotspot/share/runtime/thread.cpp线程生命周期管理。hotspot/share/opto/c2_compile.cppC2即时编译器的入口。这些文件不是一次能读完的。我的习惯是遇到一个具体问题就找一个具体文件精读而不是按目录顺序通读。比如研究内存泄漏就去翻GC相关的代码研究锁优化就去翻synchronizer.cpp。带着问题去读源码效率远超漫无目的的翻阅。另外OpenJDK的代码注释质量非常高很多注释直接把设计意图和限制条件写清楚了阅读时一定要重视注释它们能帮你避免陷入无谓的猜想。我个人在实际操作中体会最深的一点是编译一次OpenJDK本质上就是把Java开发者和JVM实现者之间的隔阂打穿了一个洞。在这之前JVM对我来说是个黑盒编译之后黑盒变成了灰盒至少出了问题知道去哪里看、怎么改。如果你也想深入理解Java生态最底层的这件事建议直接clone一份源码亲手编译一次。第一次可能会花掉大半天但后续每次增量编译只需要几分钟这门心思花得非常值。
返回列表