抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

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 的结构是:

cp_info
1
2
3
4
cp_info {
u1 tag;
u1 info[];
}

根据一个字节的类型标记,我们可以info[]到底是什么。比如,一个CONSTANT_Utf8_info结构,即字面常量结构在4.4.7被介绍,其结构如下:

CONSTANT_Utf8_info
1
2
3
4
5
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}

根据这个原理,我们每次首先判断其是什么类型,再按照类型读取数据。建议随便写一个程序,然后利用javap观察一下,看看它存放了什么字面数据,别的数据又是如何被存放的。

访问标志

访问标志就是你在程序里写的public, private, final, abstract等等。其具有两个字节,并且其是一个掩码结构。我们暂且不需要知道所有的访问标志都是什么,只需要知道计算这块区域的方式是将所有的访问标志求和即可,举例来说,假如有一个公开接口,其具有的访问标志有PUBLIC的值是0x0001INTERFACE的值是0x0200,那么访问标志就是0x0201

类索引与父类索引

这里的类索引和父类索引都分别占有两个字节,存储一个指向常量池中CONSTANT_Class_info的索引。

接口

首先具有接口数量信息,接着有该数量个接口数据信息。注意接口数据信息只是一个两字节的,指向常量池中CONSTANT_Class_info的索引。

字段与方法

字段和方法相当类似,其开头都会有一个计数器,而后跟随一串数据,这些数据拥有这样的结构:

field_info
1
2
3
4
5
6
7
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}

method_info也是一模一样的。我们可以看到一个字段或方法拥有访问标志,具有其名字和描述符索引,属性计数器和属性等。

我们必须注意到的是,这个结构中也具有“属性”这个结构,即attribute_info的数组。也就是说字段、方法和类文件都具有属性。实际上,方法是如何被执行的,即JVM的字节操作码就记录在method_info里的attribute_info的数组内,并且其具体的attribute_info类型是Code

属性

我们暂时不需要关心属性(attribute)的部分,但是我还是把其结构表示出来:

attribute_info
1
2
3
4
5
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}

在写代码的时候会用到,并且之后实现真正运行程序的时候也是必要的。除了上面提到的Code属性以外,还存在常量ConstantValue,异常Exception等等。

程序框架

在 VJVM 项目的src目录中存放了所有的程序源代码,我们可以先观察一下其结构。不需要关心的部分被我去掉了。

Source File Tree
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
src
├── main
│   └── java
│   └── vjvm
│   ├── classfiledefs
│   │   ├── ...
│   ├── classloader
│   │   ├── JClassLoader.java
│   │   └── searchpath
│   │   ├── ClassSearchPath.java
│   │   └── ModuleSearchPath.java
│   ├── runtime
│   │   ├── JClass.java
│   │   └── classdata
│   │   ├── ConstantPool.java
│   │   ├── FieldInfo.java
│   │   ├── MethodInfo.java
│   │   ├── attribute
│   │   │   ├── ...
│   │   └── constant
│   │   ├── ClassConstant.java
│   │   ├── ...
│   │   └── UnknownConstant.java
│   ├── utils
│   │   └── UnimplementedError.java
│   └── vm
│   ├── Main.java
│   └── VMContext.java
└── test
└── ...

我们首先从目录main/java/vjvm/vm看起。这里有程序的入口,我们可以暂不关心。构造 VJVM 的程序员使用了 lombok 来简化程序的使用,我们可以理解为编译好程序运行后为其提供一些参数,由程序入口来处理。接下来 VMContext 就是接收查找目录并生成类加载器实例。

所以,我们来看main/java/vjvm/classloader,即类加载器。我们可以看到该目录下还有一个searchpath,我们合理的推测其是类加载器的成员。因为我们可能到运行时才知道我们需要从何处,比如文件内或是jar包内,或者是其他情况。因此需要动态绑定,面向接口编程。

JClassLoader 在对应路径加载到编译好的类文件后,其用得到的文件,根据代码实际内容来看是 DataInputStream 类型,即二进制数据流,和其本身创建一个 JClass。这带领我们来到main/java/vjvm/classdata目录。在这里我们需要实现读取一个类的内容。根据之前字节码的介绍,比较棘手的是常量池,字段方法属性信息等等。

