LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

就简单写个 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,所以我们可以用现成 ByteBuffergetBytegetChargetInt

读的格式也基本固定,大致分为:

  1. 顺序读取,如第一个就是读取 32bit 的魔法数,读完就可以读两个 16bit 的版本号。
  2. 先顺序读取大小值,然后用读取该值大小的数据。

Constant Pool 部分

这里的静态池是保存在 .class 文件里面的一些数据,并不是 JVM 的运行时静态池,且需要注意的是里面保存的 UTF-8Modified UTF-8 的非标准 Unicode。最终这个解析完的池是个类似字典的东西,所有名字或者浮点数等都会往里面查找,如果你是用数组记录的话,要记得记录的索引是类似 int32[] 组织的,所以面对 longint64 的数据,要占用多一个索引。

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 中类的加载有三个过程:

  1. Load:使用双亲委托读取一个类(动态生成的数组还是有实际的类文件)
  2. Link:信息记录类到 RuntimeDataArea
  3. Initialization:对 RuntimeDataArea 里面有需要的类进行初始化,其实就是修改 Clazz 里面对应 Method 的值,或者给 Clazz 一个 Java Class 的 Instance,并且只能有一次

而在这里还要有一个特殊的机制,预先自动加载 ObjectClass 两个系统类,然后自动加载基本类型类(voidbyte 等)。

解释器和指令

参考 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