(伪)2022 NJUSE VJVM Lab3.1 类与对象

本篇 Blog 的主要内容是我对南京大学 2022 软工大作业 VJVM 的继续完善, 用 Java 实现 jvm.

前两个 Lab 代码基于学长 Github@amnore框架代码, 具体的实验指引见 https://amnore.github.io/VJVM/, 答案的视频解说见 https://space.bilibili.com/507030405, 以下内容假设你已经完成 Lab1&2.

从 Lab3 开始的代码和测试用例为我自己的补充.

前言

前两个 Lab 中, 我们完成了类的解析和大部分字节码的解析与执行, 现在我们的 jvm 已经图灵完备了, 它可以解决一切可计算问题.

图灵完备对于我们完成的虚拟机来说是一次伟大的进步.

但… 仅有八个字符组成的 BrainFuck 语言 也是图灵完备的, 而且它的解释器比 jvm 好写三百倍.

1
2
3
4
// BrainFuck 语言的 HelloWorld 程序
++++++++++[>+++++++>++++++++++>+++>+<<<<-]
>++.>+.+++++++..+++.>++.<<+++++++++++++++.
>.+++.------.--------.>+.>.

就算你说 BrainFuck 语言实在太难懂, C 语言也是图灵完备的, C 语言甚至能直接编译成机器码不需要依赖虚拟机来执行.


那作为面向对象的 Java 语言自然与面向过程的 C 语言有不同之处 - 其具有面向对象的三大特征: 封装, 继承, 多态.

我们仅仅实现了多态其中的一部分 - 特设多态, 体现为 Java 语言中的重载(Overload). 更准确地说, 其实是编译器帮我们完成了大部分的工作, 我们只是按照编译的结果执行了对应指令.

既然缺失了面向对象的特征, 我们现在的 jvm 当然也就只能执行一些”很像 C 语言”的代码. 涉及到对象的许多代码都是无法执行的. (对了, 由于 Java 中数组也是对象, 现在的 jvm 中甚至无法创建数组…)

这样的结果显然不能让人满意, 所以在 Lab 3.1 中, 我们将会实现类的初始化, 对象创建等功能.

内存管理

先来看一段 Java 代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
int var;

A() {
var = 1;
}

public void echo() {
System.out.println(var);
}
}

public class Hello {
public static void main(String[] args) {
new A().echo();
}
}

我们可以将其改写为等价的 C 代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct A {
int var;
} A;

void A_init(A* a) {
a->var = 1;
}

void A_echo(A* a) {
printf("%d", a->var);
}

int main() {
A* a = (A*) malloc(sizeof(A));

A_init(a);
A_echo(a);

free(a);
return 0;
}

不同于 C 语言中我们手动调用 mallocfree 向系统申请和归还内存, Java 语言中程序员无需手动管理内存的分配和释放, jvm 代我们完成了这一工作.

我们使用 new 关键字创建对象, 得到了对象的引用 (reference), 其实就和 C 语言中的 指针 很类似.

但我们无需也无法手动释放内存, jvm 中的 gc (垃圾回收机制) 会帮助我们回收内存, 防止内存泄漏.


一些题外话

在 C 语言中我们可以使用指针随意的操控内存.

1
2
*((int*) 0x12345678) = 0xabcd1234;  // 修改 0x12345678 的内存为 0xabcd1234
((void(*)(void)) 0x23333333)(); // 修改 pc 为 0x23333333

而 java 中我们(一般情况下)没有直接操作内存的方法. 更高程度的抽象和封装让程序员无需关心底层的实现, 能更简单地完成程序, 但同时也在某种程度上剥夺了部分程序员的自由.

在我们已经完成的操作数栈和局部变量表中, 我们能存储的数据类型是原始类型(int, long, float, …).

在这个 Lab 中我们还会加入引用类型(reference).

这些类型都是长度确定, 大小有限的数据类型, 我们将它们储存在栈中.


而我们的对象内部可能封装了很多的数据, 大小可能很大, 为了保持栈的简洁和高效, 我们将其储存在 (heap) 中, 栈中只保持对堆中对象的引用 (reference), 引用类似 C 语言的指针, 其本身大小确定(1 Slot), 指向堆中的某段数据.

(注意, 每个线程 (JThread) 都有自己的栈, 而它们共享一个堆.)

我们对 reference 的具体处理其实和其他原始类型很相似, 我们只是将其作为一个”位置的标志”, 由此我们可以发现 Java 语言是一种完全 值传递 的语言, 不存在引用传递.

JVM 架构图

类的初始化

在 Lab1 中我们完成了对 JClass 内容的解析和读取, 但还没有完成类的初始化.

每个 JClass 中的静态字段是属于类的, 我们需要在 JClass 中为这些静态字段分配空间.

之后就可以进行类的初始化工作, 其在 jvm specs 中的 5.5 节 有具体描述.

初始化主要是为类中的静态字段赋值和执行 static 代码块的内容.

