原始博客的地址:
该项目的地址:
上一篇文章,我们主要是实现了虚拟机栈,知道了方法执行的一个大致的流程。在这一篇文章中我们就要深入到方法的指令,实现指令的解析,以及实现一个简单的解释器。
解释器也是也是编译器的一种,java文件编译成class文件是前端编译器完成的,java程序在运行的时候对字节码的执行使用的是解释器,也叫后端编译器。

指令结构介绍

通常将指令分为两部分:
  • 操作符
  • 操作数
每条指令都以一个单字节的操作码(opcode)开头,这意味着Java虚拟机最多只能支持256条指令。
Java虚拟机规范给每个操作码都指定了一个助记符(mnemonic)。
比如操作码是0x00这条指令,因为它什么也不做,所以它的助记符是nop(no operation)。
JVM的指令中,操作数不是固定的,可能有0个,1个,2个等等。

指令的读取

前面我们解析了 class 文件,拿到了每个 method 的 code 属性,使用的是 byte[] 储存的:
要解析指令,我们需要遵循每条指令的格式。我们定义一个 CodeReader 类,它负责读取固定字节的数据并进行转换:
该类主要是读取 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 类型,具体的类型对应表如下:
notion image

常量指令

const系列指令,这一系列指令把隐含在操作码中的常量值推入操作数栈顶。
IConst_0 指令的具体意义可以看官方文档:
贴一下该指令的描述:
notion image
有了指令的描述,我们就可以写出指令的具体执行逻辑。
我们定义了一个 Instruction 接口,它表示无操作数的指令。
再看 bipush 的描述:
notion image
描述里面说,第一个操作数是一个有符号数,当作 int 处理,将这个值放入操作数的栈顶。所以代码实现如下:
Operand1Instruction 是一个抽象类,它封装了读取一个操作数的逻辑,具体可看源码。

加载指令

加载指令从局部变量表获取变量,然后推入操作数栈顶。举个例子:
notion image
iload指令后面跟了一个操作数 index
这个指令描述的很清楚:index是一个无符号数,指向的是局部变量表的索引,该索引对应的位置一定是一个 int 类型的数。
所以代码写起来就很简单:
JVM为了加快指令解析速度,还搞了一些常用的指令。
因为有一些index比较常见,就直接将index写在指令(助记符)上,不用那么的麻烦,再去读一个操作数,还可以节省 code 大小。
比如:
notion image
看一下,iload_0 的实现:
这种指令几乎占了大部分,看起来JVM的指令很多,实际上很多都是类似的。后面就不重复介绍这种类似的指令了。

储存指令

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

栈指令

栈指令直接对操作数栈进行操作,共9条:
  • pop和pop2指令将栈顶变量弹出,
  • dup系列指令复制栈顶变量,
  • swap指令交换栈顶的两个变量。
和其他类型的指令不同,栈指令并不关心变量类型。
举个例子,看看 dup指令:
notion image
就算不看描述,看 Operand Stack 也很形象,就是将栈顶的变量复制一下,将复制后的变量也推到栈顶。具体代码实现如下:
那这个指令啥时候会用到呢?实际上我们在创建对象的时候都会是一个成对的指令,比如如下代码:
编译后的字节码如下:
notion image
为啥 new 后面会跟一个 dup 指令呢?是因为创建对象会调用类的 init 方法。而 init 方法默认带一个参数,就是 this。这个 this 就是 new 指令创建出来的对象了。是不是有点神奇!!!想通这一点对后面实现方法区等很有帮助哦。
 

数学指令

数学指令又可以细分好多种,但是由于我们是使用 Java 实现 JVM,所以这一部分功能都没啥难度。比如,isub 指令,具体的实现细节还是要看 JVM 的说明哈:
notion image
注意这里的操作数栈,栈顶的是 value2,下面的是 value1。我代码里面写的是反的:
 

转换指令

