编译之后的 Java 方法以字节码的形式存储在 class 文件中。前面已经初步实现了 Java 虚拟机栈、帧、操作数栈和局部变量表等运行时数据区。那么 JVM 是如何操作局部变量表和操作数栈中的数据呢?这就涉及到了 JVM 的字节码指令集。这一节对 JVM 的字节码指令集做一个介绍,本节所实现的指令位于项目的 instructions 包下。

字节码简介

字节码是运行在虚拟机上的机器码,每一个类或者接口都会被 Java 编译器编译成一个 class 文件,类或接口的方法信息就放在 class 文件的 method_info 结构中。如果方法不是抽象的,也不是本地方法,方法的 Java 代码就会被编译器编译成字节码(即使方法是空的,编译器也会生成一条 return 语句),存放在 method_info 结构的Code 属性中。 ==> 字节码一般来说是针对方法中的步骤的;

指令简介

字节码中存放编码后的 Java 虚拟机指令。

指令 = 操作码 + 操作数(如果有的话)

  • 操作码(Opcode):一个字节长度,代表着某种特定操作含义。
  • 操作数(Operands):操作码后面需要跟零到多个代表该操作码所需的参数。

所以 Java 虚拟机使用的是变长指令

如果把指令想象成函数的话,操作数就是它的参数。每条指令都以一个单字节的操作码(opcode)开头。由于只使用一字节表示操作码,显而易见,Java 虚拟机最多只能支持 256(2^8 )条指令。

Java 虚拟机规范已经定义了 205 条指令,操作码分别是 0(0x00)到 202(0xCA)、254(0xFE)和 255(0xFF)。这 205 条指令构成了 Java 虚拟机的指令集(instruction set)。

字节码指令是一种具有鲜明特点,优劣势都很突出的指令集架构。

缺点:由于 Class 文件格式放弃了将编译后操作数长度对齐,那么如果处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据,eg:将一个 16 位无符号整数用两字节(byte1,byte2)存储起来,那么在读取该 16 位整数时,需要这样转换:( byte1 << 8 ) | byte2,这导致解释执行操作码时损失了一些性能。

优点:放弃了操作数长度对其,意味着可以省略很多填充和间隔符号,尽可能获取短小精干的编译代码(小数据量,高效传输)

操作码的数据类型

为了让编码后的字节码更加紧凑,很多操作码本身就隐含了操作数,比如把常数 0 推入操作数栈的指令是 iconst_0。

操作数栈局部变量表只存放数据的值,并不记录数据类型。结果就是:指令必须知道自己在操作什么类型的数据。这一点也直接反映在了操作码的助记符上。

例如:

iadd 指令就是对 int 值进行加法操作;

dstore 指令把操作数栈顶的 double 值弹出,存储到局部变量表中;

areturn 从方法中返回引用值。

也就是说,如果某类指令可以操作不同类型的变量,则助记符的第一个字母表示变量类型

助记符首字母 数据类型 例子
a reference aload、astore、areturn
b byte/boolean bipush、baload
c char caload、castore
d double dload、dstore、dadd
f float fload、fstore、fadd
i int iload、istore、iadd
l long lload、lstore、ladd
s short sipush、sastore

并非每个基本数据类型都有对应的操作码,从上图可以看到大部指令都没有直接支持 byte、char、short、boolean,编译器会在编译器或者运行期将 byte 和 short 类型的数据带符号扩展为相应的 int 类型,将 boolean 和 char 类型数据零位扩展为相应的 int 类型。因此,大多数对于 byte、char、short、boolean 的操作,实际上都是使用相应的 int 类型作为运算类型。

指令的分类

因为使用一字节表示操作码,所以 Java 虚拟机最多只能支持 256(2^8 )条指令。

Java 虚拟机规范已经定义了 205 条指令,操作码分别是 0(0x00)到 202(0xCA)、254(0xFE)和 255(0xFF)。这 205 条指令构成了 Java 虚拟机的指令集(instruction set)。