1
2
3
4
5
6
7
8
9
10
11
public class Test {
public static int a = 1; // 在类初始化时被赋值

static {
a = 2; // 在类初始化时执行
}

public static void main(String[] args) {

}
}

对象的创建

从上文给出的 C 和 Java 代码的对比其实不难看出, 对象内部真正存储在堆区的数据其实是其本身的字段 (fields).

在创建对象时我们只需要在堆中以 JClass 作为”模板”, 为其中的非静态 fields 分配对应的空间即可. 对其构造方法和其父类构造方法的调用已经由编译器编译为了单独的 invokespecial 指令.

具体要求见 jvm specs new 指令的相关叙述.

jvm specs 并没有规定底层对象的实现方法, 我的做法是为 reference 类型创建了 Reference 类, 类内部持有 JHeapindex, 通过 indexJHeap 中找到对应的 Fields, 检查类型后从 Fields 中存入或取出变量内容.

虚方法的执行

动态分派是指程序运行时动态选择具体实现, 而不是在编译时确定具体实现.

1
2
3
4
5
6
7
8
9
10
11
class A {
public void func() { }
public void func2() { }
}
class B extends A {
public void func() { }
}

A obj = new B();
obj.foo(); // 最终会执行 B.func();
new B().func2(); // 可以执行到 A.func2();

这种可以被子类继承和覆盖的方法 (如 func) 就是虚方法. 在 Java 语言中, 所有的方法默认都是”虚方法”. 只有以关键字 final 或 private 标记的方法才是非虚方法.

执行 java 某个对象的虚方法需要使用 invokevirtual 指令, jvm specs 对这条指令的行为做出了详细的说明. 按照 jvm specs 的要求编写代码即可实现虚方法的执行.

数组

jvm 为数组提供了字节码级别的支持, 数组是一种特殊类型的 reference, 与普通的对象不同, array referencenewarray 指令 创建.

实现时我们可以创建 ObjectReferenceArrayReference 两个类作为 Reference 的子类, 各自实现不同的方法.

你还需要实现相关的指令从 arrayref 中取出内容或向 arrayref 中存储内容.

实现

相信如果你没有过度依赖刘钦老师的视频完成作业, 在你完成 Lab1 和 Lab2 的痛苦 debug 过程中, 你已经对当前的代码框架有了一个整体的了解.

你可以 Clone 这里的项目地址, checkout 到 lab3 分支后, 将其中的 testdatasrc/test 两个文件夹复制到你原本实现的 jvm 中即可加入 Lab3 得到相关测试用例: https://github.com/lyc8503/jjvm/tree/lab3

Lab3.1 中我没有给出”完形填空”式的框架代码, 你 clone 下来的其实是我已经完成的版本, 推荐做法是你只使用其中提供的测试样例.

由于我也是边看文档边写, 很多问题在刚写时考虑的并不全面, 虽然能通过测试样例, 细节部分可能和文档还有出入, 结构也有不合理之处, 但由于我懒还没有重构, 希望你能自己独立完成, 而不受到所谓”框架”的影响. 如果你的确没有思路, 可以参考我的实现的部分代码. (之前的两个 Lab 是我自己实现的, 后来对代码做出了细节上的调整, 可能与演示视频中的有差别.)


为了通过 Lab3.1 的测试用例, 你至少需要完成下面这些工作.

  1. 拓展之前的 SlotsOperandStack 的相关方法, 使其支持 reference 的相关操作, 并实现相关指令.

  2. 在恰当的时候进行类的初始化工作, 类中增添静态变量的储存.

  3. 创建一个 JHeap, 在虚拟机启动时创建全局唯一的 JHeap 实例, 在其中实现对象内存的分配, reference 的构建相关接口.

  4. 实现字节码指令, 相比 Lab2.2, 你需要补充以下内容.

    补全 Constants, Loads, Stores, Comparisons 中未完成的部分. 补全 References 中除了 invokeinterface, invokedynamic, athrow, monitorenter, monitorexit 的相关指令. 完成 Extended 中的 ifnullifnonnull 指令.

目前暂未涉及到异常处理的相关部分, 你可以暂时忽略在 jvm specs 中明确指出要抛出异常的部分, 最好先抛出 UnimplementedErrorAssertionError 便于日后补全.

为简化问题, 测试样例中暂无高维数组的相关测试, 你可以尝试自己加入, 欢迎 PR.

暂时测试用例中对继承情况下的细节表现测试的不是很到位, 只是做最基础的测试, 不涉及接口或抽象类, 相关用例有待补充.

如果你发现了 Lab3.1 中的错误, 欢迎联系我.


打赏支持
“请我吃糖果~o(*^ω^*)o”
(伪)2022 NJUSE VJVM Lab3.1 类与对象
https://blog.lyc8503.site/post/njuse-jvm-lab3.1/
作者
lyc8503
发布于
2022年7月5日
许可协议