写自己的JVM(0x3)
原始博客的地址:
该项目的地址:
有了前面2篇的基础,现在我们终于有能力进入正题了。
我们先来看一张图:
相信大家对这张图还是比较熟悉的,今天我们主要的目的就是搭建一个运行数据区的框架出来。
- 方法区:这一块内容我们到后面再说,暂时简单理解为它就是存放了 class。
- 堆区:这里主要是管理对象的分配和释放,由于我们暂时不实现 malloc,所以也不用管,而且用java实现起来也很蛋疼,有兴趣的可以看一下我的另一个项目:csapp-emulater,里面有个 malloc 的实验代码就做了这部分工作,不过是用C实现的。
- 本地方法栈:也不用管,这个系列也暂时不会做这块。也有人使用桥接的方式来做,但是也失去了意义。
- 栈区:这一块,我们实现一个单线程模式,后面看情况加上多线程模型。
- 程序计数器:这个需要实现,不然方法调用有问题。
虚拟机栈
分析完成后,我们的目标就简单多了,先来了解一下栈区的构成。
虚拟机栈里面有多个栈帧,每个栈帧对应着一个方法。我们在程序中打一个断点,方法的调用层次就对应着虚拟机栈里面的层次,举个例子:
typescript
public class Test01 {
public static void main(String[] args) {
test(args);
}
public static void test(String[] args) {
test2(args);
}
public static void test2(String[] args) {
System.out.println(args.length);
}
}
我们给 test2 方法打上断点:
可以看到方法调用有3层,那么虚拟机栈也有3个栈帧:
将一个栈帧理解为方法的执行,那么栈帧里面必然还需要很多其他的东西:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
动态链接是执行方法的时候,需要将常量池中的字符串类名转换成一个具体的指向对象,这个我们在实现invoke等指令的时候再细说。
局部变量表
局部变量表用于存放方法参数和方法内部定义的局部变量,以变量槽(Variable Slot)为最小单位。
《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java语言 中明确的64位的数据类型只有long和double两种。所以,long 与 double 占2个 slot。
我们先创建一个 slot 类:
csharp
public class Slot {
// used for gc
// if we not reference heap instance, object will be release
private MyObject ref;
private int value;
public Slot(int value) {
this.value = value;
isRef = false;
}
public Slot(MyObject ref) {
this.ref = ref;
isRef = true;
}
}
由于 slot 里面既可以存放基本类型也可以存放引用类型,所以我们使用两个字段来分别存放。这个时候有点想念C
里面的 union
类型(这里出现的 MyObject 后面会细说,暂时把它当成是 Object 理解)。
我们再创建一个 LocalVariableTable 类,里面有很多的 slot:
java
public class LocalVariableTable {
protected final Slot[] slots;
public LocalVariableTable(int slotSize) {
slots = new Slot[slotSize];
for (int i = 0; i < slotSize; i++) {
slots[i] = new Slot(null);
}
}
}
这样局部变量表就创建好了。非常的简单啊!slot size
是编译完后就确定了的:
class 文件里面就包含了所有方法的局部变量表的大小,之前在解析 class 的时候就解析出来了。
该类还需要提供了一些 set/get 方法,便于存取变量:
csharp
public void setRef(int index, MyObject ref) {
slots[index].setRef(ref);
}
public MyObject getRef(int index) {
return slots[index].getRef();
}
public long getLong(int index) {
int high = slots[index].getValue();
int low = slots[index + 1].getValue();
return NumUtil.merge(high, low);
}
public void setLong(int index, long val) {
int high = NumUtil.getHigh(val);
int low = NumUtil.getLow(val);
slots[index] = new Slot(high);
slots[index + 1] = new Slot(low);
}
...
操作数栈
同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
有了这个特征,那么操作数栈也可以使用 slot 来实现。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。
举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。
嗯,说了这么多不如上图,看例子:
csharp
public class Test02 {
public static void main(String[] args) {
System.out.println(add(1, 2));
}
public static int add(int x, int y) {
return x + y;
}
}
先看 add 方法的 code 里面的字节码:
iload_0 是将局部变量表中的第0号位置储存的值取出来放入操作数栈。
iload_1 是将局部变量表中的第1号位置储存的值取出来放入操作数栈。
我们看看局部变量表:
我们看到第0号位置是 x,第 1 号位置是 y,与源码是对的上的。后面我们实现参数传递的时候再说局部变量表里面的值。
iadd 是将操作数栈里面的最上面2个int值取出来相加,然后将相加的结果再放回操作数栈中。
ireturn 会将操作数栈顶的值传递给调用该方法的栈帧,后面再说。
操作数栈的作用看起来就是临时存放一些变量做计算的,确实如此,所有方法参数的传递,变量的计算等都是在操作数栈上完成的。
这也是为啥说JVM是基于栈的,我们可以对比一下 x86 的汇编代码:
perl
// 11 条指令
Dump of assembler code for function add:
0x000055555540064a <+0>: push %rbp
0x000055555540064b <+1>: mov %rsp,%rbp
=> 0x000055555540064e <+4>: mov %rdi,-0x18(%rbp)
0x0000555555400652 <+8>: mov %rsi,-0x20(%rbp)
0x0000555555400656 <+12>: mov -0x18(%rbp),%rdx
0x000055555540065a <+16>: mov -0x20(%rbp),%rax
0x000055555540065e <+20>: add %rdx,%rax
0x0000555555400661 <+23>: mov %rax,-0x8(%rbp)
0x0000555555400665 <+27>: mov -0x8(%rbp),%rax
0x0000555555400669 <+31>: pop %rbp
0x000055555540066a <+32>: retq
End of assembler dump.
可以看到 add 的汇编还是比较麻烦的,但是里面的数据的计算都是由寄存器(带%号的)来完成,根本不需要啥操作数栈。再说远一点,还有啥复杂指令集与精简指令集的区别。
了解了操作数栈,我们来实现它:
arduino
public class OperandStack {
private final Slot[] slots;
// point to last free slot element index
private int freeIndex;
public OperandStack(int size) {
slots = new Slot[size];
for (int i = 0; i < size; i++) {
slots[i] = new Slot(null);
}
}
}
这里我并没有采用stack来实现而是使用了数组来实现。也没啥特殊的原因,就是想用数组。
栈帧
上面实现了局部变量表和操作数栈,只剩pc没有实现,我们只需要搞一个变量即可:
arduino
public class StackFrame {
private final OperandStack operandStack;
private final LocalVariableTable localVariableTable;
private final MyThread thread;
private final MyMethod myMethod;
private int nextPc;
public StackFrame(MyThread thread, MyMethod myMethod) {
this.thread = thread;
this.myMethod = myMethod;
this.localVariableTable = new LocalVariableTable(myMethod.getMaxLocals());
this.operandStack = new OperandStack(myMethod.getMaxStack());
}
}
MyThread 主要就是模拟线程,MyMethod 就是模拟方法,后续用于动态链接。
nextPc 就是储存的需要读取的下一条指令的位置。
在进行执行的执行或者方法跳转的时候,我们只需要随时更新 nextPc 的值。无论栈帧如何变化,已有栈帧的 nextPc 都不会变化,当栈帧 pop 与 push 的时候只需要继续取栈顶 nextPc 的指令执行即可。
虽然有一个 ret 指令,它会从局部变量表里面取出返回地址的值,但是我们不支持该指令。我们按照自己的想法来实现一个简单的JVM,能省则省。
测试
我们已经实现好了一个虚拟机栈,现在测试一下:
这里我们给程序添加 -vinst -vframe 指令,表示我们想看方法指令执行时栈帧变化细节。
看输出结果:
less
write/your/own/jvm/test/Test02.add() #0 -> iload_0
+----------operand stack----------
| Value:slot[0]=1
+----------operand stack----------
+----------local variable table----------
| Value:slot[0]=1
| Value:slot[1]=2
+----------local variable table----------
可以看到执行 iload_0 之后,操作数栈多了一个值 1。
less
write/your/own/jvm/test/Test02.add() #1 -> iload_1
+----------operand stack----------
| Value:slot[0]=1
| Value:slot[1]=2
+----------operand stack----------
+----------local variable table----------
| Value:slot[0]=1
| Value:slot[1]=2
+----------local variable table----------
执行 iload_1 之后,操作数栈多了一个值 2。
less
write/your/own/jvm/test/Test02.add() #2 -> iadd
+----------operand stack----------
| Value:slot[0]=3
+----------operand stack----------
+----------local variable table----------
| Value:slot[0]=1
| Value:slot[1]=2
+----------local variable table----------
执行 iadd 后,操作栈就只剩一个数 3 了。