上一篇【第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 自带的反汇编工具javap:
javap-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 类。
但有意思的是:即使是最简单的 HelloWorld,JVM 也不只加载一个类。想想看:
- HelloWorld 的父类是
java.lang.Object→ 要先加载 Object main(String[] args)的参数是 String 数组 → 要加载java.lang.String和String[]System.out.println()用到java.lang.System类 → 要加载 System- System 类里有个静态字段
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),这是它最重要的设计特征。什么意思呢?看这段代码:
inta=1+2;编译成字节码后是这样:
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 VM):
x86: 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、ch08 |
| JIT 编译器 | ❌ 不实现 | — |
| 垃圾回收器 | ❌ 不实现(用 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 安装与环境准备——工欲善其事必先利其器