资讯详情

资讯详情

高效读懂JVM源码:普通Java工程师的HotSpot学习路径

很多Java程序员在JVM这条路上卡住不是不努力而是努力的方向不对。市面上讲JVM的书、视频、面试题一抓一大把但真正翻开过HotSpot源码的人少之又少。我自己的经历是从“背JVM知识点”到“用源码视角看JVM”中间隔的不是智商而是一套正确的方法。作为一个啃过HotSpot源码、用源码知识解决过线上问题的Java工程师这篇文章想把我摸索出来的高效学习路径完整分享出来。这篇文章不是教你怎么逐行读完几百万行C而是告诉你一个普通Java程序员如何用最少的成本、最短的时间从JVM源码中拿到最有价值的信息。无论你是准备面试、排查线上问题、还是想真正理解Java生态的底层逻辑这套方法都能让你少走很多弯路。1. 先想清楚读JVM源码到底在解决什么问题1.1 一个可能颠覆你认知的澄清JDK源码不等于JVM源码很多初学者拿着一本《深入理解Java虚拟机》就开始找源码结果下载OpenJDK源码后打开一个java目录看到的全是.java文件——Object.class、String.class、ArrayList.class的源码都在这里。这确实叫JDK源码但它不是JVM源码。真正的JVM实现在同一个源码包的hotspot子目录里全称HotSpot VM是用C写的。你日常配置的-Xmx、-Xms、-XX:UseG1GC这些参数真正干活的是HotSpot里的C代码。也就是说你看JVM内存模型、垃圾回收、类加载机制的底层不看C那部分就等于没看。这个区分很重要因为它决定了你搜索代码时该去哪找。我见过有人花了一个月啃java.util.concurrent的源码然后说“JVM源码也不难嘛”这不是一回事。搞混了学习对象路线全废。1.2 不同阶段的人读源码的期望值应该不一样读JVM源码之前先诚实评估自己处在哪个阶段这会决定你用什么策略。1-2年经验的开发主要目标是建立底层认知搞清楚堆、栈、方法区在真实VM里长什么样对象是怎么分配的。这个阶段的重点不是读通全部而是抓主干。3-5年经验的开发开始遇到线上性能问题、内存溢出、GC调优。此时读源码是为了精准定位比如“为什么我明明设置了-Xmx4G堆却涨到了4.5G才触发Full GC”。5年以上或架构师方向需要评估新特性、选型垃圾回收器、修改参数调优。这时源码就是你的决策依据和证据库。不同阶段投入产出比最高的读法完全不同。初级上来就想搞懂C2 JIT编译器的中间表示那是自找苦吃。反过来说如果你已经能独立排查OOM还在死背“CMS的Concurrent Mark阶段会发生什么”这种面试答案那也太亏了——源码里一行注释就能解释清楚的事你花几天记表格图什么1.3 读源码要付出的真实成本读JVM源码的时间成本很高远超读一般框架源码。一个HotSpot仓库的真实代码量是百万行级别。而且它是C写的充斥着宏定义、模板、条件编译——很多你想找的函数名字被宏改写过了直接搜字符串往往搜不到。这就意味着如果你用“通读”的方式去啃结局大概率是第一周兴致勃勃第二周头晕眼花第三周放弃。高效学习的核心原则不是勤奋而是做好规划、和“按需猎取”。你要把源码当成一本字典、一个证据库带着具体问题去查、去验证、去打通关节而不是从头读到尾。一句话总结读JVM源码的收益极高但只有用对了方法收益才会高于成本。2. 读源码前先把这几块地基打牢这章是我苦口婆心的重点。我在各种技术群里见过太多人问“C不会能不能读JVM源码”答案是可以但前提是你掌握了一些“最小必要知识”。没有这些地基你连入口函数在哪都找不到。2.1 C能力不需要精通但要会“猜”HotSpot的C代码风格非常特殊是上世纪90年代延续下来的老式C混合C的写法。你不需要会STL、不需要懂模板元编程但有几样东西必须能看懂指针和引用源码里全是this-、*p、obj底层一点的地方直接操作内存地址。虚函数和继承CollectedHeap有一堆子类GenCollectedHeap、G1CollectedHeap都是它的子类。看懂继承关系才能理解“一个API多种实现”。宏定义这是最容易劝退人的点。比如NOT_PRODUCT、PRODUCT这种宏会编译时展开或删除代码。还有#define大段代码的技巧你得习惯“代码里的代码”。DEBUG和ASSERT宏很多信息只在debug版本里存在正式版是空宏。你在源码里看到的很多函数体实际上生产环境编译后是空的。一个有效的策略是把C当“带类型的伪代码”来读。先跳过长得很吓人的宏和模板只关心函数名、参数和注释把调用关系理顺再回来看具体实现。你要的是理解机制不是去给HotSpot提代码合并。真到了某一行比如内存序、指令重排序要抠特别细时再回头补C知识也不迟。操作系统知识避不开。JVM说到底是一个运行在操作系统上的进程它的内存分配最终要调用系统调用它的线程最终要映射到内核线程。需要掌握的最小集合进程虚拟内存布局text、data、heap、mmap区域、栈。对照着看JVM参数里-Xmx、-Xss你会更直观。系统调用和内存映射JVM的TLAB之外的大块内存、元空间很多通过mmap来申请。理解mmap的大致机制你就能懂“为什么JVM刚启动时物理内存占用不大然后慢慢涨”。CPU缓存与伪共享这对理解并发性能至关重要。JVM里的Contended注解就是用来解决伪共享的源码里对应的实现逻辑在share/vm/runtime和share/vm/utilities下。我不是让你先去把《操作系统概念》啃完。你只需要找一个周末搜一下“进程内存布局”“mmap原理”“cache line”看懂示意图和简单的例子就够了。后面读JVM代码时你会频繁遇到这些概念到时再回头深化。2.3 字节码与编译原理的“最小必要知识”HotSpot的执行引擎本质上是一个字节码解释器外加JIT编译器。如果完全不懂字节码你会寸步难行。我推荐的最小知识树是这样的知道.java编译成.class字节码字节码是一组操作码加操作数。认识常见的操作码new、invokevirtual、invokestatic、getstatic、putfield、ifne、goto等。不用记全用到再查。知道方法体里的字节码存在Method对象的_code字段中运行时由解释器或JIT处理。了解解释执行和编译执行JIT的区别知道-Xint、-Xcomp、-XX:TieredCompilation大概控制什么事。掌握这些之后再看HotSpot解释器源码你会发现它就是一个大循环取字节码、查操作码表、跳转到对应的处理段、执行。一旦看懂这个循环JVM对你来说就不再是黑盒了。3. 第一个黄金切入点从new一个对象开始很多人的困惑是“源码那么多我到底从哪个文件开始读”我建议你从一个每天都在写的代码开始new一个对象。一条new语句背后串起来的是类加载、内存分配、对象访问定位三大核心机制。把这一个链路搞通JVM的半壁江山都摸到了。3.1 new在字节码层面的动作先用javap -c反编译一段最简单的代码public class User { private int age; public void setAge(int age) { this.age age; } }调用处大概是0: new #2 // class User 3: dup 4: invokespecial #3 // Method init:()V 7: returnnew字节码只是创建了一个对象实例并分配内存此时还没有调用构造函数紧接着的dup复制引用给构造函数用最后的invokespecial才是调用init做初始化。这段话是《深入理解Java虚拟机》里的经典内容但大多数人只把它当作“面试题解析”。它的价值远不止于此——它是你进入HotSpot源码的最佳入口。3.2 源码链路追踪从new到内存分配在HotSpot源码里new字节码最终会走到InterpreterRuntime::_new这个C函数。用调试器或代码搜索就能找到它在openjdk/hotspot/src/share/vm/interpreter/interpreterRuntime.cpp里。我来还原一下这段链路的要点解释器循环取出new操作码从操作码表查到对应入口进入模板解释器或C解释器的执行段。执行段最终调用InterpreterRuntime::_new这个函数先解析当前字节码里的类符号通过SystemDictionary检查类是否已加载。如果类还没加载触发类加载过程得到instanceKlass类元数据在JVM里的表示。类加载完成后从instanceKlass里拿到对象大小等信息然后调用CollectedHeap::mem_allocate或mem_allocate往上走分配流程。分配流程先看当前线程的TLABThread Local Allocation Buffer里有没有足够的空间。TLAB是JVM为每个线程在Eden区划出的一小块私有空间目的就是避免线程之间竞争同一块内存。TLAB空间够就直接从TLAB分配不够进入慢路径尝试从Eden区或其他区域分配。大对象会顺着PretenureSizeThreshold这个参数走直接分配到老年代。分配完成后返回对象指针解释器继续执行后面的dup和invokespecial。你看就一个new已经牵扯出instanceKlass、CollectedHeap、TLAB、Eden区、老年代、类加载。任何一个关键点展开都是一个大的源码模块。我强烈建议你第一次顺着这条链路走的时候不用追求每一行都看懂。你先做到“知道这里有个类加载”“知道这里有个TLAB判断”在大脑里建立起一张调用链地图然后再逐步深入。3.3 这个切入点为什么收益极高从new入手有三大好处覆盖面广一次追踪能带出内存分配、类加载、对象头、GC区域等核心概念。后续无论你是学GC、学JIT还是学锁优化都能从这条主干上分叉出去。与日常工作关联强你天天写new遇到OOM、频繁Full GC时你会想起TLAB分配失败、Eden不足这些场景排查问题时会快速有方向。文字资料多OpenJDK社区和各类博客对这条链路已经剖析得很透遇到难点时容易找到对照材料你不会卡死。技术上这个切入的思路也可以推广不要找抽象的概念做切入点要找具体字节码或具体操作做入口。比如从invokevirtual切入静态分派和动态分派从monitorenter/monitorexit切入锁升级链路从athrow切入异常表处理。每一个都是经典入口。4. 让JVM真正跑在自己电脑上编译与调试环境搭建读源码如果只靠“人眼静态阅读”效率低且容易误判。我强烈建议你把HotSpot编译出来用调试器跑起来。当你能够在一个Java方法上打断点、看到解释器一帧一帧地执行字节码时那种感觉完全不一样。4.1 前置准备与版本选型先说平台最稳妥的是Linux。我很不建议在Windows上折腾很多脚本和依赖会让你怀疑人生。如果你日常用Windows开个虚拟机或者搞一台Linux云主机都行。内存8G以上是底线编译OpenJDK特别吃内存4G机器很容易在编译过程中被OOM干掉。版本选型也要注意。直接拉最新的OpenJDK main分支编译难度会大一些而且代码变动较快。我更推荐选择jdk11u或jdk17u也就是11或17的维护分支。原因有三一是这两个版本是如今生产环境的绝对主流你学的东西能直接应用到工作二是维护分支相对稳定坑少三是网上针对这两个版本源码的解析资料最多搜的时候方便很多。源码获取方式也很简单从GitHub的镜像仓库拉取即可git clone --depth 1 -b jdk17u https://github.com/openjdk/jdk17u.git如果你用Mercurial也可以从OpenJDK官方仓库拉原理一样。4.2 编译参数与常见失败进入源码目录后先看看doc/building.html或doc/building.md这是官方构建指南。整个编译过程分为两步configure和make。**configure这一步是最容易出问题的。**建议直接开启debug模式这样源码里的断言、调试符号都能保留bash configure --with-debug-levelslowdebug如果你不想全量编译只想得到能调试的HotSpot解释器可以用--with-jvm-variantsserver然后只编hotspot目标make hotspot我第一次编译时整整折腾了一个下午主要踩了几个坑内存不足configure时提示Cannot allocate memory最终我调整了虚拟机的内存到16G。依赖缺失Linux上需要装freetype、fontconfig、libx11等。configure会明确提示缺什么挨个装上即可。版本不匹配gcc版本太老或太新都可能导致编译失败。我用的是Ubuntu 20.04自带的gcc 9没出问题。磁盘空间完整编译下来要20多G空间我只编译hotspot的话需求小很多。尽量别把仓库放在空间快满的分区上。编译完成后可以跑到build/*/jdk/bin/java下验证一下版本./build/linux-x86_64-server-slowdebug/jdk/bin/java -version看到一行openjdk version 17.0.x就说明你手上的HotSpot是可以调试的版本了。4.3 gdb调试HotSpot的实战操作调试HotSpot最常见的方案是用gdb。下面是一次典型操作先写一个测试类public class TestNew { public static void main(String[] args) { User u new User(); u.setAge(18); System.out.println(u.getAge()); } }编译成class后启动gdbgdb -ex break InterpreterRuntime::_new -ex run --args \ ./build/linux-x86_64-server-slowdebug/jdk/bin/java TestNew解释一下这条命令-ex参数让gdb在启动时先设置断点再运行。break InterpreterRuntime::_new在new字节码的运行时入口处停下。程序跑起来后遇到new User()就会中断。此时输入btbacktrace就能看到完整的调用栈从解释器到InterpreterRuntime::_new再到类加载、内存分配一眼就能理清楚。再配合print查看关键变量比如查看klass指向的类、查看size大小、查看heap状态等整个过程就非常直观了。不少人在这个环节陷入工具泥潭——一会儿嫌gdb不够图形化一会儿想折腾IDE远程调试。没必要。gdbbtprint三个命令已经能覆盖你90%的源码阅读需求。真到需要读大段JIT汇编时再考虑hsdis插件或者专用工具不迟。5. 沿线推进类加载链路与运行时数据区的源码对应搞定了new的起点之后下一步我建议沿着类加载和运行时数据区的线推进。这条线能让你把“JVM内存模型”从概念图变成真正的C数据结构。5.1 类加载的源码入口我们日常写的Class.forName(com.xx.User)或者JVM启动时加载主类最终会走到HotSpot的SystemDictionary和ClassFileParser。核心链路如下Java层的ClassLoader::loadClass是一个native方法调用。进入VM层后核心是SystemDictionary::resolve_from_stream它的职责是根据类名和字节流转成内部类结构。ClassFileParser::parseClassFile负责真正的字节码解析先检查魔数0xCAFEBABE再解析版本号、常量池、字段表、方法表、属性表。解析得到的类元信息保存为InstanceKlass这个结构体里包含了Java类的所有元数据——字段、方法、常量池、接口、注解等。链接过程由LinkClass等函数完成包括字节码验证Verifier、方法重写尤其是指令的常量池索引替换等。最后是初始化也就是执行clinit静态初始化块。建议你自己打断点看一下在ClassFileParser::parseClassFile处设置断点随便运行一个能加载类的Java程序然后单步往里走。你会直接看到一段一段的class文件二进制内容被解析成JVM内部结构所有概念都落地了。Java类加载的双亲委派模型在Java层面讲一套到了VM层你会发现真正干活的还是SystemDictionary。Java层的委派其实只负责“从哪里拿字节流”而“怎么把字节流变成类”是SystemDictionary和ClassFileParser的事。这两个层的职责搞清楚后你对双亲委派模型的理解会深一个层次。5.2 运行时数据区在源码中的“长相”《深入理解Java虚拟机》里的经典图——虚拟机栈、堆、方法区、程序计数器、本地方法栈——在HotSpot源码中是如何体现的我总结一下对应关系程序计数器在解释器里对应的是_bcpbytecode pointer它指向当前正在执行的字节码地址在JIT编译后的代码里则体现为CPU指令寄存器。不做JVM调优的人很少关心它但它在源码中无处不在。Java虚拟机栈对应JavaThread对象里面有_stack_base、_stack_size。每进入一个Java方法就压入一个栈帧JavaFrameAnchor或interpreted_frame结构。-Xss参数控制的就是这个栈大小。Java堆对应CollectedHeap及其子类。G1CollectedHeap、GenCollectedHeap旧的并行收集器都在这个继承树下。堆结构里有_eden区、_survivor区、_old区的具体地址范围。方法区/元空间对应Metaspace。JDK8之后永久代被移除方法区移到本地内存源码级别的实现就是Metaspace模块。类的InstanceKlass、常量池等都被分配在这里。本地方法栈对应os::create_thread相关调用链一般读者不需要深入。不要把这几块当成死记硬背的概念。建议你在gdb里打印一下CollectedHeap的子类对象看_eden_start、_top、_end这些字段的实际值。你会发现所谓Eden区就是一段连续的内存地址范围栈就是一块高位地址空间。这些东西肉眼看到之后什么“堆内存、栈内存”就不再是抽象名词了。5.3 读宏定义密集代码的技巧我在这块要多说几句因为这是初学者最大的痛点。HotSpot源码宏特别多随便打开一个头文件满眼都是#define、#ifdef、NOT_PRODUCT。直接读很容易崩溃。我自己总结了一些实用技巧先用grep -rn找到关键类定义先看类头注释了解这个类的核心职责。HotSpot的C代码注释质量很高很多几十年前的注释至今仍然准确。遇到不认识的宏不要硬猜。很多宏只是用来控制生产版和调试版代码的差异。搜索一下宏名很多带PRODUCT字样的意思就是“正式版中这段代码会被删掉”。用IDE或者编辑器的“跳转到定义”功能把宏展开看。我在CLion里展开过一次JVMCI_END之类的宏才彻底明白它们包裹的其实是标准函数入口。善用版本对照。最新的main分支很多代码已经改动过了初学者读老一点的稳定分支jdk17u网上已有大量现成的解析文章可以对照一定要善用这个资源。还有一点当你读一段代码很久都理不清时不要继续死磕。跳到调用它的地方去看或者去Github上搜一下是否有分析博客、视频。JVM源码毕竟是老牌热门话题大神的解析文章多到你读不完只是需要你去发现和筛选。6. 源码视角下的变现从面试到线上排查很多人学源码是为了面试也有很多人觉得“源码学到的东西用不上”。我以亲身经历告诉你源码知识在面试中的好感度极高在线上问题排查中更是能直接救场。6.1 面试题背后的源码逻辑举一个最常见的问题“JVM是如何判断对象可以被回收的”背答案的人会说“可达性分析”。但如果你读过源码你能说出更具体的细节HotSpot里通过GCBarrier和OopMap记录栈上的引用位置遍历时从GC Roots出发顺着对象引用关系图做标记。如果面试官再追问“那什么是GC Roots”懂源码的人能从JavaThread当前栈帧、SystemDictionary里的类加载器、JNIHandles等具体结构出发罗列得很详细。这种细节的差异面试官立刻就能感知到你的深度。再举一个例子“为什么在JDK 8之后字符串常量池从永久代挪到了堆”如果你只背面试题会答“因为永久代空间有限”。如果你读过JVM源码和JEP文档你知道更深层的原因永久代PermGen在实现上有太多限制元数据类元数据、方法元数据和字符串常量混在一起导致Full GC时元数据清理和压缩的效率不高且PermGen大小很难精确估计经常导致OOM。把它挪到堆后字符串常量池就可以享受年轻代、老年代GC的管理对象的生命周期管理更自然。这些在symbolTable.cpp和stringTable.cpp对应的实现中也体现得淋漓尽致。再比如volatile的可见性和内存屏障。源码层面就是通过OrderAccess、Atomic下的内存屏障函数来保证顺序。你看过源码之后再回答“volatile如何防止指令重排”能直接从CPU缓存的视角和GeneratedAssembler生成的汇编指令层面给出回答这绝对是面试中的高分亮点了。6.2 线上OOM排查的源码辅助决策这块我举一个我真实处理过的例子。某服务的GC日志里反复出现G1 Humongous Allocation意思是G1收集器在分配大对象超过Region的一半大小时直接进入老年代Region然后时不时触发Mixed GC。刚开始大家只知道“有大对象”但大对象为什么会频繁出现为什么GC压力那么大当时我对照着G1的源码看G1CollectedHeap::humongous_obj_allocate的实现逻辑发现一个很容易被忽视的细节大对象必须分配到连续的Region中而且每个Region只能放一个大对象容不下就回收。有些框架在读取数据时一次性分配了一个很大的字节数组即使后面立刻释放这个Region也已经被打上了“Humongous”标签GC时回收成本很高。顺着这个源码结论我们定位到业务代码中一个ByteBuffer.allocateDirect的大块分配点改造后GC频率直接下降了一个数量级。如果我当时没有源码知识我很可能还在调-XX:G1HeapRegionSize测试各种参数甚至考虑换垃圾回收器而不是从业务代码层面解决问题。这就是源码视角带来的决策能力。当然直接从问题跳到源码对初学者来说门槛较高。更务实的方法是先在GC日志中定位问题现象比如Humongous Allocation、Promotion Failed再到源码中搜关键词看这个现象的触发条件。逻辑是“现象 - 源码触发条件 - 反推业务代码”而不是“直接读GC模块源码”。6.3 给新手的学习路线建议如果让我给一个完整的“从零开始读JVM源码”的路线图我的建议是这样的先阅读《深入理解Java虚拟机》的前几章建立内存区域、GC、类加载的概念体系。搭好Chapter 4的编译调试环境确保能把断点打到InterpreterRuntime::_new上。从new一个对象开始把“对象创建与分配”这条主链路读熟。沿链路扩展碰到instanceKlass就读类加载碰到CollectedHeap就读GC基础架构。每读一个知识点就回到线上场景或面试题里找对应案例让知识落地。持续积累而不急于求成。即使你每天只花1小时坚持三个月你的JVM认知水平也会超过绝大多数面试者。因为你看到的不是结论而是结论的来源。我自己的体会是JVM源码阅读是一件“复利”很强的事。一开始很慢一个函数要查半天但一旦建立了主干链路地图后面每个新知识点都能很快挂到旧框架上。到了这个阶段那些曾经劝退你的宏、模板、指针反而成了你和其他开发者拉开差距的护城河。最后一句话送给你不要指望一口气读完HotSpot它是几百万行C代码打开它之前先想清楚自己要找什么然后带着问题进去带着收获出来。坚持一年之后你回头看会发现自己对Java的理解已经完全不一样了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →