从字节码的角度分析i++和++i的本质区别
最近在研究广播机制的时候,碰到了一个i++类型的问题,代码节选如下:
......
r.nextReceiver = 0;
int recIdx = r.nextReceiver++;
......
在这两行代码中,因为错误的认为recIdex的结果是1,导致下面的广播发送流程理解错误,考虑到这个问题很典型,代码中经常用到,面试笔试也经常碰到,所以从字节码入手,分析下i++的原理,顺便再分析下++i的原理,然后举一个xx公司的笔试题增加理解,比如有如下代码:
public class TopwiseAdd{
public static void main(String[] args){
TopwiseAdd ta = new TopwiseAdd();
int a = ta.add(2);
System.out.println(a);
}
public int add(int i){
i = i++;
return i;
}
}
这里定义了一个add方法,传入一个int型的参数,在方法体里面将参数自增,然后返回;在main方法里面调用该方法,传入2,代码非常简单。下面通过javac -g TopwiseAdd.java来编译该类,然后通过javap -verbose TopwiseAdd
这个命令来查看add方法的字节码(其他的字节码没有贴出来),如下所示:
public int add(int);
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iload_1
1: iinc 1, 1
4: istore_1
5: iload_1
6: ireturn
LineNumberTable:
line 9: 0
line 10: 5
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this LTopwiseAdd;
0 7 1 i I
首先来解释下这个字节码。
1.flags:ACC_PUBLIC,说明此方法是public类型的;
2.code:就是方法体,不过是用虚拟机指令来表示的,其中stack=1是说该方法的操作数栈的深度是1,通俗点说就是他的操作数栈只能保存一个变量或者中间结果,反正只能保存一个值;locals=2是说该方法包含两个局部变量;args_size=2是说该方法有两个参数,第一个参数是this,第二个参数是i;0,1,4,5,6是虚拟机要执行的指令集。
3. LocalVariableTable:本地变量表,我们只需要关注Slot,Name和Signature即可。Slot = 0那行代表本地变量表的第一个参数,参数名字是this,参数类型是TopwiseAdd,前面的L代表该参数是引用类型,这就是大名鼎鼎的this变量;每个实例方法都会隐式的包含一个this参数,代表调用该方法的对象,这一点非常重要;如果是类方法的话,就不会有这个参数;Slot = 1那行代表第二个参数,名字是i,类型是整形(I代表整形)
0:iload_1:这表指令代表将局部变量表的第2个参数压入虚拟机栈;i代表入栈的是整形变量;load是指从局部变量表中获取指定的值,并压入操作数栈;_1代表将局部变量表的第2个变量入栈,0是第一个局部变量,专指this,所以1就指第二个局部变量。此时该线程的虚拟机栈分布如下:
2.png 1: iinc 1, 1 : 这行指令的作用是将局部变量表的第二个变量+1。注意,iinc应该是多个指令的集合,查看上面的字节码,iinc是第二条指令,下一个指令的编号就变成4了,说明中间还要两条指令,目前还不知道怎么把这两条指令显示出来,java虚拟机规范也没有说明。此时该线程的虚拟机栈分布如下:
3.png 4: istore_1:将操作数栈顶的值出栈并存入局部变量表的第二个变量,也就是将2存入i,可以看到,局部变量表的i=3被覆盖成i=2了,也就是说i++相当于根本没有执行。此时该线程的虚拟机栈分布如下: 4.png 5: iload_1:上面说过该指令,将将局部变量表的第2个参数压入虚拟机栈。此时该线程的虚拟机栈分布如下:
5.png 6: ireturn:方法返回,将操作数栈顶的值返回给方法调用者,结果返回的是2
从上面的分析中可以看出,i++的结果是i没有变。下面把i = i++改成i = ++i;编译后的字节码如下所示:
public int add(int);
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iinc 1, 1
3: iload_1
4: istore_1
5: iload_1
6: ireturn
LineNumberTable:
line 9: 0
line 10: 5
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this LTopwiseAdd;
0 7 1 i I
可以看出,这此的字节码跟之前的字节码有明显的区别,主要体现在第一条指令,这里的第一条指令是iinc,也就是说,对于++i这种操作,虚拟机并不会将该变量压入栈顶,而是直接把局部变量表的变量加1,以后的任何操作,包括出栈入栈,都是基于自增后的结果来操作的。刚进入该方法时,该线程的虚拟机栈分布如下:
6.png
下面逐条分析该字节码的指令:
8.png 4: istore_1:将栈顶的操作数出栈,并存入第二个局部变量。此时该线程的虚拟机栈分布如下: 9.png 5: iload_1:将第二个局部变量压入虚拟机栈。此时该线程的虚拟机栈分布如下: 10.png 6: ireturn:将栈顶的操作数出栈,并将该操作数返回给方法调用者
从上面两个分析可以看出,虚拟机执行字节码时,总是将操作数在局部变量表和操作数栈之间来回转移。i++的原理是先将局部变量表的变量入栈,然后局部变量本身自增,接着将栈顶的操作数保存到局部变量表(也就是覆盖自增操作),其结果是自增的结果被还原了;++i的原理是上来就将局部变量表的变量自增,然后入栈,接着不管在栈顶做什么操作,都是基于自增后的值来操作的,而i++都是基于自增前的值来操作的
从上面的分析来看,i++好像也没什么用啊,那为什么要搞i++这种玩意呢?想想,如果i++没用的话,那么常规的for循环是怎么循环下去的呢?既然常规的for循环能够让i真正的自增而没用被覆盖还原,那么说明i++有时候还是能够自增的,为了说明这个问题,下面把上面的代码改成下面的:
public class TopwiseAdd{
public static void main(String[] args){
TopwiseAdd ta = new TopwiseAdd();
int a = ta.add(2);
System.out.println(a);
}
public int add(int i){
i++;
return i;
}
}
这里唯一的修改就是把i = i++改成了i++,也就是把赋值运算给拿掉了,那么结果呢?我既然特意这样改了,说明结果肯定发生了变化,上面这段语句打印出来是3,下面看下这段代码add方法的字节码(这里就不画图了):
public int add(int);
descriptor: (I)I
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iinc 1, 1
3: iload_1
4: ireturn
LineNumberTable:
line 11: 0
line 12: 3
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTopwiseAdd;
0 5 1 i I
这段字节码更简单,下面逐一分析:
0: iinc 1, 1:将局部变量表的第二个局部变量(2)自增,这个指令执行完成后,局部变量表的i就等于3了
3: iload_1:将局部变量表的第二个局部变量压入栈顶,也就是将3压入栈顶
4: ireturn:栈顶的值出栈,也就是将3返回给main方法
两个i++调用,区别是一个++后有赋值操作:i = i++;另外一个没有赋值操作,而是直接返回:i++;可是两个结果却不一样,从字节码可以看出:
a.对于i = i++;这种代码,虚拟机首先将i压入栈顶(上例中的2),然后i自增,这样局部变量表的i变成了3;接着赋值,赋值是把栈顶的值赋值给局部变量表,也就是把2存入局部变量表,所以i = i++的结果是2;
b.对于i++;这种代码,从字节码来看,并不会在自增前将i压入栈顶,所以也就不存在覆盖操作;自增后i是多少就是多少;这也是i++能够驱动for循环的原因
假设有如下代码:
public int add(int i){
int a = 5;
i++;
int a = i*2;
return a;
}
假设传入的i是2,i++后,局部变量表的i是3,因为i++没有赋值操作,所以方法执行之初病不会将i压入栈顶,当然也不存在覆盖还原局部变量表的i的可能;然后i * 2的时候会把局部变量表的i压入栈顶(3),3*2 = 6,结果是6.
当然了,上面的两个例子是改革开放以来,最简单的i++和++i的例子,下面来看下一个复杂点的例子,这个例子是xx的笔试题,代码如下:
public class CyAdd{
public static void main(String[] args){
getValue(2);
}
public static int getValue(int i){
int result = 0;
switch(i){
case 1:
result = result + i;
case 2:
result = result + i*2;
case 3:
result = result++;
default:
++result;
}
return result;
}
}
原始的代码只有getValue,问传入2,最后返回的是什么。我们通过上面相同的编译命令和字节码查看命令可以看到下面的字节码(只关注getValue方法):
public static int getValue(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_0
3: tableswitch { // 1 to 3
1: 28
2: 32
3: 38
default: 43
}
28: iload_1
29: iload_0
30: iadd
31: istore_1
32: iload_1
33: iload_0
34: iconst_2
35: imul
36: iadd
37: istore_1
38: iload_1
39: iinc 1, 1
42: istore_1
43: iinc 1, 1
46: iload_1
47: ireturn
LineNumberTable:
line 7: 0
line 8: 2
line 10: 28
line 12: 32
line 14: 38
line 16: 43
line 18: 46
LocalVariableTable:
Start Length Slot Name Signature
0 48 0 i I
2 46 1 result I
大部分字节码上面分析过,不过需要特别注意的一点是,LocalVariableTable的第一个参数不是this了,原因是getValue是个类方法,类方法是不需要this的,这点要特别注意。这个方法有两个局部变量,一个是i,一个是result;操作数栈的深度是3;方法执行之初,该线程的虚拟机栈分布如下:
11.PNG
下面一条条分析字节码指令:
13.PNG 2: iload_0 :将局部变量表的第一位压入操作数栈,此时该线程的虚拟机栈分布如下:
14.PNG
3: tableswitch { // 1 to 3
1: 28
2: 32
3: 38
default: 43
}
这是switch编译出来的字节码,意思是判断栈顶元素的值,如果是1的话,就执行第28行字节码;如果是2的话,就执行第32行字节码;如果是3的话,那么执行第38行字节码;如果都不是,那么执行第43行字节码,也就是default分支,然后将i出栈。因为我们传入的是2,所以应该执行第32行字节码
这条指令有什么作用呢?此时我们是在执行case 2分支,该分支的代码是:
result = result + i*2;
可以看到,我们是要将result和i2相加,所以首先我们要把result压入操作数栈,这条指令的作用就是将result压入操作数栈。
33: iload_0 : 将局部变量表的第一位压入操作数栈,此时该线程的虚拟机栈分布如下:
17.PNG 35: imul : 该指令的作用是将栈顶的两个元素相乘并出栈,对应的代码就是i*2,此时该线程的虚拟机栈分布如下:
18.PNG 36: iadd : 将栈顶的两个元素相加,对应的代码就是result+(i * 2)的结果,此时该线程的虚拟机栈分布如下:
19.PNG 37: istore_1 : 将操作数栈顶的元素存入局部变量表的第二位,此时该线程的虚拟机栈分布如下:
20.PNG 指令执行到这里,result = result + i * 2;算是执行完毕了。
38: iload_1 : 将局部变量表的第二位压入操作数栈,此时该线程的虚拟机栈分布如下:
21.PNG 39: iinc 1, 1 : 将局部变量表的第二位+1,此时该线程的虚拟机栈分布如下:
22.PNG 42: istore_1 : 将操作数栈顶的元素存入局部变量表的第二位(自增被覆盖还原了),此时该线程的虚拟机栈分布如下:
23.PNG ps : 38-42条指令代表的代码是case 3里面的result = result++;
43: iinc 1, 1 : 将局部变量表的第二位+1,此时该线程的虚拟机栈分布如下:
24.PNG 46: iload_1 : 将局部变量表的第二位压入操作数栈,此时该线程的虚拟机栈分布如下:
25.PNG ps : 43-46条指令代表的代码是default的++result;
47: ireturn : 将操作数栈顶的元素出栈,并返回给方法调用者
至此,getValue(2)的流程执行完毕,此问题的答案是5
这个问题主要考察两个知识点:1,i++和++i的区别,区别在上面总结过;2,在case中如果没有break,代码的执行流程,如果switch的case没有break,代码会继续执行后面的case中指定的代码,一直遇到一个break或者default,这种破问题在笔试中经常碰到
注意:这是在Oracle的Hotspot虚拟机上执行的过程,也就是说是在纯Java的环境下执行的。对于非纯Java环境下执行的,比如ART虚拟机,Dalvik虚拟机环境下,虽然结果是一样的,但是执行流程是有根本上的区别的,Hotspot虚拟机是基于操作数栈的,而ART和Dalvik是基于寄存器的,对于这种基于寄存器的虚拟机,暂时还不了解,等了解了再来看下其执行流程