其他有省略号的文件均不是本次 Lab 需要考虑的内容。

构造查找类文件的代码

如果我们想要读取.class文件内容,首先该找到.class文件在哪里。

类加载类

我们首先来阅读一下框架给出的JClassLoader

JClassLoader.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class JClassLoader implements Closeable {

private final JClassLoader parent;
private final ClassSearchPath[] searchPaths;
private final HashMap<String, JClass> definedClass = new HashMap<>();
// Stores its creator
private final VMContext context;

// constructor
// ...

public JClass loadClass(String descriptor) {
// 以下的代码在这里
}

@Override
@SneakyThrows
public void close() {
// ...
}

首先看属性,它具有一个同类型的parent,一个搜索路径列表和一个描述符对应JClass的的哈希表。

我们要知道Java加载类通过使用Parent-First优先策略,一个Loader得到一个路径,首先会交给父Loader查找,如果找不到再自己进行查找。而查找的路径就是ClassSearchPath。接下来完成loadClass()方法。

我们首先对类名字和路径做一下处理。Java类描述符大概长这样:Ljava/lang/Object;。如果换为路径,就是java/lang/Object,再用点更换斜杠,就得到了类名。

1
2
String dir = descriptor.substring(1, descriptor.length() - 1);
String name = descriptor.substring(1, descriptor.length() - 1).replace("/", ".");

接下来看一下是否已经加载过,并丢给双亲加载:

1
2
3
4
5
if (this.definedClass.containsKey(name)) {
return this.definedClass.get(name);
} else if (this.parent != null) {
parentFoundClass = this.parent.loadClass(descriptor);
}

如果双亲找到了,就返回双亲的。否则,自己去找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (parentFoundClass == null) {
for(ClassSearchPath p : this.searchPaths) {

if(p.findClass(dir) != null) {
JClass tmp = new JClass(new DataInputStream(p.findClass(dir)), this);
this.definedClass.put(name, tmp);
return tmp;
}
}

return null;
} else {
return parentFoundClass;
}

这一段代码其实涉及到一个风格问题。几天前看过的一个视频中提到,应该尽量少在代码中使用Else,这样可以减少嵌套。因此,我们这样修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
if (parentFoundClass != null) { return parentFoundClass; }

for(ClassSearchPath p : this.searchPaths) {
// 这里的findClass方法将在后面提到
if(p.findClass(dir) != null) {
JClass tmp = new JClass(new DataInputStream(p.findClass(dir)), this);
this.definedClass.put(name, tmp);
return tmp;
}
}

return null;

通过把最开始的判等换为判不等,我们得到了一个守卫语句(Guard Clause)。这是在代码中减少使用嵌套的好办法。

类加载路径类

我们这样并没有完成构造,因为我们没有完成ClassSearchPath。在这里使用多态的原因是,我们的类可能来自于一个路径,也可能来自一个Jar包;在未来,可能有更多不同的路径。

我们在ClassSearchPath中有一个静态方法。通过调用这个静态方法,我们可以在不实例化任何类的时候,根据参数构建多个SearchPath。这是一个工厂模式的设计实例。

ClassSearchPath内部方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static ClassSearchPath[] constructSearchPath(String path) {

String sep = System.getProperty("path.separator");

// Split the path to array
String[] pathArray = path.split(sep);
ClassSearchPath[] tmp = new ClassSearchPath[pathArray.length];

// Implement different class for different path
for(int i = 0; i < pathArray.length; ++i) {
if(pathArray[i].endsWith(".jar")){
tmp[i] = new JarSearchPath(pathArray[i]);
} else {
tmp[i] = new DirSearchPath(pathArray[i]);
}
}

return tmp;
}

public abstract InputStream findClass(String name);

而我们每一个具体的SearchPath都要override父类的findClass()方法,拿路径搜索举例:

DirSearchPath内部方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private final String searchDir;

public DirSearchPath(String dir) {
this.searchDir = dir;
}

@Override
public InputStream findClass(String dir) {
String fileDir = this.searchDir + System.getProperty("file.separator") + dir + ".class";

// Open it directly
try {
return new FileInputStream(fileDir);
} catch (FileNotFoundException e) {
return null;
}
}

构造读取字节码内容的代码

待更新。

评论