写自己的JVM(0x4)- 实现一个解释器

​原始博客的地址:

lyldalek.notion.site/JVM-0x4-8a7...

该项目的地址:

github.com/aprz512/wri...

上一篇文章,我们主要是实现了虚拟机栈,知道了方法执行的一个大致的流程。在这一篇文章中我们就要深入到方法的指令,实现指令的解析,以及实现一个简单的解释器。

解释器也是也是编译器的一种,java文件编译成class文件是前端编译器完成的,java程序在运行的时候对字节码的执行使用的是解释器,也叫后端编译器。

指令结构介绍

通常将指令分为两部分:

  • 操作符
  • 操作数

每条指令都以一个单字节的操作码(opcode)开头,这意味着Java虚拟机最多只能支持256条指令。

Java虚拟机规范给每个操作码都指定了一个助记符(mnemonic)。

比如操作码是0x00这条指令,因为它什么也不做,所以它的助记符是nop(no operation)。

JVM的指令中,操作数不是固定的,可能有0个,1个,2个等等。

指令的读取

前面我们解析了 class 文件,拿到了每个 method 的 code 属性,使用的是 byte[] 储存的:

java 复制代码
public class CodeAttribute extends AttributeInfo {
  private final byte[] code;
}

要解析指令,我们需要遵循每条指令的格式。我们定义一个 CodeReader 类,它负责读取固定字节的数据并进行转换:

ini 复制代码
public class CodeReader {

    private byte[] code;

    private int pc;

    public void reset(byte[] code, int pc) {
        this.code = code;
        this.pc = pc;
    }

    public int readByte() {
        byte b = code[pc];
        pc += 1;
        return b;
    }

    /**
     * remove extended sign bit
     */
    public int readUnsignedByte() {
        byte b = code[pc];
        pc += 1;
        return b & 0xFF;
    }

    public int readShort() {
        int b1 = readUnsignedByte();
        int b2 = readUnsignedByte();
        return (short) (b1 << 8 | b2);
    }

    /**
     * remove extended sign bit
     */
    public int readUnsignedShort() {
        int b1 = readUnsignedByte();
        int b2 = readUnsignedByte();
        return (b1 << 8 | b2) & 0xFFFF;
    }

    public int readInt() {
        int b1 = readUnsignedByte();
        int b2 = readUnsignedByte();
        int b3 = readUnsignedByte();
        int b4 = readUnsignedByte();
        return b1 << 24 | b2 << 16 | b3 << 8 | b4;
    }

    public int[] readInts(int count) {
        int[] result = new int[count];
        for (int i = 0; i < count; i++) {
            result[i] = readInt();
        }
        return result;
    }

    public void skipPadding() {
        while (pc % 4 != 0) {
            readByte();
        }
    }

    public int getPc() {
        return pc;
    }
}

该类主要是读取 1/2/4 个字节,并转换为有符号或者无符号数。之所以要区分有符号与无符号是因为不同的指令的操作数不同,有的操作数需要看作无符号数。

这里我们也维护了一个 pc,其实这个pc的值就是 StackFrame 里面的 nextPc 的值。因为一个栈对应一个方法,一个方法对应一个(或者0个)code 属性。code 的 pc 位置就是方法执行到的位置。

指令的解析

JVM的指令分为多种:

  • 常量(constants)指令
  • 加载(loads)指令
  • 存储(stores)指令
  • 操作数栈(stack)指令
  • 数学(math)指令
  • 转换(conversions)指令
  • 比较(comparisons)指令
  • 控制(control)指令
  • 引用(references)指令
  • 扩展(extended)指令
  • 保留(reserved)指令

由于篇幅问题,我们每种指令介绍1~2个吧,后面实现方法区后,还会实现 invoke 相关的指令。

一般的指令有自身的操作类型,比如,IConst_0 就是操作的 int 类型,具体的类型对应表如下:

常量指令

const系列指令,这一系列指令把隐含在操作码中的常量值推入操作数栈顶。

IConst_0 指令的具体意义可以看官方文档:

docs.oracle.com/javase/spec...

贴一下该指令的描述:

有了指令的描述,我们就可以写出指令的具体执行逻辑。

