Lab6 Interpreter

这次实验比第五次难QwQ 比第四次也难不少(指正 (大家认准他!有一半是他出的题!难的那一半!( ´▽` )ノ

0. 须知

本次实验有不小的难度,请各位同学抓紧时间完成

由于原文过长,对应内容一定一定一定要读手册,背景知识中仅能对部分重点细节做出概述,无法面面俱到。此外,中文版的规范对这部分的描述非常非常非常容易引起歧义,建议直接英文版。

类加载和引用解析的知识在《深入理解Java虚拟机》的第七章中也有非常精炼易读的讲解。最重要的是,你需要通读JVM规范Chapter5 来获取更多的细节。

方法调用相关的知识对应手册第六章中的invokeInterface/invokeVirtual/invokeStatic指令

请先阅读教材学习知识再来做练习!!

另外,实验框架中的任何变动都会在网站上进行更改,使用本地文档的同学请注意群里的通知,以免浪费自己的时间。在线文档地址 https://jvm.ydjsir.com.cn

更新记录

!!!注意看这里!!!!

  1. 由于框架代码没有支持本地方法,所以请大家在实现invoke类指令时正确地跳过本地方法。(InvokeVirtual中提供了一种实现)

if (method.isNative()) {
   //在这里写一些代码来撤销之前代码对栈帧的影响以跳过这条invoke指令   
}

2. TestUtil相关的部分只需要在invokeStatic中对方法名进行判断,然后再作对应处理即可实现。

1. 实验背景

类初始化

在之前的作业中,我们已经完成了类加载的前两步工作——加载、链接,这次实验中将完成最后一个步骤,即初始化。从初始化阶段开始,JVM正式从静态进入了动态执行。 在javac编译源码时,编译器将会自动收集类中的所有对静态变量的赋值语句静态代码块中的语句并将其整合生成<clinit>方法。 类初始化就是执行类的<clinit>方法中的每一条指令。 与实例构造方法<init>不同,<clinit>不需要显式地调用父类的<clinit>方法,他们的顺序关系是由编译器保证的。 在JVM执行过程中,第一个调用<clinit>的类一定是java/lang/Object,因为它是所有类的父类。

类初始化所要执行的<clinit>方法需要依赖于方法调用,只要正确实现了方法调用和返回,就能够正确实现类初始化。

方法调用

Java是一种基于栈执行的语言。 每一个线程栈中的栈帧都有且仅有一个方法。 方法调用最关键的指令有两类,一类是INVOKE指令,用于为被调用的方法准备参数并初始化对应的栈帧,最后将初始化后的栈帧推入线程栈。 另一类则是RETURN指令,用于被调用的方法将可能存在的返回值传递给调用者并将被调用者弹出线程栈。

引用解析

在第三次作业中已经接触过一点关于引用的解析,也就是classRef,而除了类的引用,还存在着methodRef、interfaceMethodRef、fieldRef这三个类型的引用。它们和classRef的作用类似,都是为了动态地加载所需要的类/接口,以及获取到类/接口中的方法、成员。

引用解析和方法调用的关系是,方法调用需要调用到某个具体的方法。例如在一个运用多态的源码中,程序在执行的时候才能够知道到底调用了哪一个方法,而这个具体的方法就是由引用解析得到的。

2. 实验要求

完成所有TODO

测试用例中 **除了jvm规范以外的** 要求

本地方法

在invoke指令的实现中跳过本地方法

引用解析

为了降低本次作业难度,引用解析中不需要考虑找不到对应类、方法、成员的情况,也不需要验证JVM规范中的各种约束,提供的测试用例都是正确和合法的。并且参考第七版的要求即可,不需要考虑第八版开始增加的“接口中可以定义public static、public default方法”这一特性。

支持以下三个静态方法

  1. TestUtil.equalInt(int a, int b): 如果 a 和 b 相等,则不抛出任何异常,否则抛出RuntimeException, 其中,这个异常的 message 为 ${第一个参数的值}!=${第二个参数的值}例如,TestUtil.equalInt(1, 2)应该抛出 RuntimeException("1!=2")

  2. TestUtil.fail(): 抛出RuntimeException

  3. TestUtil.equalFloat(float a, float b): 如果 a 和 b 相等,则不抛出任何异常,

    否则抛出RuntimeException. 对于异常的 message 不作要求

加分项

请先保证其它3个正常测试用例都已经通过了之后再来思考加分项。

请先保证其它3个正常测试用例都已经通过了之后再来思考加分项。

请先保证其它3个正常测试用例都已经通过了之后再来思考加分项。

hack其实就是通过特殊的方式(面向用例)来使测试能够通过。下面这个测试用例在一个正确实现的虚拟机上是无法通过的。在没有hack之前,这个测试用例会抛出异常,因为类WYMXXZ的接口方法getMyNumber()返回的值不同。

你可以试着通过各种方法来修改你的实现,使得这个用例能够通过。

值得一提的是,在这里不应该通过hack TestUtil中的方法来通过测试,因为这可能会造成其他用例出错。

interface Printable{
    int getMyNumber();
}



class WYM implements Printable{
    @java.lang.Override
    public int getMyNumber() {
        return 0;
    }
}

class XXZ implements Printable{
    @java.lang.Override
    public int getMyNumber() {
        return 1;
    }
}

public class HackTest{
    public static void main(String[] args) {
        Printable ym = new WYM();
        Printable xz = new XXZ();
        //没有hack之前这里会抛出异常,因为wym的number是0而xxz是1
        TestUtil.equalInt(ym.getMyNumber(), xz.getMyNumber());
        TestUtil.equalInt(1, 1);
    }
}

3. 实验指导

类初始化

在手册中5.5节中提到了类初始化的时机,在第八版的文档中一共有六个场景,在本次作业中只需要考虑第1、4、5个场景,而大作业中还需要加上第6个场景。

此外,代码中使用了InitState来表示初始化的各个阶段,这里每个状态的解释来源于JVM规范,在实现中Fail阶段只是做了定义,暂时不需要考虑使用它,而其他三个状态都需要被用到。

  • Prepared :This class object is verified and prepared but not initialized.

  • Busy :This class object is being initialized by some particular thread.

  • Success:This class object is fully initialized and ready for use.

  • Fail :This class object is in an erroneous state, perhaps because initialization was attempted and failed.

在实现new等指令时(查看手册确认所有的指令),首先需要考虑如何验证类的初始化状态,以及何时需要初始化,你可以改变InitState来实现这一功能。需要注意的是子类不能在父类的初始化完成之前完成初始化。另一个问题是如果类的初始化状态说明未初始化完成你又需要做哪些工作,例如是否需要调整PC的值,是否需要改变线程栈中的某些结构。

你可以在JClass类中新增initClass方法,以便代码复用。你可以参考getMainMethod方法来实现对<clinit>方法的获取,它的描述符是()V,名字是“<clinit>”,并且是一个静态方法。

至于如何在子类中如何优先调用父类的方法,在之前的第三次作业中已经有所涉及,在本次作业中也会多次出现。你可以选择递归或是迭代来完成,不过要注意初始化完成状态的顺序。

对象和堆

在先前的作业中提到了使用Slot这个结构来存放一个值或者一个引用,而这个引用在源码中即表示为一个JObject对象。JObject有两个子类分别为NonArrayObject和NullObject,分别用来表示非数组对象和null。对象是由类创建的,在new指令中会涉及到这部分的内容,因此在JClass中应该增加关于创建对象的代码,而创建完成后的对象需要被加入JHeap也就是堆中,相关API看源码即可。

解释器是怎样工作的

在实验5的手册中,已经初步介绍了解释器的大概工作流程(不记得的小朋友现在就翻回去看一下!)。 这次实验我们来关注一下其中的细节部分。

进入解释器之前

在解释器执行main方法之前,其他代码首先为它读取和找到了要执行的字节码。在我们的实验中,这部分代码 在测试用例里。 具体代码如下所示:

private void execTest(String className) throws ClassNotFoundException {
    //加载JClass
    JClass clazz = loader.loadClass(className, null);
    //创建线程
    JThread thread = new JThread();
    //获取main方法
    Method main = clazz.getMainMethod();
    //创建StackFrame
    StackFrame mainFrame = new StackFrame(thread, main, main.getMaxStack(), main.getMaxLocal());
    //将StackFrame添加到线程栈顶
    thread.pushFrame(mainFrame);
    //调用解释器
    Interpreter.interpret(thread);
}

程序的一次运行是从main方法开始直到main方法结束,因此开始解释main方法之后,就标志着我们的JVM 开始执行用户程序了!

解释器的主循环

下面的代码是框架代码中解释器的loop()方法的重要部分,具体实现细节请大家按需自行关注框架代码

private static ArrayList<StateVO> loop(JThread thread) {                        
        while (true) {
            //读取ThreadStack的栈顶
            StackFrame oriTop = thread.getTopFrame();            
            //设置PC
            codeReader.position(oriTop.getNextPC());
            //取指令
            int opcode = codeReader.get() & 0xff;
            //译码
            Instruction instruction = Decoder.decode(opcode);
            //读取指令的剩余部分
            instruction.fetchOperands(codeReader);
            //更新PC
            int nextPC = codeReader.position();
            oriTop.setNextPC(nextPC);
            //执行指令
            instruction.execute(oriTop);
            //检查是否需要切换栈帧
            StackFrame newTop = thread.getTopFrame();
            if (newTop == null) {
              //如果新栈帧为空,则意味着main函数已经返回,这个程序已经执行完了
              return null;  
            }            
            if (oriTop != newTop) {
              //切换栈帧
                initCodeReader(thread);
            }
        }

结构化编程

然而,把所有逻辑都写在main方法里显然是一种混乱的做法,而结构化编程讲究“自顶向下,逐步求精”,我们需要把逻辑分为一个个子函数,然后递归地实现这些子函数的逻辑。在这个过程中,需要一条重要的指令来支持,即invoke_static.

为什么对应的概念是invoke static?我们在后面会提到这一点。

invoke static

在编译期,我们就可以确定代码调用的静态方法究竟对应着哪个具体的实现,因此invoke static指令做的事情也非常简单,首先 从常量池里读取被调用方法的签名,然后去对应的classfile里寻找这个方法的实现,之后用这部分实现来初始化它的栈帧,最后把栈帧push到ThreadStack的顶部。在解释器执行下一条指令的时候,就可以从被调用方法中读取代码了。

面向对象编程

面向对象的核心思想是封装和多态。(已经有很多面向对象语言放弃“继承”机制了!比如说Rust里就没有“继承”。继承在现代(这个观点是我2020年忘了在哪儿看到的)程序设计中被认为是一种不易维护的代码风格,具体原因请大家出门左转和刘老师讨论( ´▽` )ノ 在JVM里,put/getField指令对封装提供了支持(这部分比较简单,大家自己RTFM实现一下就好)

对于多态,其基本的逻辑是,被调用的方法在运行时才被动态地决定。JVM首先会读取对象的引用,然后 会从对象的信息中查找要调用的方法。 下面我们来看一个例子:(代码仅供参考,里面语法细节有不对的地方,不影响理解)

class A{
  void method(){}
}

class AChild extends A{
  void method(){
    //impl in AChild
  }
}

class BChild extends A{
  void method(){
    //impl in BChild
  }
}

void foo(A obj){
  obj.method();
}

public static void main(String[]){
  foo(new AChild())
  foo(new BChild())
}

编译器记录了obj的静态类型都是A, 在发生方法调用的时候,JVM首先获取了这个对象的引用,这个引用是运行时储存在操作数栈上的,

在具体的调用指令中,首先要去找到引用对应的类,分别是AChild和BChild,然后从它的信息(读取自对应的classfile)中 找到method这个方法的实现,然后再用这个实现来构造栈帧。

invoke virtual与invoke interface

这两条指令在Java8中除了格式不同以外,其它功能没有任何区别,包括方法查找和调用规则,都是完全一样的。

下面这部分不作要求,大家按需阅读:

之所以设置这两条指令,是因为在真正的JVM中,多态方法的查找通常是通过一个叫做“虚函数表(virutal function table)”的数据结构来实现的。 虚函数表可以理解成一个数组,每个元素对应着一个方法的地址。在方法查找的过程中,我们需要逐一遍历数组以找到对应的方法。 然而,对于非接口方法来说,某一方法在所有子类的虚函数表中的偏移量是固定的。例如上面图中的method, 在所有子类的虚函数表中都排在第0个。这样,在进行方法调用的时候,我们就不需要每次都查找method的位置了,只需要查找一次,在其它子类中就可以直接访问。

而对于接口方法来说,它在虚函数表中的位置是不固定的,因此对于每个类都需要重新查找。

在C和C++中,我们熟悉的“函数”默认是非动态多态的,即被调用的函数在编译时就可以确定,这个概念和Java中的“static method”类似,所以上面说结构化编程思想对应的是“invoke static”

调用惯例:传参、记录返回地址和返回值

对于熟悉x86/amd64的小朋友们来说,对于JVM的调用惯例可能会不太适应。 传参的部分在各种invoke指令的手册里有详细讲解。 由于JVM的Thread Stack上每个StackFrame都维护各自的PC,所以自然不需要额外记录返回地址。 返回值部分在各种return指令的手册里有详细讲解。

debug解释器

  1. 使用条件断点(STFW & RTFM)

  2. 可以在主循环中插入一些代码来打印关键信息(比如上一次实验提供的那些命令行输出)

最后更新于