深入理解JVM(五)——字节码基础

上篇文章中了解了虚拟机执行子系统中的栈和栈帧结构,并且学习了一些方法调用相关的字节码指令,对于方法的分派过程有了更深入的理解,接下来这篇文章来了解一下基础的字节码指令。

字节码指令

字节码指令主要有加载和存储指令操作数栈指令运算和类型转换指令控制转移指令等,这一部分是了解字节码的基础。

加载和存储指令

加载(load)存储(store)相关的指令是使用得最频繁的指令,分为load类、store类、常量加载这三种。

  • load类指令:将局部变量表中的变量加载到操作数栈,比如iload_0将局部变量表中下表为0的int型变量加载到操作数栈上,类似的还有lload、fload、dload、aload,分别表示加载局部变量表中long、float、double、引用类型的变量。
  • store类指令:将操作数栈栈顶的数据存储到局部变量表中,比如istore_0将操作数栈顶的元素存储到局部变量表中下表为0的位置,类似的还有lstore、fstore、dstore、astore这些指令。
  • 常量加载相关的指令,常见的有const类、push类、ldc类。const、push类指令是将常数值直接加载到操作数栈顶,ldc指令是从常量池加载对应的常量到操作数栈顶。

存储指令列表如下所示。

指 令 名 描 述
aconst_null 将null入栈到栈顶
iconst_m1 将int类型值-1压栈到栈顶
iconst_<n> 将int类型值n(0~5)压栈到栈顶
lconst_<n> 将long类型值n(0~1)压栈到栈顶
fconst_<n> 将float类型值n(0~2)压栈到栈顶
dconst<n> 将double类型值n(0~1)压栈到栈顶
bipush 将范围在-128~127的整型值压栈到栈顶
sipush 将范围在-32768~32767的整型值压栈到栈顶
ldc 将int、float、String类型的常量值从常量池压栈到栈顶
ldc_w 作用同ldc,不同的是ldc的操作码是一个字节,ldc_w的操作码是两个字节,即ldc只能寻址255个常量池的索引值,而ldc_w可以覆盖常量池所有的值
ldc2_w 将long或double类型的常量值从常量池压栈到栈顶
<T>load 将局部变量表中指定位置的int、long、float、double、引用类型、boolean、byte、char、short类型变量加载到栈上
<T>load_<n> 将局部变量表中下标为n(0~3)的变量加载到栈上,T可以为i、l、f、d、a
<T>aload 将指定数组中特定位置的数据加载到栈上,T可以为i、l、f、d、a、b、c、s
<T>store 将栈顶数据存储到局部变量表中的特定位置,T可以为i、l、f、d、a
<T>store_<n> 将栈顶变量数据存储到局部变量表中下标为n(0~3)的位置,T可以为i、l、f、d、a
<T>astore 将栈顶数据存储到数组的指定位置,T可以为i、l、f、d、a、b、c、s

操作数栈指令

常见的操作数栈指令有pop、dup和swap。pop指令用于将栈顶的值出栈,dup指令用来复制栈顶的元素并压入栈顶,swap用于交换栈顶的两个元素,如下图所示。

dup、pop、swap指令

另外还有几个复杂一点的指令,如下操作数栈指令列表所示。

指 令 名 字 节 码 描 述
pop 0x57 将栈顶数据(非long和double)出栈
pop2 0x58 弹出栈顶一个long或double类型的数据或者两个其他类型的数据
dup 0x59 复制栈顶数据并将复制的数据入栈
dup_x1 0x5A 复制栈顶数据并将复制的数据插入到栈顶第二个元素之下
dup_x2 0x5B 复制栈顶数据并将复制的数据插入到栈顶第三个元素之下
dup2 0x5C 复制栈顶两个数据并将复制的数据入栈
dup2_x1 0x5D 复制栈顶两个数据并将复制的数据插入到栈第二个元素之下
dup2_x2 0x5E 复制栈顶两个数据并将复制的数据插入到栈第三个元素之下
swap 0x5F 交换栈顶两个元素

运算和类型转换指令

针对Java中的加减乘除相关语法,字节码也有对应的运算指令,如下表示所示。

运算符 int long float double
+ iadd ladd fadd dadd
- isub lsub fsub dsub
/ idiv ldiv fdiv ddiv
* imul lmul fmul dmul
% irem lrem frem drem
negate(-) ineg lneg fneg dneg
& iand land
| ior lor
^ ixor lxor

