原始博客的地址:
该项目的地址:
上一篇文章,我们主要是实现了虚拟机栈,知道了方法执行的一个大致的流程。在这一篇文章中我们就要深入到方法的指令,实现指令的解析,以及实现一个简单的解释器。
解释器也是也是编译器的一种,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
指令的具体意义可以看官方文档:
贴一下该指令的描述:
有了指令的描述,我们就可以写出指令的具体执行逻辑。
我们定义了一个 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 位置去执行。
控制指令
这里主要讨论 goto
、tableswitch
和 lookupswitch
这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-cas
e可以编译成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,不过没关系,后面遇到了再修一修即可。