news 2026/9/8 16:10:52

【DIY系列:Java虚拟机】第02篇:Java 虚拟机的工作原理——从 java HelloWorld 说起

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【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 自带的反汇编工具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 }

先不用完全看懂,注意几个特征:

  • 每行格式是偏移量: 指令名 操作数
  • getstaticldcinvokevirtual就是 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.StringString[]
  • 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 从源码到输出,走过了完整的五个阶段:

  1. 编译期:javac 把.java编译成.class字节码(JVM 的"汇编语言")
  2. 加载:通过类路径找到 class 文件,读入内存,生成 Class 对象
  3. 链接:验证字节码合法性 → 为静态变量分配内存并赋零值 → 符号引用解析为直接引用
  4. 初始化:执行<clinit>(),静态变量赋上真实初值
  5. 执行:执行引擎创建栈帧,通过操作数栈 + 局部变量表逐条执行字节码指令

JVM 的执行核心是栈式架构——所有计算都在操作数栈上进行。而运行时数据区则划分为线程共享的方法区/堆,和线程私有的 PC 寄存器/虚拟机栈/本地方法栈。

下一篇,我们开始动手前的最后准备:安装 JDK 和 Go 开发环境。工欲善其事,必先利其器——把环境搭好,才能愉快地写代码。


上一篇【第01篇】为什么我们要自己写 Java 虚拟机
下一篇【第03篇】JDK 安装与环境准备——工欲善其事必先利其器


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 16:10:35

腾讯云AI Skills实战:从工具调用到Agent高效编排的最佳实践

做 Agent 开发这一年多&#xff0c;我最大的感受是&#xff1a;真正让项目“活”起来的往往不是模型本身&#xff0c;而是工具调用这一层。模型再聪明&#xff0c;如果它不知道你的业务接口怎么用、不知道参数该传什么、返回结构一变化就没法处理&#xff0c;整个 Agent 就卡死…

作者头像 李华
网站建设 2026/9/8 16:10:03

单片机毕业设计-基于 STM32 的多生理体征声光语音报警终端设计 基于 STM32 的便携式人体健康监测设备设计与开发(013207)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/9/8 16:09:57

P3366最小生成树模板题详解:Kruskal与Prim实现与边界处理

1. 先看懂题目信息&#xff1a;P3366 究竟想让你做什么 1.1 题面信息剥开之后只剩一件事 洛谷P3366【模板】最小生成树&#xff0c;大概是很多算法竞赛选手写的第一道带“模板”标签的图论题。页面上的题目信息非常干净&#xff1a;给出一张无向图&#xff0c;有N个点&#xf…

作者头像 李华
网站建设 2026/9/8 16:08:32

Java 后端转 AI Agent 开发:2026 年学习清单与面试高频考点

第一章 先想明白:你转的到底是 "AI 开发" 还是 "Agent 工程" 很多 Java 后端第一次接触 AI,会把三件事混成一件事:调用大模型、RAG、Agent。这三者的技术含量和面试含金量差着好几个等级,混着学的人最后往往什么都学不深。 调用大模型是最底层的东西。…

作者头像 李华
网站建设 2026/9/8 16:07:42

Yii2 事件机制:EVENT_BEFORE_ACTION 的执行链路与工程实践

打开 Yii2 源码之前&#xff0c;我一直把EVENT_BEFORE_ACTION当成一个“约定俗成的钩子”&#xff1a;在behaviors()里挂上去&#xff0c;事件就能在action执行前自动跑一圈。直到有一次排查线上权限失效的问题&#xff0c;我才发现这玩意儿的底层真相&#xff0c;比我想象中要…

作者头像 李华