这里需要注意的是,如果需要进行运算的数据类型不一样,会涉及到类型转换(cast),例如1.0 + 1 对应的字节码为:

1
2
3
fconst_1 // 将1.0入栈
iconst_1 // 将1入栈
fadd // 栈顶两个元素相加

这里fadd指令只支持对两个float类型的数据做相加操作,为了支持这种运算,JVM会先把int类型的数据转为float类型然后再相加,这种类型转换称为宽化类型转换(widening)

控制转移指令

控制转移指令用于有条件和无条件的分支跳转,常见的if-then-else、三目表达式、for循环、异常处理等都属于这个范畴。对应的指令集包括:

  • 条件转移:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
  • 复合条件转移:tableswitch、lookupswitch。
  • 无条件转移:goto、goto_w、jsr、jsr_w、ret。

以下面代码为例来分析字节码,它的作用是判断一个整数是否为整数。

1
2
3
4
5
6
7
public int isPositive(int n){
if (n > 0){
return 1;
}else {
return 0;
}
}

对应的字节码如下所示。

1
2
3
4
5
6
0: iload_1  // 将局部变量表中下标为1的int变量加载到操作数栈上
1: ifle 6 // 将操作数栈顶元素出栈与0比较,如果小于等于0则跳转到字节码第六行
4: iconst_1 // 4~5行对应代码”return 1“,这里先将常量1压到栈顶
5: ireturn // 将栈顶整数1出栈并返回
6: iconst_0 // 将常量0压入栈顶
7: ireturn // 将栈顶整数0出栈返回

假设n等于20,调用isPositive(20)方法操作数栈的变化情况如下图所示。

isPositive(/img/jvm/jvm_bytecode_ispositive.png)操作数栈变化所所有控制转移指令如下表所示。

指 令 名 字 节 码 描 述
ifeq 0x99 如果栈顶int型变量等于0,则跳转
ifne 0x9A 如果栈顶int型变量不等于0,则跳转
iflt 0x9B 如果栈顶int型变量小于0,则跳转
ifge 0x9C 如果栈顶int型变量大于等于0,则跳转
ifgt 0x9D 如果栈顶int型变量大于0,则跳转
ifle 0x9E 如果栈顶int型变量小于等于0,则跳转
if_icmpeq 0x9F 比较栈顶两个int型变量,如果相等则跳转
if_icmpne 0xA0 比较栈顶两个int型变量,如果不相等则跳转
if_icmplt 0xA1 比较栈顶两个int型变量,如果小于则跳转
if_icmpge 0xA2 比较栈顶两个int型变量,如果大于等于则跳转
if_icmpgt 0xA3 比较栈顶两个int型变量,如果大于则跳转
if_icmple 0xA4 比较栈顶两个int型变量,如果小于等于则跳转
if_acmpeq 0xA5 比较栈顶两个引用类型变量,如果相等则跳转
if_acmpne 0xA6 比较栈顶两个引用类型变量,如果不相等则跳转
goto 0xA7 无条件跳转
tableswitch 0xAA switch条件跳转,case值紧凑的情况下使用
lookupswitch 0xAB switch条件跳转,case值稀疏的情况下使用

for语句的字节码原理

下面通过分析一段for循环代码来理解上述字节码指令。

纵观所有的字节码指令,并没有与for名字相关的指令,那么for循环的实现原理是怎样的呢?

以下列为例,看看JVM是如何处理高级for循环的。

1
2
3
4
5
6
7
public int sum(int[] numbers) {
int sum = 0;
for (int num : numbers) {
sum += num;
}
return sum;
}

同样的,使用 javac -p 命令编译后,通过javap -c -v -l 来查看字节码:

sum方法字节码

为了方便理解,我们可以把局部变量表画出来,如下图所示。

sum方法局部变量表

下面以执行sum(new int[]{0,20,30});为例来逐行分析字节码执行过程。

第0~1行:把常量0加载到操作数栈上,随后通过istore_2指令将0出栈赋值给局部变量表中下标为2的元素,就是将sum赋值为0。此时的局部变量表和操作数栈如下图所示。

for循环执行细节(1)

第2~9行:初始化循环控制变量,可以使用如下伪代码表示。

1
2
3
$array = numbers;
$len = $array.arraylength
$i = 0

