编译之后的 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 类:
- 常量(constants)指令
- 加载(loads)指令
- 存储(stores)指令
- 操作数栈(stack)指令
- 数学(math)指令
- 转换(conversions)指令
- 比较(comparisons)指令
- 控制(control)指令
- 引用(references)指令
- 扩展(extended)指令
- 保留(reserved)指令
注:保留指令共有 3 条。其中一条是留给调试器的,用于实现断点,操作码是202(0xCA)
,助记符是breakpoint
。另外两条留给Java
虚拟机实现内使用,操作码分别是254(0xFE)
和266(0xFF)
,助记符是impdep1
和impdep2
。这三条指令不允许出现在 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 类:
- aload 系列指令操作引用类型变量
- dload 系列操作 double 类型变量
- fload 系列操作 float 变量
- iload 系列操作 int 变量
- lload 系列操作 long 变量
- xaload 操作数组
存储指令
和加载指令刚好相反,存储指令把变量从操作数栈顶弹出,然后存入局部变量表。
栈指令
直接对操作数栈进行操作,栈指令并不关心变量类型(但是可以确定不是 long 和 double,因为这两种类型占用两个 Slot,所以这里虽然不管类型,但是可以确定是占用了一个 Slot)。共 9 条:
- pop 和 pop2 指令将栈顶变量弹出
- dup 系列指令复制栈顶变量
- swap 指令交换栈顶的两个变量
数学指令
数学指令大致对应 Java 语言中的加、减、乘、除等数学运算符。
数学指令共 37 条,包括:
- 算术指令
- 位移指令
- 布尔运算指令
- 自增指令(eg:i++)
算术指令
- 加法(add)指令
- 减法(sub)指令
- 乘法(mul)指令
- 除法(div)指令
- 求余(rem)指令
- 取反(neg)指令
位移指令
左移
右移
- 算术右移(有符号右移)
- 逻辑右移(无符号右移)
布尔运算指令
布尔运算指令只能操作 int 和 long 变量:
- 按位与(and)
- 按位或(or)
- 按位异或(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 | ifeq: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 | int chooseNear(int i) { |
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 | int chooseFar(int i) { |
因为这里的 case 语句并不是连续的,所以不能用数组来表示了,只能是一个 case 的索引,接下来该 case 对应的跳转。
扩展指令
wide 指令
加载类指令、存储类指令、ret 指令和 iinc 指令需要按索引访问局部变量表,索引以 uint8 的形式存在字节码中。对于大部分方法来说,局部变量表大小一般都不会超过 256,所以用一字节来表示索引就够了。
但是如果有方法的局部变量表超过这限制时,Java 虚拟机规范定义了 wide 指令来扩展前述指令。
wide 指令改变其他指令的行为,modifiedInstruction 字段存放被改变的指令。
ifnull/ifnonnull 指令
根据引用是否是 null 进行跳转,ifnull 和 ifnonnull 指令把栈顶的引用弹出。
goto_w 指令
goto_w 指令和 goto 指令的唯一区别就是索引从 2 字节变成了 4 字节。