Java 虚拟机规范把已经定义的 205 条指令按用途分成了 11 类:

  1. 常量(constants)指令
  2. 加载(loads)指令
  3. 存储(stores)指令
  4. 操作数栈(stack)指令
  5. 数学(math)指令
  6. 转换(conversions)指令
  7. 比较(comparisons)指令
  8. 控制(control)指令
  9. 引用(references)指令
  10. 扩展(extended)指令
  11. 保留(reserved)指令

注:保留指令共有 3 条。其中一条是留给调试器的,用于实现断点,操作码是202(0xCA),助记符是breakpoint。另外两条留给Java虚拟机实现内使用,操作码分别是254(0xFE)266(0xFF),助记符是impdep1impdep2。这三条指令不允许出现在 class 文件中。

接下来这上述的 11 类指令做一个简单的介绍,并在每种类别的指令中跳出几个有代表性的进行简要说明。

常量指令

常量指令把常量推入操作数栈顶。常量可以来自三个地方:隐含在操作码里、操作数和运行时常量池。

nop 指令

nop 指令是最简单的一条指令,因为它什么也不做。

const 系列指令

这一系列指令把隐含在操作码中的常量值推入操作数栈顶。几个具体的例子如下:

  • aconst_null 指令把 null 引用推入操作数栈顶

  • dconst_0 指令把 double 型 0 推入操作数栈顶

  • iconst_m1 指令把 int 型 -1 推入操作数栈顶

bipush 和 sipush 指令

bipush 指令从操作数中获取一个 byte 型整数,扩展成 int 型,然后推入栈顶

sipush 指令从操作数中获取一个 short 型整数,扩展成 int 型,然后推入栈顶

加载指令

加载指令从局部变量表获取变量,然后推入操作数栈顶。加载指令共 33 条,按照所操作变量的类型可以分为 6 类:

  1. aload 系列指令操作引用类型变量
  2. dload 系列操作 double 类型变量
  3. fload 系列操作 float 变量
  4. iload 系列操作 int 变量
  5. lload 系列操作 long 变量
  6. xaload 操作数组

存储指令

和加载指令刚好相反,存储指令把变量从操作数栈顶弹出,然后存入局部变量表。

栈指令

直接对操作数栈进行操作,栈指令并不关心变量类型(但是可以确定不是 long 和 double,因为这两种类型占用两个 Slot,所以这里虽然不管类型,但是可以确定是占用了一个 Slot)。共 9 条:

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

数学指令

数学指令大致对应 Java 语言中的加、减、乘、除等数学运算符。

数学指令共 37 条,包括:

  • 算术指令
  • 位移指令
  • 布尔运算指令
  • 自增指令(eg:i++)

算术指令

  1. 加法(add)指令
  2. 减法(sub)指令
  3. 乘法(mul)指令
  4. 除法(div)指令
  5. 求余(rem)指令
  6. 取反(neg)指令

位移指令

  1. 左移

  2. 右移

    1. 算术右移(有符号右移)
    2. 逻辑右移(无符号右移)

布尔运算指令

布尔运算指令只能操作 int 和 long 变量:

  1. 按位与(and)
  2. 按位或(or)
  3. 按位异或(xor)

iinc 指令

iinc 指令给局部变量表中的 int 变量增加常量值,局部变量表索引和常量值都由指令的操作数提供。

类型转换指令

类型转换指令可以分为 4 种:

  • i2x 系列指令把 int 变量强制转换成其他类型
  • l2x 系列指令把 long 变量强制转换成其他类型
  • f2x 系列指令把 float 变量强制转换成其他型
  • d2x 系列指令把 double 变量强制转换成其他类型

注意:引用类型转换对应的是 checkcast 指令,在后面的章节会出现。

比较指令

比较指令可以分为两类:

  • 将比较结果推入操作数栈顶。
  • 根据比较结果跳转。

lcmp 指令

用于比较 long 变量,把栈顶的两个 long 变量弹出,进行比较,然后把比较结果(int 型 0、1 或 -1)推入栈顶。

