Java 代码以类为基础。在编译时,每个类都会生成一个.class
文件,其中用16进制字节码保存了JVM运行程序需要的各种信息。虚拟机读取信息,就可以执行程序。根据这个原理,我们可以用 Java 写一个 Java 虚拟机,这真是太有趣了(棒读)。
本程序使用 OpenJDK 1.8,使用 Gradle 构建并使用了 lombok 插件。如果使用IDEA,请在手动在项目设置里修改为 OpenJDK 1.8;提到的 lombok 插件非常便利,在自己编写程序时也有多次用到,比如最简单的应用是可以在抛出异常的地方写一个 @SneakyThrow
,这样程序员就不用麻烦的为一个自己无法处理的异常写一个 try-catch
包裹了。
Java 字节码
在一切开始之前,我们需要先了解 Java 字节码的结构。你可以用任何16进制编辑器查看.class
文件,或者你可以使用javap ${target} -verbose
来用人类可以读懂的方式查看其内容。下图展示了.class
文件顺序排列的结构。一个固定长度的小块块代表了一个 Byte。

魔数和版本号
最开始的魔数 0xCAFEBABE
,或者 3405691582 (int),标志着这是一个 Java 虚拟机可运行的字节码文件,真是独特(Cafe Babe = 看板娘?)。接下来两个 short 存储了编译的副、主版本号。JVM在执行的时候也会检查自己能否运行该版本号的字节码。
常量池
首先,常量池计数器占用一个 short。它记录了 cp_info
即常量池项的个数,注意到常量池计数器是从1开始的,所以常量池项将会有constant_pool_count - 1
个,因为偶尔会需要有一个引用“不指向任何常量”,即索引0。又注意到计数器因为是16比特存储,因此常量个数不能超过65534个(如果你在一个类里面写了这么多常量,那你应该思考一下设计模式)。
随其后的是不定长度的常量池数据区。如果它不定长度,那么我们如何读取呢?请打开我们的手册并翻到4.4节常量池(在78页)。我们可以看到,每个 cp_info
的结构是:
1 | cp_info { |
根据一个字节的类型标记,我们可以info[]
到底是什么。比如,一个CONSTANT_Utf8_info
结构,即字面常量结构在4.4.7被介绍,其结构如下:
1 | CONSTANT_Utf8_info { |
根据这个原理,我们每次首先判断其是什么类型,再按照类型读取数据。建议随便写一个程序,然后利用javap
观察一下,看看它存放了什么字面数据,别的数据又是如何被存放的。
访问标志
访问标志就是你在程序里写的public, private, final, abstract等等。其具有两个字节,并且其是一个掩码结构。我们暂且不需要知道所有的访问标志都是什么,只需要知道计算这块区域的方式是将所有的访问标志求和即可,举例来说,假如有一个公开接口,其具有的访问标志有PUBLIC
的值是0x0001
,INTERFACE
的值是0x0200
,那么访问标志就是0x0201
。
类索引与父类索引
这里的类索引和父类索引都分别占有两个字节,存储一个指向常量池中CONSTANT_Class_info
的索引。
接口
首先具有接口数量信息,接着有该数量个接口数据信息。注意接口数据信息只是一个两字节的,指向常量池中CONSTANT_Class_info
的索引。
字段与方法
字段和方法相当类似,其开头都会有一个计数器,而后跟随一串数据,这些数据拥有这样的结构:
1 | field_info { |
method_info
也是一模一样的。我们可以看到一个字段或方法拥有访问标志,具有其名字和描述符索引,属性计数器和属性等。
我们必须注意到的是,这个结构中也具有“属性”这个结构,即attribute_info
的数组。也就是说字段、方法和类文件都具有属性。实际上,方法是如何被执行的,即JVM的字节操作码就记录在method_info
里的attribute_info
的数组内,并且其具体的attribute_info
类型是Code
。
属性
我们暂时不需要关心属性(attribute)的部分,但是我还是把其结构表示出来:
1 | attribute_info { |
在写代码的时候会用到,并且之后实现真正运行程序的时候也是必要的。除了上面提到的Code
属性以外,还存在常量ConstantValue
,异常Exception
等等。
程序框架
在 VJVM 项目的src
目录中存放了所有的程序源代码,我们可以先观察一下其结构。不需要关心的部分被我去掉了。
1 | src |
我们首先从目录main/java/vjvm/vm
看起。这里有程序的入口,我们可以暂不关心。构造 VJVM 的程序员使用了 lombok 来简化程序的使用,我们可以理解为编译好程序运行后为其提供一些参数,由程序入口来处理。接下来 VMContext 就是接收查找目录并生成类加载器实例。
所以,我们来看main/java/vjvm/classloader
,即类加载器。我们可以看到该目录下还有一个searchpath
,我们合理的推测其是类加载器的成员。因为我们可能到运行时才知道我们需要从何处,比如文件内或是jar包内,或者是其他情况。因此需要动态绑定,面向接口编程。
JClassLoader 在对应路径加载到编译好的类文件后,其用得到的文件,根据代码实际内容来看是 DataInputStream
类型,即二进制数据流,和其本身创建一个 JClass。这带领我们来到main/java/vjvm/classdata
目录。在这里我们需要实现读取一个类的内容。根据之前字节码的介绍,比较棘手的是常量池,字段方法属性信息等等。
其他有省略号的文件均不是本次 Lab 需要考虑的内容。
构造查找类文件的代码
如果我们想要读取.class
文件内容,首先该找到.class
文件在哪里。
类加载类
我们首先来阅读一下框架给出的JClassLoader
。
1 | public class JClassLoader implements Closeable { |
首先看属性,它具有一个同类型的parent,一个搜索路径列表和一个描述符对应JClass的的哈希表。
我们要知道Java加载类通过使用Parent-First优先策略,一个Loader得到一个路径,首先会交给父Loader查找,如果找不到再自己进行查找。而查找的路径就是ClassSearchPath。接下来完成loadClass()方法。
我们首先对类名字和路径做一下处理。Java类描述符大概长这样:Ljava/lang/Object;
。如果换为路径,就是java/lang/Object
,再用点更换斜杠,就得到了类名。
1 | String dir = descriptor.substring(1, descriptor.length() - 1); |
接下来看一下是否已经加载过,并丢给双亲加载:
1 | if (this.definedClass.containsKey(name)) { |
如果双亲找到了,就返回双亲的。否则,自己去找。
1 | if (parentFoundClass == null) { |
这一段代码其实涉及到一个风格问题。几天前看过的一个视频中提到,应该尽量少在代码中使用Else,这样可以减少嵌套。因此,我们这样修改一下:
1 | if (parentFoundClass != null) { return parentFoundClass; } |
通过把最开始的判等换为判不等,我们得到了一个守卫语句(Guard Clause)。这是在代码中减少使用嵌套的好办法。
类加载路径类
我们这样并没有完成构造,因为我们没有完成ClassSearchPath。在这里使用多态的原因是,我们的类可能来自于一个路径,也可能来自一个Jar包;在未来,可能有更多不同的路径。
我们在ClassSearchPath中有一个静态方法。通过调用这个静态方法,我们可以在不实例化任何类的时候,根据参数构建多个SearchPath。这是一个工厂模式的设计实例。
1 | public static ClassSearchPath[] constructSearchPath(String path) { |
而我们每一个具体的SearchPath都要override父类的findClass()方法,拿路径搜索举例:
1 | private final String searchDir; |
构造读取字节码内容的代码
待更新。