【DIY系列:Java虚拟机】第02篇:Java 虚拟机的工作原理——从 java HelloWorld 说起
上一篇【第01篇】为什么我们要自己写 Java 虚拟机下一篇【第03篇】JDK 安装与环境准备——工欲善其事必先利其器摘要每个 Java 程序员都写过 Hello World但很少有人能说清当你在命令行敲下java HelloWorld并按下回车到屏幕上出现那行文字中间到底发生了什么本文把这个过程完整拆解成五个阶段javac 编译生成字节码 → JVM 启动并加载类 → 链接验证/准备/解析→ 初始化 → 执行引擎逐条执行字节码指令。你会看到 class 文件长什么样、类加载的三个阶段各自干了什么、操作数栈和局部变量表如何配合、以及 HotSpot 的整体架构。这是动手写 JVM 之前的地图。一、从 HelloWorld 说起一行命令背后的故事先从最经典的程序开始publicclassHelloWorld{publicstaticvoidmain(String[]args){System.out.println(Hello, world!);}}让它跑起来只需要两步# 第一步编译生成 HelloWorld.classjavac HelloWorld.java# 第二步运行javaHelloWorld# 输出Hello, world!朴素的两行命令背后却跑完了一整套复杂的流水线【HelloWorld 从源码到输出的完整流程】 ① 编译期 (javac) ┌──────────────────┐ │ HelloWorld.java │ 人类可读的源码 └────────┬─────────┘ │ javac 编译词法/语法/语义分析 字节码生成 ▼ ┌──────────────────┐ │ HelloWorld.class │ JVM 可读的字节码 └────────┬─────────┘ │ ② 运行期 (java) │ java 命令启动 JVM ▼ ┌────────────────────────────────────────┐ │ JVM 启动加载 HelloWorld │ │ ┌──────────────────────────────────┐ │ │ │ 加载 (Loading) │ │ 找到 class 文件读入内存 │ │ ↓ │ │ │ │ 链接 (Linking) │ │ │ │ ├─ 验证 Verify │ │ 检查字节码是否合法 │ │ ├─ 准备 Prepare │ │ 给静态变量分配内存并赋零值 │ │ └─ 解析 Resolve │ │ 符号引用 → 直接引用 │ │ ↓ │ │ │ │ 初始化 (Initialization) │ │ 执行 clinit赋真实初值 │ └──────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────┐ │ │ │ 执行引擎调用 main() 方法 │ │ │ │ 创建栈帧 → 逐条执行字节码指令 │ │ │ └──────────────────────────────────┘ │ └────────────────────┬───────────────────┘ ▼ Hello, world! 看懂这张图你就算是对 JVM 的工作流程有了全局认知。下面逐个阶段展开。二、编译期javac 做了什么javac是 Java 的编译器它把.java源码翻译成.class字节码。这里有个关键认知javac 输出的不是机器码而是 JVM 的汇编语言——字节码bytecode。字节码之所以叫字节码是因为它的每条指令只占 1 个字节u1 类型后面跟 0 到多个操作数。我们来看看 HelloWorld 编译后的字节码长什么样。用 JDK 自带的反汇编工具javapjavap-cHelloWorld.class输出大致是这样public class HelloWorld { public HelloWorld(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object.init:()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello, world! 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return }先不用完全看懂注意几个特征每行格式是偏移量: 指令名 操作数getstatic、ldc、invokevirtual就是 JVM 的指令操作码#2、#3是常量池索引——字符串、类名、方法名这些字面量都存在常量池里指令里只存一个索引号重点class 文件是 JVM 的通用语。不管你用 Java、Kotlin 还是 Scala 写的代码只要能编译成符合规范的 class 文件JVM 就能执行。这就是一次编译到处运行的本质。三、类加载把 class 文件搬进内存java HelloWorld这条命令首先会启动 JVM然后加载 HelloWorld 类。但有意思的是即使是最简单的 HelloWorldJVM 也不只加载一个类。想想看HelloWorld 的父类是java.lang.Object→ 要先加载 Objectmain(String[] args)的参数是 String 数组 → 要加载java.lang.String和String[]System.out.println()用到java.lang.System类 → 要加载 SystemSystem 类里有个静态字段out类型是PrintStream→ 还要加载java.io.PrintStream一个 HelloWorld 背后JVM 可能要加载上百个类。类加载的三个阶段类加载的完整过程分三步【类加载的三个阶段】 ┌─────────────────────────────────────────────┐ │ ① 加载 (Loading) │ │ · 通过类的全限定名获取二进制字节流 │ │ · 把字节流转化为方法区的运行时数据结构 │ │ · 在堆中生成 java.lang.Class 对象 │ └──────────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────────┐ │ ② 链接 (Linking) │ │ ├─ 验证 (Verification) │ │ │ · 文件格式验证魔数、版本号对不对 │ │ │ · 元数据验证有没有继承 final 类 │ │ │ · 字节码验证跳转指令合法吗 │ │ │ · 符号引用验证引用的类存在吗 │ │ │ │ │ ├─ 准备 (Preparation) │ │ │ · 为类变量(static)分配内存 │ │ │ · 设置零值不是你写的初值 │ │ │ 例static int a 123; │ │ │ 准备阶段后 a 0 │ │ │ │ │ └─ 解析 (Resolution) │ │ · 常量池中的符号引用 → 直接引用 │ │ 例java/lang/Object → 真实的类指针 │ └──────────────────┬──────────────────────────┘ ▼ ┌─────────────────────────────────────────────┐ │ ③ 初始化 (Initialization) │ │ · 执行 clinit() 方法 │ │ · clinit 由编译器自动收集 │ │ - 所有 static 变量的赋值语句 │ │ - 所有 static 代码块 │ │ · 例此时 a 才真正变成 123 │ └─────────────────────────────────────────────┘重点准备阶段和初始化阶段容易混淆。记住准备阶段赋零值0/null/false初始化阶段才赋你写的值。双亲委派模型JVM 用三层类加载器来加载类它们之间是父子关系类加载器加载路径例子Bootstrap ClassLoader启动类加载器$JAVA_HOME/jre/lib下的核心类库java.lang.*、java.util.*Extension ClassLoader扩展类加载器$JAVA_HOME/jre/lib/ext扩展包Application ClassLoader应用类加载器用户 classpath你写的 HelloWorld双亲委派的工作方式是一个类加载器收到加载请求先不自己加载而是层层向上委派给父加载器父加载器搞不定才自己尝试加载。这么做的好处是保证核心类库的安全性——你自己写个java.lang.String也没用因为 JVM 只会加载 Bootstrap 加载的那个正牌 String。四、执行引擎解释器如何执行字节码类加载完成后JVM 就要调用 main() 方法了。这一步由执行引擎完成。JVM 是栈式架构Stack-based这是它最重要的设计特征。什么意思呢看这段代码inta12;编译成字节码后是这样0: iconst_1 // 把常量 1 压入操作数栈 1: iconst_2 // 把常量 2 压入操作数栈 2: iadd // 弹出两个值相加结果压回栈 3: istore_1 // 弹出栈顶值存入局部变量表 slot 1执行的全过程中操作数栈和局部变量表的配合是关键【1 2 的字节码执行过程栈帧视角】 指令 操作数栈 局部变量表 ───────────────────────────────────────────────── (初始) [ ] slot0: args slot1: (空) iconst_1 [ 1 ] slot0: args slot1: (空) iconst_2 [ 1, 2 ] slot0: args slot1: (空) iadd [ 3 ] slot0: args ← 弹出 1,2压入 3 slot1: (空) istore_1 [ ] slot0: args ← 弹出 3存入 slot1 slot1: 3 ✅看到没操作数栈就是 JVM 的草稿纸所有计算都在栈上进行指令从栈顶取操作数算完再压回栈顶。这就是栈式虚拟机的含义。对比一下基于寄存器的架构比如 x86 汇编、Lua VMx86: mov eax, 1 ; 把 1 放进寄存器 eax add eax, 2 ; eax eax 2 mov [ebp-4], eax ; 存到内存 JVM: iconst_1 ; 压栈 iconst_2 ; 压栈 iadd ; 弹出、相加、压回 istore_1 ; 存到局部变量表栈式架构的优势是指令短小、实现简单、可移植性好不用操心寄存器分配劣势是同样的计算需要更多条指令。HotSpot 后来用 JIT 编译器把热点字节码编译成机器码就弥补了这个劣势。重点理解了操作数栈 局部变量表你就理解了 JVM 指令集的灵魂。本系列后面实现 150 多条指令本质上就是在反复操作这两个结构。解释器 vs JIT 编译器JVM 执行字节码有两种方式方式工作原理优点缺点解释器逐条读取字节码翻译成机器码并执行启动快、内存占用小、实现简单执行慢每次都要翻译JIT 编译器把热点代码频繁执行的方法/循环整体编译成本地机器码缓存起来执行快接近 C编译耗时、内存占用大、实现复杂HotSpot 用的是混合模式Mixed Mode默认用解释器执行同时对热点代码做 profiling达到一定阈值后触发 JIT 编译。我们的 jvmgo 只实现解释器——JIT 编译器的复杂度是另一个量级不适合入门。五、运行时数据区全景执行字节码时JVM 需要各种内存区域来存放数据。这些区域统称运行时数据区Run-Time Data Areas。【JVM 运行时数据区】 ┌──────────────────────────────────────────────────────┐ │ 线程共享区域 │ │ ┌────────────────────────────────────────────────┐ │ │ │ 方法区 (Method Area) │ │ │ │ · 类信息版本/字段/方法/接口 │ │ │ │ · 运行时常量池 │ │ │ │ · 静态变量 │ │ │ │ · JIT 编译后的代码 │ │ │ │ ↑ JDK8 以前叫永久代JDK8 叫元空间 │ │ │ └────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────┐ │ │ │ 堆 (Heap) │ │ │ │ · 几乎所有对象实例都在这里 │ │ │ │ · GC 的主战场 │ │ │ │ · 分代新生代(Eden/S0/S1) 老年代 │ │ │ └────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────┐ │ 线程私有区域 │ │ │ │ Thread 1 Thread 2 │ │ ┌───────────────────┐ ┌───────────────────┐ │ │ │ PC 寄存器 │ │ PC 寄存器 │ │ │ │ (当前指令地址) │ │ │ │ │ ├───────────────────┤ ├───────────────────┤ │ │ │ Java 虚拟机栈 │ │ Java 虚拟机栈 │ │ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ │ │ │ Frame 栈帧 │ │ │ │ Frame 栈帧 │ │ │ │ │ │ ┌─────────┐ │ │ │ │ ┌─────────┐ │ │ │ │ │ │ │局部变量表│ │ │ │ │ │局部变量表│ │ │ │ │ │ │ ├─────────┤ │ │ │ │ ├─────────┤ │ │ │ │ │ │ │操作数栈 │ │ │ │ │ │操作数栈 │ │ │ │ │ │ │ └─────────┘ │ │ │ │ └─────────┘ │ │ │ │ │ ├─────────────┤ │ │ ├─────────────┤ │ │ │ │ │ Frame 栈帧 │ │ │ │ Frame 栈帧 │ │ │ │ │ └─────────────┘ │ │ └─────────────┘ │ │ │ └───────────────────┘ └───────────────────┘ │ │ │ │ 本地方法栈 (Native Method Stack) —— 为 JNI 服务 │ └──────────────────────────────────────────────────────┘各区域的核心要点区域线程共享存什么会 OOM 吗会 GC 吗PC 寄存器私有当前执行的字节码指令地址❌ 不会❌虚拟机栈私有栈帧局部变量表/操作数栈/动态链接/方法出口StackOverflowError / OOM❌本地方法栈私有Native 方法调用StackOverflowError / OOM❌堆共享对象实例、数组✅ OutOfMemoryError✅ 主要战场方法区/元空间共享类信息、常量、静态变量✅ OOM元空间溢出✅ 类卸载**栈帧Frame**是虚拟机栈的基本单位——每调用一个方法就创建一个栈帧并压栈方法执行完栈帧弹出。栈帧里最重要的是两块局部变量表Local Variables存放方法参数和方法内定义的局部变量用槽位Slot索引访问操作数栈Operand Stack执行指令时的临时工作区就是前面说的草稿纸六、HotSpot 的整体架构把前面所有内容整合起来就是 HotSpot JVM 的完整架构【HotSpot JVM 整体架构】 ┌──────────────────────┐ ┌──────────────────────┐ │ .java 源文件 │ │ .class 字节码 │ └──────────┬───────────┘ └──────────┬───────────┘ │ │ │ javac 编译 │ └──────────────► ───────────────┘ │ ╔═════════════════════════════════════════▼══════════════════════════════╗ ║ JVM (HotSpot) ║ ║ ║ ║ ┌─────────────────────────────────────────────────────────────┐ ║ ║ │ 类加载器子系统 (Class Loader) │ ║ ║ │ Bootstrap → Extension → Application (双亲委派) │ ║ ║ │ 加载 → 链接(验证/准备/解析) → 初始化 │ ║ ║ └───────────────────────────┬─────────────────────────────────┘ ║ ║ │ ║ ║ ▼ ║ ║ ┌─────────────────────────────────────────────────────────────┐ ║ ║ │ 运行时数据区 (Runtime Data Area) │ ║ ║ │ 方法区 │ 堆 │ 虚拟机栈 │ PC寄存器 │ 本地方法栈 │ ║ ║ └───────────────────────────┬─────────────────────────────────┘ ║ ║ │ ║ ║ ▼ ║ ║ ┌─────────────────────────────────────────────────────────────┐ ║ ║ │ 执行引擎 (Execution Engine) │ ║ ║ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ ║ ║ │ │ 解释器 │◄──►│ JIT 编译器 │ │ 垃圾回收器 │ │ ║ ║ │ │Interpreter│ │ C1 / C2 │ │ G1/ZGC/... │ │ ║ ║ │ └──────────┘ └──────────────┘ └──────────────┘ │ ║ ║ └───────────────────────────┬─────────────────────────────────┘ ║ ║ │ ║ ║ ▼ ║ ║ ┌─────────────────────────────────────────────────────────────┐ ║ ║ │ 本地方法接口 (JNI) 本地方法库 │ ║ ║ └─────────────────────────────────────────────────────────────┘ ║ ╚═════════════════════════════════════════════════════════════════════════╝ │ ▼ ┌──────────────────────────┐ │ 操作系统 硬件 │ └──────────────────────────┘对照这张图我们 jvmgo 的实现范围就清楚了HotSpot 组件我们的 jvmgo 实现吗对应章节类加载器子系统✅ 实现简化版ch02、ch06运行时数据区✅ 实现ch04解释器 指令集✅ 实现 150 条指令ch05、ch06、ch07、ch08JIT 编译器❌ 不实现—垃圾回收器❌ 不实现用 Go 的 GC—JNI⚠️ 用 Go 替代实现ch09多线程❌ 不实现单线程—重点我们的 jvmgo 是单线程、无 GC、纯解释执行的迷你 JVM。这三块是 HotSpot 最复杂的部分砍掉之后剩下的核心骨架就清爽得多了——而这恰恰是理解 JVM 原理最需要的部分。本篇小结一个 HelloWorld 从源码到输出走过了完整的五个阶段编译期javac 把.java编译成.class字节码JVM 的汇编语言加载通过类路径找到 class 文件读入内存生成 Class 对象链接验证字节码合法性 → 为静态变量分配内存并赋零值 → 符号引用解析为直接引用初始化执行clinit()静态变量赋上真实初值执行执行引擎创建栈帧通过操作数栈 局部变量表逐条执行字节码指令JVM 的执行核心是栈式架构——所有计算都在操作数栈上进行。而运行时数据区则划分为线程共享的方法区/堆和线程私有的 PC 寄存器/虚拟机栈/本地方法栈。下一篇我们开始动手前的最后准备安装 JDK 和 Go 开发环境。工欲善其事必先利其器——把环境搭好才能愉快地写代码。上一篇【第01篇】为什么我们要自己写 Java 虚拟机下一篇【第03篇】JDK 安装与环境准备——工欲善其事必先利其器