第2~3行:aload_1指令的作用是加载局部变量表中下标为1的变量(参数numbers),astore_3指令的作用是将栈顶元素存储到局部变量表下标为3的位置上,这里记为$array,如下图所示。

for循环执行细节(2)

第4~6行:计算数据的长度,astore_3加载$array到栈顶,调用arrayLength指令获取数组长度存储到栈顶,随后调用istore 4将数组长度存储到局部变量表的第4个位置,这里记为$len,如下图所示。

for循环执行细节(3)

第8~9行:初始化数组遍历的下标初始值。iconst_0将0加载到操作数栈上,随后istore 5将栈顶的0存储到局部变量表中的第5个位置,这个局部变量是数据变量的下表初始值,这里记为$i,如下图所示。

for循环执行细节(4)

第11~32行是循环体执行过程。

第11~15行的作用是判断循环能否继续:

1
2
3
11: iload         5
13: iload 4
15: if_icmpge 35

首先通过iload 5和iload 4加载局部变量表中下标5和4的变量到栈顶,参照上图中的局部变量表我们知道下标5和下标4的变量分别是数组下标$i和数组长度$len。

接着会调用if_icmpge进行条件判断,如果$i >= $len,则直接跳转到第35行指令处,for循环结束,否则继续往下执行。过程如下图所示。

for循环执行细节(5)

第18~22行的作用是把$array[$i]赋值给number。aload_3加载$array到栈上,iload 5加载$i到栈上,然后iaload指令把下标为$i的数组元素加载到操作数栈上,随后istore 6将栈顶元素存储到局部变量表下标为6的位置上,如下图所示。

for循环执行细节(6)

第24~28行:iload_2 和 iload 6指令把sum和number值加载到操作数栈上,然后执行iadd指令进行整数相加,如下图所示。

for循环执行细节(7)

第29行:”iinc 5,1“指令对执行循环后的$i加一。iinc指令比较特殊,它并不依赖于操作数栈,而是直接对局部变量进行自增,再将结果出栈存储到局部变量表,因此效率非常高。

for循环执行细节(8)

第32行:goto 11指令无条件跳转到第11行继续进行循环条件判断。

经过上述分析,很容易发现所谓”高级”for循环翻译成字节码后实际上与普通的for循环并无差别,只是个语法糖而已。

swtich-case底层实现原理

switch-case语法实现原理依赖于tableswitch和lookupswtich两条字节码指令。先来看下面的与其字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public int chooseNear(int i) {
switch (i) {
case 100:
return 0;
case 101:
return 1;
case 104:
return 4;
default:
return -1;
}
}

// 对应字节码
0: iload_1
1: tableswitch { // 100 to 104
100: 36
101: 38
102: 42
103: 42
104: 40
default: 42
}
36: iconst_0
37: ireturn
38: iconst_1
39: ireturn
40: iconst_4
41: ireturn
42: iconst_m1
43: ireturn

这里使用了tableswitch指令,之前说过tableswitch指令在case比较紧凑的情况下使用。这里需要特别注意的是,代码中并没有102和103两个case,字节码中却自动帮我们“补齐”了,实际上是编译器对“紧凑”case的优化:一是使用tableswitch;二是case出现断层时自动补齐为连续的值。这样的好处是由于case是连续的所以可以以O(1)的时间复杂度来进行查找。

再看一个case比较“稀疏”的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public int chooseFar(int i) {
switch (i) {
case 1:
return 1;
case 100:
return 100;
case 10:
return 10;
default:
return -1;
}
}
// 对应字节码
0: iload_1
1: lookupswitch { // 3
1: 36
10: 38
100: 41
default: 44
}
36: iconst_1
37: ireturn
38: bipush 10
40: ireturn
41: bipush 100
43: ireturn
44: iconst_m1
45: ireturn

可以看到这种情况case将不会被补齐,而且没有使用tableswitch而是lookupswitch字节码指令,并且case会经过排序,使得lookupswitch可以以二分查找的方式进行case的查找,时间复杂度为O(log n)。

String的switch-case实现的字节码原理

上面讨论的两种情况都是基于case是整形数值的情况,Java中支持在Swtich中使用String,那么这是如何实现的呢?

同样以一段代码为例:

1
2
3
4
5
6
7
8
9
10
public int stringSwitch(String name) {
switch (name) {
case "Java":
return 100;
case "Kotlin":
return 200;
default:
return -1;
}
}

对应字节码如下图:

对String进行switch对应字节码