我们定义了一个 Instruction 接口,它表示无操作数的指令。

typescript 复制代码
public class IConst0 implements Instruction {
    @Override
    public int getOpCode() {
        return 0x3;
    }

    @Override
    public void execute(StackFrame frame) {
        frame.getOperandStack().pushInt(0);
    }

    @Override
    public String getReadableName() {
        return "iconst_0";
    }
}

再看 bipush 的描述:

描述里面说,第一个操作数是一个有符号数,当作 int 处理,将这个值放入操作数的栈顶。所以代码实现如下:

typescript 复制代码
public class BiPush extends Operand1Instruction {

    public BiPush(CodeReader reader) {
        super(reader);
    }

    @Override
    public int getOpCode() {
        return 0x10;
    }

    @Override
    public void execute(StackFrame frame) {
    // operand 是 readOperand 返回的值
        frame.getOperandStack().pushInt(operand);
    }

    @Override
    public String getReadableName() {
        return "bipush";
    }

    @Override
    protected int readOperand(CodeReader reader) {
    // 这里读取的是 byte 的值
        return reader.readByte();
    }
}

Operand1Instruction 是一个抽象类,它封装了读取一个操作数的逻辑,具体可看源码。

加载指令

加载指令从局部变量表获取变量,然后推入操作数栈顶。举个例子:

iload指令后面跟了一个操作数 index

这个指令描述的很清楚:index是一个无符号数,指向的是局部变量表的索引,该索引对应的位置一定是一个 int 类型的数。

所以代码写起来就很简单:

java 复制代码
public class ILoad extends Operand1Instruction {
    public ILoad(CodeReader reader) {
        super(reader);
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readUnsignedByte();
    }

    @Override
    public int getOpCode() {
        return 0x15;
    }

    @Override
    public void execute(StackFrame frame) {
        int local = frame.getLocalVariableTable().getInt(operand);
        frame.getOperandStack().pushInt(local);
    }

    @Override
    public String getReadableName() {
        return "iload";
    }
}

JVM为了加快指令解析速度,还搞了一些常用的指令。

因为有一些index比较常见,就直接将index写在指令(助记符)上,不用那么的麻烦,再去读一个操作数,还可以节省 code 大小。

比如:

看一下,iload_0 的实现:

typescript 复制代码
public class ILoad0 implements Instruction {

    @Override
    public int getOpCode() {
        return 0x1A;
    }

    @Override
    public void execute(StackFrame frame) {
        int local = frame.getLocalVariableTable().getInt(0);
        frame.getOperandStack().pushInt(local);
    }

    @Override
    public String getReadableName() {
        return "iload_0";
    }
}

这种指令几乎占了大部分,看起来JVM的指令很多,实际上很多都是类似的。后面就不重复介绍这种类似的指令了。

储存指令

和加载指令刚好相反,存储指令把变量从操作数栈顶弹出,然后存入局部变量表。举一个例子就够了:

java 复制代码
public class IStore extends Operand1Instruction {
    public IStore(CodeReader reader) {
        super(reader);
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readUnsignedByte();
    }

    @Override
    public int getOpCode() {
        return 0x36;
    }

    @Override
    public void execute(StackFrame frame) {
        int value = frame.getOperandStack().popInt();
        frame.getLocalVariableTable().setInt(operand, value);
    }

    @Override
    public String getReadableName() {
        return "istore";
    }
}

栈指令

栈指令直接对操作数栈进行操作,共9条:

  • pop和pop2指令将栈顶变量弹出,
  • dup系列指令复制栈顶变量,
  • swap指令交换栈顶的两个变量。

和其他类型的指令不同,栈指令并不关心变量类型。

举个例子,看看 dup指令:

就算不看描述,看 Operand Stack 也很形象,就是将栈顶的变量复制一下,将复制后的变量也推到栈顶。具体代码实现如下:

typescript 复制代码
public class Dup implements Instruction {
    @Override
    public int getOpCode() {
        return 0x59;
    }

    @Override
    public void execute(StackFrame frame) {
        OperandStack operandStack = frame.getOperandStack();
        Slot slot = operandStack.popSlot();
        operandStack.pushSlot(slot);
        operandStack.pushSlot(slot);
    }