fcmp 指令

指令和 lcmp 指令很像,但是除了比较的变量类型不同以外,还有一个重要的区别:由于浮点数计算有可能产生 NaN(Not a Number)值,所以比较两个浮点数时,除了大于、等于、小于之外, 还有第 4 种结果:无法比较

fcmpg 和 fcmpl 指令的区别就在于对第 4 种结果的定义;
当两个 float 变量中至少有一个是 NaN 时,用 fcmpg 指令比较的结果是 1,而用 fcmpl 指令比较的结果是 -1。

dcmp 指令

dcmpg 和 dcmpl 指令用来比较 double 变量,这条指令和 fcmp 指令只是比较的变量类型不同。

if<cond>指令

if<cond>指令把操作数栈顶的 int 变量弹出,然后跟 0 进行比较,满足条件则跳转。假设从栈顶弹出的变量是 x,则指令执行跳转操作的条件如下:具体判定之后如何跳转,现在先不讨论;

1
2
3
4
5
6
ifeq:x==0
ifne:x!=0
iflt:x<0
ifle:x<=0
ifgt:x>0
ifge:x>=0

if_icmp<cond>指令

if_icmp<cond>指令把栈顶的两个 int 变量弹出,然后进行比较,满足条件则跳转。跳转条件和 if指令类似。

if_acmp<cond>指令

把栈顶的两个引用弹出,根据引用是否相同进行跳转。

控制指令

分类:

  • goto

  • tableswitch

  • lookupswitch

goto

goto 指令进行无条件跳转,其操作码后是一个 int16 类型的操作数,所以需要读两个字节并转成成 int16 类型,该操作数即偏移 offset,那么接下来要执行的指令的位置为:pc + offset

tableswitch

Java 语言中的 switch-case 语句有两种实现方式:如果 case 值可以编码成一个索引表(索引值是连续的),则实现成 tableswitch 指令;否则实现成 lookupswitch 指令

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

1
2
3
4
5
6
7
8
int chooseNear(int i) {
switch (i) {
case 0: return 0;
case 1: return 1;
case 2: return 2;
default: return -1;
}
}

tableswitch 后跟的操作数是对应 case 分支的跳转 offset,其格式类似于:

default 分支对应的跳转 defaultOffset,0 分支对应的索引 low(这里就是 0),2 分支对应的索引 high(这里就是 2),因为 02 分支对应的跳转是连续的,所以接下来分别对应 02 分支的跳转 offset。

这里用一个数组存放最合适不过了,数组大小就是high-low+1,这里拿到一个分支跳转的 case 的索引 index,先判断 index 在不在 low 和 high 之间,如果不在,其 offset 就是 defaultOffset,否则就用从数组中找 arr[index-low],拿到对应的 offset。

lookupswitch

(索引值是非连续的)下面这个 Java 方法中的 switch-case 则需要编译成 lookupswitch 指令:

1
2
3
4
5
6
7
8
int chooseFar(int i) {
switch (i) {
case -100: return -1;
case 0: return 0;
case 100: return 1;
default: return -1;
}
}

因为这里的 case 语句并不是连续的,所以不能用数组来表示了,只能是一个 case 的索引,接下来该 case 对应的跳转。

扩展指令

wide 指令

加载类指令、存储类指令、ret 指令和 iinc 指令需要按索引访问局部变量表,索引以 uint8 的形式存在字节码中。对于大部分方法来说,局部变量表大小一般都不会超过 256,所以用一字节来表示索引就够了。

但是如果有方法的局部变量表超过这限制时,Java 虚拟机规范定义了 wide 指令来扩展前述指令。

wide 指令改变其他指令的行为,modifiedInstruction 字段存放被改变的指令。

ifnull/ifnonnull 指令

根据引用是否是 null 进行跳转,ifnull 和 ifnonnull 指令把栈顶的引用弹出。

goto_w 指令

goto_w 指令和 goto 指令的唯一区别就是索引从 2 字节变成了 4 字节。