这里有几个比较关键的点,第5行中调用了String#hashCode()方法,并且选择使用lookupswitch指令,判断case后跳转到对应的行数。

由于hash值相同的String也有可能是不同字符串,因此随后会调用String#equals()方法来判断是否是相等。这里使用了ifeq指令,ifeq用于判断栈顶数据是否等于0,是则跳转到对应的行数,相当于等于false时跳转。如果相等将会继续执行,使用iconst_0将常量0压入栈顶,表示匹配了case0,或者使用iconst_1将常量1压入栈顶,表示匹配了case1,随后使用istore_3存入局部变量表。

接着就是第61~96行的处理了。61行拿到刚刚存入的0或者1,通过lookupswitch来判断跳转到不同的字节码行数执行case里面的操作。

++i和i++的字节码源码

在日常开发中我们通常不会使用++i和i++这两种容易让人困惑的自增方式,而使用更加清晰的i = i + 1,不过它们很容易在面试中作为陷阱题出现,例如下列代码:

1
2
3
4
5
6
7
public void inc() {
int i = 0;
for (int j = 0; j < 50; j++) {
i = i++;
}
System.out.println(i);
}

这段代码的输出结果是什么呢?答案是0,查看 i = i++ 的字节码如下:

1
2
3
4
5
···
10: iload_1
11: iinc 1, 1
14: istore_1
···

第10行:iload_1把局部变量表slot = 1的变量(i)加载到操作数栈上。

第11行:“iinc 1,1”对局部变量表slot = 1的变量(i)直接加1,但是这时候栈顶的元素没有变化,还是0.

第14行:istore_1 将栈顶元素出栈赋值给局部变量表slot = 1的变量,也就是i。此时,局部变量i又被赋值为0,前面的iinc指令对i的加一操作被覆盖。

整个过程局部变量表和操作数栈的变化如下图所示。

i = i++ 字节码执行过程

如果把上面代码的i = i++替换成 i = ++i,则可以正常输出打印50。替换后的字节码如下:

1
2
3
4
5
···
10: iinc 1, 1
13: iload_1
14: istore_1
···

与上面i++的字节码不同的是,这里先对局部变量表中第1位变量进行了加一,然后将其加载到操作数栈,随后重新存储到局部变量表中。

对象相关的字节码指令

1.<init>方法

<init>方法是对象初始化方法,类的构造方法、非静态变量的初始化、对象初始化代码块都会被编译进这个方法中。例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Initializer {
// 初始化变量
private int a = 10;

// 构造器方法
public Initializer() {
int c = 30;
}

// 对象初始化代码块
{
int b = 20;
}
}

对应字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Initializer();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 10
7: putfield #2 // Field a:I
10: bipush 20
12: istore_1
13: bipush 30
15: istore_1
16: return

Initializer()方法对应<init>对象初始化方法,其中57行将成员变量a赋值为10,1012行将b赋值为10,13~15行将c赋值为30。可以看到,虽然我们将变量a和变量b分别放在构造方法外和构造代码块中初始化,实际上也会统一编译到<init>方法里面。

2.new、dup、invokespecial对象创建三条指令

Java 中通过 new 关键字来创建对象,字节码中也有一个叫 new 的指令,但两者不是一回事。以下面的代码为例:

1
Initializer initializer = new Initializer();

编译后对应字节码:

1
2
3
4
0: new           #3                  // class Initializer
3: dup
4: invokespecial #4 // Method "<init>":()V
7: astore_1

出去第 7 行的存储指令,new 关键字经过编译后实际上生成了 new-dup-invokespecial 3行字节码。new指令很容易理解,invokespecial 则是用于调用对象<init>方法来初始化对象,那么中间这个dup指令的作用是什么呢?

dup指令的含义是复制栈顶的数据并且插入到栈顶,在第0行时通过new指令创建了Initializer的一个实例然后加入到栈顶,接着如果直接通过invokespecial调用其构造方法,由于invokespecial会消耗栈顶的类实例引用,会导致操作数栈为空,使得刚刚创建的对象丢失。因此在invokespecial调用前需使用dup复制一份实例,随后就可以通过astore_1指令将其存入局部变量表了。

3.<clinit>方法

<clinit>是类的静态初始化方法,同样的,类静态初始化代码块、静态变量初始化都会被编译进这个方法中。javap输出字节码中的 static{} 表示<cinit>方法。

参考