    @Override
    public String getReadableName() {
        return "dup";
    }
}

那这个指令啥时候会用到呢?实际上我们在创建对象的时候都会是一个成对的指令,比如如下代码:

ini 复制代码
Test03 test03 = new Test03();

编译后的字节码如下:

为啥 new 后面会跟一个 dup 指令呢?是因为创建对象会调用类的 init 方法。而 init 方法默认带一个参数,就是 this。这个 this 就是 new 指令创建出来的对象了。是不是有点神奇!!!想通这一点对后面实现方法区等很有帮助哦。

数学指令

数学指令又可以细分好多种,但是由于我们是使用 Java 实现 JVM,所以这一部分功能都没啥难度。比如,isub 指令,具体的实现细节还是要看 JVM 的说明哈:

注意这里的操作数栈,栈顶的是 value2,下面的是 value1。我代码里面写的是反的:

typescript 复制代码
public class ISub implements Instruction {
    @Override
    public int getOpCode() {
        return 0x64;
    }

    @Override
    public void execute(StackFrame frame) {
        int v1 = frame.getOperandStack().popInt();
        int v2 = frame.getOperandStack().popInt();
        frame.getOperandStack().pushInt(v2 - v1);
    }

    @Override
    public String getReadableName() {
        return "isub";
    }
}

转换指令

类型转换指令大致对应Java语言中的基本类型强制转换操作。

我们看一个 int 转 float 的指令,i2f

typescript 复制代码
public class I2F implements Instruction {
    @Override
    public int getOpCode() {
        return 0x86;
    }

    @Override
    public void execute(StackFrame frame) {
        float value = frame.getOperandStack().popInt();
        frame.getOperandStack().pushFloat(value);
    }

    @Override
    public String getReadableName() {
        return "i2f";
    }
}

因为在 java 里面 int 转 float 不需要做啥,所以代码逻辑也是非常的简单哈。

比较指令

比较指令是编译器实现if-else、for、while等语句的基石。

看一个比较复杂的例子:

这张图里面介绍了很多指令,但是功能类似,只不过判断条件不一样,有的是判断等于,有的是判断不等于。

我们看等于的实现:

java 复制代码
public class IfICmpEq extends Operand1Instruction {

    public IfICmpEq(CodeReader reader) {
        super(reader);
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readShort();
    }

    @Override
    public int getOpCode() {
        return 0x9F;
    }

    @Override
    public void execute(StackFrame frame) {
        int v2 = frame.getOperandStack().popInt();
        int v1 = frame.getOperandStack().popInt();
        if (v1 == v2) {
            frame.setNextPc(frame.getThread().getPc() + operand);
        }
    }

    @Override
    public String getReadableName() {
        return "if_icmpeq";
    }

}

其实也非常的简单,因为该指令后面虽然跟了两个字节,但是需要将它看作一个操作数,这个操作数表示的是一个 offset,是 pc 需要跳转的位置。

看具体的例子:

arduino 复制代码
  public static int test(int a) {
        if (a != 10) {
            a = 1;
        } else {
            a = 0;
        }

        return a;
    }

编译后的字节码:

看到 if_icmpeq 后面跟了一个 11,是 3 + 8 得出来的,所以其 offset 为 8。也就是说满足了当前的条件的话,下一条指令就要跳转到 11 位置去执行。

控制指令

这里主要讨论 gototableswitchlookupswitch 这3条指令,其他的比如 return 等指令后面再说。

goto

goto指令进行无条件跳转,我们看一个例子:

ini 复制代码
  public static int test(int a) {
        if (a > 0) {
            a = 1;
        } else if (a < 0){
            a = -1;
        } else {
            a = 0;
        }

        return a;
    }

看其编译后的字节码:

可以看到 if 与 goto 基本是配对的(虽然编译后的字节码逻辑变得很奇怪,但是还是正确的)。

goto 的实现:

typescript 复制代码
public class Goto extends Operand1Instruction {
    public Goto(CodeReader reader) {
        super(reader);
    }

    @Override
    public int getOpCode() {
        return 0xa7;
    }

    @Override
    public void execute(StackFrame frame) {
        frame.setNextPc(frame.getThread().getPc() + operand);
    }

