就简单写个 JVM 吧
前话
好久没有敲过代码了,对整个程序世界都异常的陌生。过去学得太多也太杂(前端后端啦,底层啊,应用啦,系统啊,数据啦,架构啊……),单就编程语言就几十种,导致心智负担特别重,虽说开拓了眼界,但是维护和升级确实已经远超出我能掌控的范围。所以只能随之年龄的增长和社会工作的需求环境,选择一个方向去。
这段时间因为没有工作,而且也晓得找工作的困难,所以只能以社会大形式是 Java 我就 Java 去苟活。重拾 Java 要找个练手的玩意儿,就简单用 Java 写个 JVM 来试试手。
JJVM 项目
Github 地址: https://github.com/chongwish/jjvm
该项目使用的是 Java 语言,我花了两周了解 JVM 并只用 11 个 Java 文件实现的,虽然说基本什么语言都可以来实现 JVM,但在 JDK 11 后引入的系统包都在 jrt 里面,使用 Java 就有绝对的方便为优势了,而且项目的目的就是为了重拾 Java 和了解学习 JVM 的,当然最大的目的还是 Just for Fun。
区别于现在 Github 上面的一些 Java 实现,具有的优势:
- 支持 JDK 8 以上(Github 上面大多只能 8 或 6 或特定版本的 JDK,运行限制很大)
- 小巧玲珑,所以更适合浏览和学习(Github 上面大多是同一个模子的项目,这里是提供另外的实现参考)
- Linux/Mac/Windows 都可运行和测试
实现
需要说明的是 JJVM 里面的实现,内嵌类用的比较多,因为很多时候是为了区分类,而又不想其他外部类可以调用。
解析 .class 文件
参考 JJVM 的 Classfile.java。
通过 javac
或者其他 JVM 上语言(如 scala)的 compiler (如 scalac
)编译成 JVM 可以运行的 class,都要遵循 JVM 的 Spec。所以解析它们,只要知道一个 .class 文件的结构就可以了。
JJVM 是这么来分层解析数据的:
Information: class 的主体信息,包括版本号,名字等
├── Constant Pool: 静态池部分
├── Field:字段部分
├── Method:方法部分
└── Attribute: 属性部分
读 .class 内容的方式
JVM 已经规范每个需要的数据该如何去读。
读的大小固定,大端的 8bit,16bit,32bit,所以我们可以用现成 ByteBuffer
的 getByte
,getChar
,getInt
。
读的格式也基本固定,大致分为:
- 顺序读取,如第一个就是读取 32bit 的魔法数,读完就可以读两个 16bit 的版本号。
- 先顺序读取大小值,然后用读取该值大小的数据。
Constant Pool 部分
这里的静态池是保存在 .class 文件里面的一些数据,并不是 JVM 的运行时静态池,且需要注意的是里面保存的 UTF-8
是 Modified UTF-8
的非标准 Unicode。最终这个解析完的池是个类似字典的东西,所有名字或者浮点数等都会往里面查找,如果你是用数组记录的话,要记得记录的索引是类似 int32[]
组织的,所以面对 long
等 int64
的数据,要占用多一个索引。
Attribute 部分
这部分是最多数据结构的,不同虚拟机的实现也可以在这里扩展自己的属性,但是最基本的,只需要关心里面的两部分:静态值属性和 Code 属性。Code 属性的数据结构里面就有 bytecode。
Method 和 Field 部分
这里记录一个类当前的静态和实例的方法和字段,详细的内容是通过 Attribute 和 Constant Pool 部分来的。
存储运行时数据
参考 JJVM 里面的 RuntimeDataArea.java 和 Frame.java。虽然 Frame.java 可以作为 RuntimeDataArea.java 文件里面的一个内嵌类,但因为 JVM 的 Spec 是将他们分开的,所以我也就分开来组织。
由于使用的是 Java, 所以这里的存储数据是以逻辑形式去划分各种区域,并非是真实划分物理内存。
组织结构
运行时的数据分为线程占有的和内存共享,他们都可以借助类的静态 Map 来实现,内存共享直接按标识在 Map 里面查找,线程占有的则是将数据结构再包裹一层线程类以达到目的。
├── ThreadResource:线程的包裹类
│ ├── ProgramCounterRegister:程序计数器,但是没用,使用 Bytecode 内置计数器来替换
│ ├── NativeMethodStack:原生方法的栈,但没使用
│ └── JavaStack:Java 的栈
│ └── Frame:桢
│ ├── OperandStack:操作数的栈
│ └── LocalVariable:变量的数组
├── Heap:堆区
│ ├── Instance:实例对象
│ └── ArrayInstance:数组实例对象
└── MethodArea:方法区
├── Method:方法的数据
├── Field:字段的数据
├── Clazz:类
├── ArrayClazz:数组的类
└── RuntimeConstantPool:运行时静态池
Heap 和 MethodArea 主要是如何保存数据,举例来说,JJVM 是每个解析完的 Class 都会有一个 Clazz 类的实例来记录该 Class 的信息,Clazz 里面会有 Method[] 和 Field[] 来保存对应的方法和字段,然后在 Java 里面 new 这个 Class 后,这个实例的信息就保存在 Instance 的一个实例中,至于 RuntimeConstantPool 其实跟 Classfile.java 里面的静态池也差不多,区别在于 RuntimeConstantPool 自己的记录结构能保存更多信息。所以对于 Heap 和 MethodArea 来说,JVM 并没有规定它该如何实现,我们要做的,是怎么样记录好 Java 里面产生的信息,怎么样容易找到它们。
需要说明的是,数组的组织结构和普通类的组织结构在 Java 里面是不一样的,数组是没有实际对应的 Java Class,而是 JVM 动态生成的。而且因为 Java 的每个类对象都有一个 Class 实例,所以要实现的 Instance 会含有 Clazz 类的信息,同样,Clazz 类也需要能记录一个 Java Class 的实例,也就是 Clazz 需要记录一个 Instance,这是一个双向依赖的关系。
JavaStack 相较于前面的 Heap 和 MethodArea 来说,侧重的不在于记录和寻找,而在于处理,也就是方法的执行。处理的单位为 Frame,每个 Frame 就是一个方法,但是因为涉及到各种跳转,实际 Frame 的实现包含的不止只有 bytecode(JJVM 是用一个 ByteCode 类来包裹 bytecode 并记录位置作为 ProgramCountRegister 的代替),这个要看个人实现。Frame 里头是靠 OperandStack 栈和 LocalVariable 数组来处理局部变量,它们处理的数据格式有 8bit,16bit,32bit,面对 64bit 的 long 和 double 都得多占用一个索引空间。
加载 .class 文件
参考 JJVM 里面的 Classloader.java 和 Classpath.java。
去哪里找 .class 文件
Classpath 可以分为三种,在 JDK 11 之前可以在项目和安装目录找到对应的路径加载,在 JDK 11 及以后,系统类不存在那路径里面的 jar 包了,而是在 jrt 里面。
Classpath 我们只需要知道他的任务是找到类,怎么实现不重要,从给出的命令行(如 -cp
),jar 和 zip 包,目录或 JVM 里头找到 .class 就行。
如何加载 .class 文件
Classloader 主要是通过 Classpath 定位到 .class 文件,使用 Classfile 解析后信息保存到 RuntimeDataArea 中类的加载有三个过程:
- Load:使用双亲委托读取一个类(动态生成的数组还是有实际的类文件)
- Link:信息记录类到 RuntimeDataArea
- Initialization:对 RuntimeDataArea 里面有需要的类进行初始化,其实就是修改 Clazz 里面对应 Method 的值,或者给 Clazz 一个 Java Class 的 Instance,并且只能有一次
而在这里还要有一个特殊的机制,预先自动加载 Object
和 Class
两个系统类,然后自动加载基本类型类(void
,byte
等)。
解释器和指令
参考 JJVM 里面的 Interpreter.java 和 Instruction.java。
解释器做的东西非常纯粹,就是执行 Frame 里面的被 ByteCode 包裹的字节码,过程非常简单,取出 ByteCode 当前 8bit 作为指令去调用,每条指令自己会相应操作掉 ByteCode 的操作数,剩下的 ByteCode 继续以当前 8bit 作为指令,直到全部执行完毕就 Drop 当前 Frame,假如 JavaStack 空了,说明程序完成。
指令部分可以在 JVM Spec 看到全部的规范,所以可以对着规范实现,两百多条指令分为 11 大类,但功能不外乎就是对 Frame,ByteCode,RuntimeDataArea 的操作,6 到 7 成的实现特别简单,而且大部分指令都是不同类型的相同操作。这里需要说的一点是,字符串字面量是需要自己处理成对象,不管是在 LDC 或者是函数调用的时候,而且字符串的对象里面是用数组来保存值的,需要一 ArrayInstance 来保存,JDK 8 和 JDK 14 一个是 byte[],一个是 char[],这也是需要注意的。
有几个相对麻烦的指令:
- tableswitch
- lookupswitch
- getstatic/putstatic
- getfield/putfield
- invokevirtual
- invokespecial
- invokestatic
- invokeinterface
- athrow
- checkcast
- wide
- multianewarray
- ldc/ldc_w/ldc2_w
原生方法
参考 JJVM 里面的 NativeMethod.java。
原生方法并不是通过指令运行的,所以要自己实现,遇见了就以函数签名去找到自己的实现然后执行,但是 JVM 里面的原生方法特别多,有些是必须要实现的,不然即使是简单的代码也是无法运行的,这里给出 JJVM 要运行 JDK 8 到 JDK 14 一些简单方法所需要的实现,这些实现大多是需要我们操作 OperandStack 和 RuntimeDataArea:
- java/lang/Class.registerNatives
- java/lang/System.registerNatives
- jdk/internal/misc/VM.initialize
- sun/misc/VM.initialize
- jdk/internal/misc/VM.initializeFromArchive
- java/lang/Class.getPrimitiveClass
- java/lang/Class.initClassName
- java/lang/Object.getClass
- java/lang/Class.getName0
- java/lang/Float.floatToRawIntBits
- java/lang/Double.doubleToRawLongBits
- java/lang/Double.longBitsToDouble
- java/lang/Throwable.fillInStackTrace
- java/lang/Class.forName0
- java/lang/Class.desiredAssertionStatus0
- java/lang/System.arraycopy
- java/lang/String.intern
- java/lang/Object.hashCode
运行虚拟机
参考 JJVM 里面的 Starter.java 和 Argument.java。
我们知道一般的 JVM 都规定 main 方法作为入口,而且参数可以通过命令行追加,所以 Argument.java 文件功能明显。Starter.java 主要就是初始化系统,找到对应类的 main 函数执行。
但是初始化系统是个非常复杂的过程,不初始化其实也可以运行部分代码,但是像 println 需要 System 类的就运行不了,因为 JDK 8 到 JDK 14 的 System 类加载都不太一样,而且需要的原生方法也很多,所以 JJVM 暂时并没有去解决这个问题,只是写了一个 println 和 print 的 hook 来凑合先。
结语
至此,纤细如 JJVM,就已经可以完全胜任大部分简单 Java 代码,诸如:
package me.chongwish.jjvm.demo.sort;
public interface Sort<T extends Comparable<? super T>> {
public T[] sort(T[] array);
}
package me.chongwish.jjvm.demo.sort;
public class Bubble<T extends Comparable<? super T>> implements Sort<T> {
@Override
public T[] sort(T[] array) {
for (int i = 0; i < array.length - 1; ++i) {
for (int j = 0; j < array.length - 1 - i; ++j) {
if (array[j].compareTo(array[j + 1]) > 0) {
T temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
return array;
}
}
package me.chongwish.jjvm.demo;
import me.chongwish.jjvm.demo.sort.Bubble;
import me.chongwish.jjvm.demo.sort.Sort;
public class Main {
public static void main(String[] args) {
System.out.println("Bubble.sort");
Integer[] array = new Integer[] { 5, 4, 1, 8, 12, 6 };
System.out.print("before: ");
for (int n : array) {
System.out.print(n + " ");
}
System.out.println();
Sort<Integer> fn = new Bubble<>();
fn.sort(array);
System.out.print("after: ");
for (int n : array) {
System.out.print(n + "");
}
System.out.println();
}
}
$> java -jar build/libs/jjvm.jar -cp demo/build/classes/java/main me.chongwish.jjvm.demo.Main
# Bubble.sort
# before: 5 4 1 8 12 6
# after: 1 4 5 6 8 12