类型转换指令大致对应Java语言中的基本类型强制转换操作。
我们看一个 int 转 float 的指令,i2f
因为在 java 里面 int 转 float 不需要做啥,所以代码逻辑也是非常的简单哈。
 

比较指令

比较指令是编译器实现if-else、for、while等语句的基石。
看一个比较复杂的例子:
notion image
这张图里面介绍了很多指令,但是功能类似,只不过判断条件不一样,有的是判断等于,有的是判断不等于。
我们看等于的实现:
其实也非常的简单,因为该指令后面虽然跟了两个字节,但是需要将它看作一个操作数,这个操作数表示的是一个 offset,是 pc 需要跳转的位置。
看具体的例子:
编译后的字节码:
notion image
看到 if_icmpeq 后面跟了一个 11,是 3 + 8 得出来的,所以其 offset 为 8。也就是说满足了当前的条件的话,下一条指令就要跳转到 11 位置去执行。
 

控制指令

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

goto

goto指令进行无条件跳转,我们看一个例子:
看其编译后的字节码:
notion image
可以看到 if 与 goto 基本是配对的(虽然编译后的字节码逻辑变得很奇怪,但是还是正确的)。
goto 的实现:
 

tableswitch

Java语言中的switch-case语句有两种实现方式:
  • 如果case值可以编码成一个索引表,则实现成tableswitch指令;
  • 否则实现成lookupswitch指令。
下面这个Java方法中的switch-case可以编译成tableswitch指令,代码如下:
下面这个Java方法中的switch-case则需要编译成lookupswitch指令:
可以看出,如果 case 的值是大致连续的,就编译成tableswitch,否者就是lookupswitch ,但是这个也仅仅是推测,具体还需要去查一些资料。由于 tableswitch 是按照查表得方式,根据操作数去计算出表得索引,所以这个索引也不能太大。
看看代码实现:
可以看到,这条指令后面跟了不少的操作数:
  • defaultOffset,就是 default case
  • low,case 的低边界
  • high,case 的高边界
  • jumpOffsets,每个元素都代表一个 case
 

LookupSwitch

tableswitch 挺像的,就是它里面储存的是键值对:
tableswitch 的 offsets 里面只有偏移,但是这个 offsets 里面储存了 case 的值与偏移。
发现一个问题,switch编译后无论使用哪种指令,它们的边界都是 int 值,那么Switch是如何支持String类型的呢?有兴趣的可以自行研究一下 String case 编译后的字节码,很有意思。

扩展指令

这一系列的指令其实很杂,只介绍一个 wide 吧。
加载类指令、存储类指令、ret指令和iinc指令需要按索引访问局部变量表,索引以uint8的形式存在字节码中。对于大部分方法来说,局部变量表大小都不会超过256,所以用一字节来表示索引就够了。但是如果有方法的局部变量表超过这限制呢?Java虚拟机规范定义了wide指令来扩展前述指令。
对于 wide 指令的处理,想了很多的方案。最终我的实现是重写了很多指令,虽然麻烦,但是看起来要清晰点,具体逻辑看源代码。
 

解释器

上面我们实现了那么多的指令,大概有100多条,然后我们就可以实现自己的解释器了。
我们先根据解析出来的 ClassFile 对象获取到我们想要执行的方法(MemberInfo)。
有了方法信息,就可以拿到其 CodeAttribute,这个属性里面有局部变量表与操作数栈的大小,还有 code 指令。
使用这些信息,就可以执行方法了,现在我们只能执行一个方法,还是一个简单的方法,但是也是一个巨大的进步。
先创建解释器:
然后执行指定的类的 main 方法:
解释器代码:
这样解释器就实现完成了,非常的简单对不对,这个时候就必须要贴一张南京大学的计算机系统实验 ics-pa 的一张图了:
notion image
解释器就是做了这个工作。

测试

配置环境并执行:
notion image
输出结果:
notion image
结果是正确的,说明我们的解释器没太大问题,但是由于没有测试到所有指令,所以可能会有一些小bug,不过没关系,后面遇到了再修一修即可。
 
Loading...