    @Override
    public String getReadableName() {
        return "goto";
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readShort();
    }
}

tableswitch

Java语言中的switch-case语句有两种实现方式:

  • 如果case值可以编码成一个索引表,则实现成tableswitch指令;
  • 否则实现成lookupswitch指令。

下面这个Java方法中的switch-case可以编译成tableswitch指令,代码如下:

arduino 复制代码
int chooseNear(int i) {
    switch (i) {
        case 0: return 0;
        case 1: return 1;
        case 2: return 2;
        default: return -1;
    }
}

下面这个Java方法中的switch-case则需要编译成lookupswitch指令:

arduino 复制代码
int chooseFar(int i) {
    switch (i) {
        case -100: return -1;
        case 0: return 0;
        case 100: return 1;
        default: return -1;
    }
}

可以看出,如果 case 的值是大致连续的,就编译成tableswitch,否者就是lookupswitch ,但是这个也仅仅是推测,具体还需要去查一些资料。由于 tableswitch 是按照查表得方式,根据操作数去计算出表得索引,所以这个索引也不能太大。

看看代码实现:

java 复制代码
public class TableSwitch implements Instruction {

    private final int defaultOffset;
    private final int low;
    private final int high;
    private final int[] jumpOffsets;

    public TableSwitch(CodeReader reader) {
        reader.skipPadding();
        defaultOffset = reader.readInt();
        low = reader.readInt();
        high = reader.readInt();
        jumpOffsets = reader.readInts(high - low + 1);
    }

    @Override
    public int getOpCode() {
        return 0xAA;
    }

    @Override
    public void execute(StackFrame frame) {
        int index = frame.getOperandStack().popInt();
        int offset;
        if (index <= high && index >= low) {
            offset = jumpOffsets[index - low];
        } else {
            offset = defaultOffset;
        }
        frame.setNextPc(frame.getThread().getPc() + offset);
    }

    @Override
    public String getReadableName() {
        return "tableswitch";
    }
}

可以看到,这条指令后面跟了不少的操作数:

  • defaultOffset,就是 default case
  • low,case 的低边界
  • high,case 的高边界
  • jumpOffsets,每个元素都代表一个 case

LookupSwitch

tableswitch 挺像的,就是它里面储存的是键值对:

java 复制代码
public class LookupSwitch implements Instruction {

    private final int defaultOffset;
    private final int npairs;
    private final int[] matchOffsets;

    public LookupSwitch(CodeReader reader) {
        reader.skipPadding();
        defaultOffset = reader.readInt();
        npairs = reader.readInt();
        matchOffsets = reader.readInts(npairs * 2);
    }

    @Override
    public int getOpCode() {
        return 0xab;
    }

    @Override
    public void execute(StackFrame frame) {
        int value = frame.getOperandStack().popInt();
        for (int i = 0; i < 2 * npairs; i += 2) {
            if (matchOffsets[i] == value) {
                frame.setNextPc(frame.getThread().getPc() + matchOffsets[i + 1]);
                return;
            }
        }
        frame.setNextPc(frame.getThread().getPc() + defaultOffset);
    }

    @Override
    public String getReadableName() {
        return "lookupswitch";
    }
}

tableswitch 的 offsets 里面只有偏移,但是这个 offsets 里面储存了 case 的值与偏移。

发现一个问题,switch编译后无论使用哪种指令,它们的边界都是 int 值,那么Switch是如何支持String类型的呢?有兴趣的可以自行研究一下 String case 编译后的字节码,很有意思。

扩展指令

这一系列的指令其实很杂,只介绍一个 wide 吧。

加载类指令、存储类指令、ret指令和iinc指令需要按索引访问局部变量表,索引以uint8的形式存在字节码中。对于大部分方法来说,局部变量表大小都不会超过256,所以用一字节来表示索引就够了。但是如果有方法的局部变量表超过这限制呢?Java虚拟机规范定义了wide指令来扩展前述指令。

java 复制代码
/**
 * <a href="https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.wide">return new </a>
 */
public class Wide implements Instruction {

    private final Instruction instruction;

    public Wide(CodeReader reader) {
        int opCode = reader.readUnsignedByte();
        instruction = delegateInstruction(reader, opCode);
    }

