自制一台JVM虚拟机,本质上不是从零复刻HotSpot,而是实现一个能运行特定字节码子集的解释器,新手入门的核心路径,是掌握<关键词:class文件解析>、<关键词:字节码执行引擎>、<关键词:运行时数据区>和<关键词:类加载机制>这四块内容,先跑通“Hello World”,再谈性能。
别被“虚拟机”三个字吓住,说白了,JVM就是一台用软件模拟出来的电脑,它读入.class文件这种“机器码”,在自己的内存里分配对象、执行指令,自制一台迷你版JVM,是理解Java语言本质最快的方式。
JVM工作原理是什么?先看一个类的完整旅程
搞懂原理之前,先明确一个概念:JVM是规范,不是具体软件,Oracle的HotSpot、Eclipse的OpenJ9,都是这个规范的不同实现,自制JVM,写的是“另一个实现”。
把下面这段代码交给任何一台正常工作的JVM,它内部大致经历了这几个步骤:
public class Demo {
public static void main(String[] args) {
String text = "hello";
System.out.println(text);
}
}
- 第一步,装载:
Demo.class文件被类加载器找到,解析文件头部的魔数(0xCAFEBABE)确认这是一个合法字节码文件,然后读取常量池、方法表等结构。 - 第二步,链接与初始化:JVM给
Demo类分配好内存空间,把main方法的符号引用替换为直接引用,然后执行静态代码块。 - 第三步,执行:执行引擎开始逐条读取
main方法里的字节码指令,比如ldc指令负责从常量池加载字符串,invokestatic指令负责调用System.out.println方法。 - 第四步,内存回收:
text字符串用完即弃,后台的垃圾回收器(GC)择机清理它占用的堆内存。
这四步,就是一台JVM的核心工作流,自制JVM,本质上就是把这四步按自己的方式重新实现一遍。
如何自制JVM虚拟机?核心技术四件套拆解
这四件套不分先后,但踩坑难度是递增的,建议按顺序逐个击破。
第一关:吃透class文件格式(字节码的“语法书”)
这是整个自制项目的地基。.class文件是有严格二进制布局的,你不能跳过任何一个字节。
- 魔数与版本号:前4个字节是
0xCAFEBABE,后4个字节是编译版本号,版本号不对,JVM直接抛UnsupportedClassVersionError。 - 常量池(Constant Pool):这是class文件里的“档案馆”,所有类名、方法名、字符串字面量都在这里登记,每个常量项都有自己的tag标签(比如7代表Class引用,9代表Fieldref)。
- 字段表与方法表:描述类里有哪些成员变量、哪些方法,各自的访问标志(public/private)和描述符是什么。
- 属性表(Attributes):这是最灵活的区域。
Code属性存放方法体真正的字节码指令,StackMapTable属性供类型检查使用。
新手实操建议:不要一开始就手啃[JVM规范文档],直接买一本《深入理解Java虚拟机》读第二章节,或者用一个开源库(如Apache Commons BCEL)帮你解析class文件,你只需要读懂结构,不需要从零写二进制解析。
第二关:运行时数据区怎么分(JVM内存模型图解的简化版)
网上搜“JVM内存模型图解对比”,你会看到一堆堆、栈、元空间的图,自制迷你JVM不用那么复杂,但两块区域是必须的:
- 堆(Heap):所有
new出来的对象都住这里,你的JVM可以用一个简单的ArrayList模拟堆内存,每次创建对象就add进去。 - 虚拟机栈(VM Stack):每个线程一个栈,每个方法调用对应一个栈帧,栈帧里存着局部变量表、操作数栈和常量池引用。
这里有一个极易混淆的坑:JVM内存模型(JMM)和运行时数据区是两码事,前者讨论多线程下的可见性,后者讨论数据存哪儿,自制JVM初期,你只需要关心后者。
第三关:类加载与双亲委派,要不要自己写?
行业共识认为,自制JVM时,类加载器是最适合“偷懒”的部分,因为你的JVM大概率是跑在标准Java环境里的(用Java语言写的JVM),可以直接复用现有JDK的ClassLoader来帮你读文件。
但如果你想真刀真枪地练手,建议手写一个findClass方法:
- 读取
.class文件的字节数组。 - 调用
defineClass方法把字节数组转换成Class对象。 - 至于双亲委派模型,新手阶段可以不做,先保证“能加载”就行,“怎么安全地加载”是后面的优化方向。
第四关:字节码执行引擎,这是灵魂所在
执行引擎是JVM最“性感”的部分,你要写一个switch分支,对上200多个操作码(opcode):
iconst_0:把整数0压入操作数栈。istore_1:把栈顶的int存到局部变量表第1个槽位。getstatic:获取静态字段的值并压入栈。invokevirtual:调用实例方法。
反复看HotSpot源码分析,不如自己写一遍,网上能找到“Java字节码指令集大全”的表格,翻译成代码并不难,难点在于操作数栈的槽位宽度:long和double类型占两个槽位,处理不当会导致局部变量表错位。
一个务实的建议:先支持int类型和String类型,跑通方法调用、返回值和打印操作,你的JVM已经能运行“Hello World”了,布尔、数组、异常处理,属于进阶补丁。
实战路线:用Java写一个解释器型的JVM
到底用Java语言写还是用C++写?我推荐用Java写,原因很现实:
- 你能直接用IDE调试,断点打在字节码分发器上,一目了然。
- Java自带的垃圾回收机制,帮你省去了写内存管理器的地狱工作量,你只需要关注
new出来的对象如何在自己的虚拟堆里管理,而不用操心系统级的内存释放。
具体搭建步骤
Step 1:建立工程,定义核心数据结构。
public class MyClass {
private ConstantPool constants;
private Method[] methods;
private Field[] fields;
}
每个Method内部持有CodeAttribute,里面是一串byte[]存放原始字节码。
Step 2:实现字节码解析器。
从ClassFileParser开始,逐字节读入文件头、常量池、方法表,这一步先跑通“解析一个class文件并打印常量池内容”,会获得极强的正反馈。
Step 3:实现最简执行引擎。
核心是一个interpret(MyFrame frame)方法:
while (true) {
int opcode = code[pc++];
switch (opcode) {
case 0x10: // bipush
int value = code[pc++];
frame.getOperandStack().push(value);
break;
// 其它指令...
}
}
Step 4:打通“类加载→执行”的闭环。
在main方法里,先调用自己的类加载器加载Test.class,拿到字节码后,创建主线程的栈帧,调用interpret方法执行main方法,当执行到invokestatic指令时,递归创建新栈帧。
遇到Bug怎么办?打印“运行时栈”
这是调试JVM最笨也最有效的方法,在操作数栈的push和pop方法里加上日志输出:
[pc=15] bipush 100 -> 操作数栈: [100, 2, "abc"]
对照Oracle官方发布的[JVM指令集文档],逐条核对,你写出来的不是“眼花缭乱的Bug”,而是“哪条指令的行为偏离了规范”。
自制JVM需要多久?以及新手最容易踩的坑
聊点实际的。自制JVM需要多久? 对于每天能投入2小时的在校生或初级开发者,三到四周可以跑通基本功能;如果是周末时间充裕的上班族,一个半月属于正常周期。
这期间,有四个坑是需要特别留意的:
- 操作数栈溢出:方法调用嵌套太深(比如递归算斐波那契数列),你的JVM会栈溢出,这是正常的,说明你的栈帧结构写对了,只是没设置栈深度上限。
- 常量池索引越界:数组越界异常经常不是代码逻辑错了,而是你在解析某个指令时,读多了或读少了字节。排查思路:比对字节码偏移量
。
- 运行缓慢:这个不用太焦虑,行业共识认为,第一版自制JVM,执行效率比HotSpot慢上百倍都是正常的,你没有做JIT编译,相当于用JavaScript解释器去跑Java代码。
- 误把“简易实现”当“完整JVM”:不用非得支持include动态代理、注解处理器或SPI机制。边界控制力比功能完整度更重要,写一个README,标明“支持Java 8的int运算和字符串拼接”,比做一个什么都跑不动的半成品强得多。
自测清单:你的迷你JVM合格吗?
| 测试用例 | 目标能力 | 耗时预期 |
|---|---|---|
打印 Hello World | 类加载、main方法解析、字符串常量池 | 第1周达成 |
| 定义两个int变量并相加 | 局部变量表、算术指令、操作数栈 | 第2周达成 |
调用自定义的add(int,int)方法 | 方法栈帧切换、参数传递 | 第2周达成 |
| 创建自定义类对象并访问字段 | 对象模型(简单的堆分配) | 第3周达成 |
| 杀死一个自动消失的对象 | 极简标记-清除GC算法 | 第4周可选 |
高频问题与手写JVM的实战价值
手写一个JVM,对实际工作有什么帮助?
帮助极大但要放平心态,你写完发现,自己并不会因此更懂Spring Boot,但站在JDK源码排查的角度,你比普通业务开发者多了一层底气:当生产环境报出OutOfMemoryError时,你能直观地想象出堆内存里的对象是如何排列的;当类冲突异常(NoClassDefFoundError)出现时,你能立刻意识到这是类加载器的层级问题。
JVM性能调优,和自制JVM有多大关系?
关系有限,自制JVM解决的是“原理问题”,调优解决的是“工程问题”,但有一个明显的隐性收益:为了让自己写的解释器跑得更快,你会去研究公共子表达式消除、逃逸分析的具体行为,这时候回看JVM调优参数-XX:+PrintCompilation,输出结果对你不再是乱码。
想深入学习,应该看HotSpot源码还是看这本书?
先用“写”代替“看”,当你用Java写完第一版解释器后,心里对JVM的行为有了底,再打开OpenJDK 17(近年来的长期支持版本)的HotSpot源码,重点看src/hotspot/share/interpreter目录下的BytecodeInterpreter.cpp,你会惊喜地发现,网络上一幅“JVM工作原理是什么”的示意图,此刻变成了你手里跑过的代码路径,这种共鸣,是任何博客和付费课程都给不了的激励。
自制JVM的本质,是用一段时间的“慢”,换取底层逻辑的“透”,动手写,解决所有焦虑。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/627828.html