    private Instruction delegateInstruction(CodeReader reader, int opCode) {
        ...
    }

    @Override
    public int getOpCode() {
        return 0xC4;
    }

    @Override
    public void execute(StackFrame frame) {
        instruction.execute(frame);
    }

    @Override
    public String getReadableName() {
        return "wide " + instruction.getReadableName();
    }

  private static class AStoreWide extends AStore {
        public AStoreWide(CodeReader reader) {
            super(reader);
        }

        @Override
        protected int readOperand(CodeReader reader) {
            return reader.readUnsignedShort();
        }
    }

  ...

}

对于 wide 指令的处理,想了很多的方案。最终我的实现是重写了很多指令,虽然麻烦,但是看起来要清晰点,具体逻辑看源代码。

解释器

上面我们实现了那么多的指令,大概有100多条,然后我们就可以实现自己的解释器了。

我们先根据解析出来的 ClassFile 对象获取到我们想要执行的方法(MemberInfo)。

有了方法信息,就可以拿到其 CodeAttribute,这个属性里面有局部变量表与操作数栈的大小,还有 code 指令。

使用这些信息,就可以执行方法了,现在我们只能执行一个方法,还是一个简单的方法,但是也是一个巨大的进步。

先创建解释器:

ini 复制代码
Interpreter interpreter = new Interpreter();

然后执行指定的类的 main 方法:

ini 复制代码
Classpath classpath = new Classpath(cmd.getClasspath());
ClassFile mainClass = new ClassFile(classpath.readClass(xxx));
MemberInfo mainInfo = mainClass.getMainMethod();
CodeAttribute mainCode = mainInfo.getCodeAttribute();

int maxLocals = mainCode.getMaxLocals();
int operandSize = mainCode.getOperandSize();
byte[] code = mainCode.getCode();

// 创建一个栈帧
StackFrame stackFrame = thread.newStackFrame(maxLocals, operandSize, code);
thread.pushStackFrame(stackFrame);
// 执行栈帧
interpreter.interpret(thread);

解释器代码:

ini 复制代码
CodeReader codeReader = new CodeReader);
do {
 StackFrame stackFrame = thread.currentStackFrame();
 
 // 更新 pc
 int pc = stackFrame.getNextPc();
 thread.setPc(pc);
 
 // 执行指令
 codeReader.reset(code, pc);
 int opCode = codeReader.readUnsignedByte();
 Instruction instruction = InstructionFactory.create(opCode, codeReader);
 
 // 每执行一条指令都需要更新 pc
 stackFrame.setNextPc(codeReader.getPc());
 
 // 调用指令的执行方法
 instruction.execute(stackFrame);
} while (xxx)

这样解释器就实现完成了,非常的简单对不对,这个时候就必须要贴一张南京大学的计算机系统实验 ics-pa 的一张图了:

解释器就是做了这个工作。

测试

arduino 复制代码
public class Test05 {
    public static void main(String[] args) {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
        System.out.println(sum);
    }
}

配置环境并执行:

输出结果:

结果是正确的,说明我们的解释器没太大问题,但是由于没有测试到所有指令,所以可能会有一些小bug,不过没关系,后面遇到了再修一修即可。

相关推荐
工呈士1 分钟前
HTML 模板技术与服务端渲染
前端·html
皮实的芒果3 分钟前
前端实时通信方案对比:WebSocket vs SSE vs setInterval 轮询
前端·javascript·性能优化
鹿九巫3 分钟前
【CSS】层叠,优先级与继承(三):超详细继承知识点
前端·css
奕云4 分钟前
react-redux源码分析
前端
咸鱼一号机5 分钟前
:global 是什么
前端
专业掘金5 分钟前
0425 手打基础丸
前端
五号厂房5 分钟前
Umi Max 如何灵活 配置多环境变量
前端
红尘散仙8 分钟前
六、WebGPU 基础入门——Vertex 缓冲区和 Index 缓冲区
前端·rust·gpu
南望无一8 分钟前
webpack性能优化和构建优化
前端·webpack
il9 分钟前
Deepdive into Tanstack Query - 2.0 Query Core 概览
前端·javascript