从本科时期学C语言的时候,我们就被教导:
++i 是先自增,后赋值 i++ 是先赋值,后自增
最近在看jvm虚拟机和java并发编程,希望从字节码的角度来进行介绍。
其实这个细节写的人已经不少了,但本人还是想稍微综合一下,更通俗、更直观地进行描述。
阅读本文需要对Java 虚拟机(Java Virtual Machine,JVM)有一定的了解,至少需要了解 字节码指令(bytecode instruction) 的含义以及 方法在JVM虚拟机栈(JVM Stack)的执行过程 。具体可以参阅《深入理解Java虚拟机:JVM高级特性与最佳实践》。
浏览下文之后,你将会理解(而不是背诵)下列代码的运行结果:
代码片段摘自于 java中i++ 和 ++i的区别
int a = 0; for(int i = 0; i < 99; i++){ a = a++; } System.out.println(a); // 输出0
int b = 0; for (int i = 0; i < 99; i++) { b = ++ b; } System.out.println(b); // 输出99
int a = 0; int b = 0; for (int i = 0; i < 99; i++) { a = a++; b = a++; } System.out.println(a); // 输出99 System.out.println(b); // 输出98
java中的字节码指令就相当于c/c++中的机器码。
开发者编写源文件(*.java),通过javac命令将源文件编译为可被JVM所能理解的class文件,其中包含了该类的常量,接口,字段,方法等信息。
对于每一个方法,其包含了行号表(LineNumber Table),局部变量表(LocalVariable Table),字节码,异常表(Exception Table)等信息。
使用JDK下的 javap
命令或者 jclasslib bytecode viewer 软件可以查看字节码信息。
每一个方法都由若干字节码指令组成, 指令会对当前方法的局部变量(Local Variables)和操作数栈(Operand Stack)产生影响 。注意,一个方法体内仅维护一个局部变量表和操作数栈;操作数栈保存了当前的变量的值,其中的元素仅通过入栈(push)和出栈(pop)两种操作进行。
具体的指令含义,可参阅Oracle官方的JVM 规范中有关指令的说明 Chapter 6. The Java Virtual Machine Instruction Set
指令名称 | 操作数栈的变化 | 描述 |
---|---|---|
iconst_<i> |
i 入栈 |
把整数 i 放入栈中 |
istore_<i> |
顶部元素出栈(pop) | 将栈顶的整数弹出,并且赋值给局部变量表中的index为 i 的元素 |
iload_<n> |
局部变量表的中元素入栈(push) | 将局部变量表中index为 n 的元素放入栈中 |
iinc i by n |
>无变化 | 对局部变量表中index为 i 的元素加上 n |
iadd |
栈顶弹出两个元素,并返回一个元素(pop, pop, push) | 从栈顶连续弹出两个元素,将其相加后的结果放回栈中 |
getstatic<i> |
将符号引用 i 放入栈中 |
将符号引用 i 放入栈中 |
invokevirtual<n> |
…, objectiveref, [arg1, [arg2…]] -> … (pop, pop, pop ,…) | 调用栈中对象objectiveref的方法n,并以arg1, arg2为该方法的参数 |
此处需要重点注意的是, iinc
自增指令直接对局部变量表的元素进行累加,而不是在栈中。
如果不了解指令集的读者可能还云里雾里的,没关系,接下来将会用图解的方法进行介绍。
如果仅有 i++
或 ++i
,如:
int i = 0; i++;
int i = 0; ++i;
其字节码指令是相同的,均为:
0 iconst_0 1 istore_1 2 iinc 1 by 1 5 return
该图描述了上述代码的字节码执行机制:第0行为向操作数栈顶添加0;第1行为将栈顶的元素存至局部变量表的index为1处的地方,也就是 i
;第2行为自增操作,注意此处没有栈的操作,在局部变量表直接将index为1的变量加1;第5行为return语句,无返回值。
通过上述的介绍,读者可能大概了解到了字节码的执行原理,注意的是此处不管是 i++
还是 ++i
,其字节码都是一样的。就好像我们在写 for(int i = 0; i<len; i++/++i)
都可以
我们来看一下出现 i++
赋值的情况:
int i = 0; i = i++; System.out.println(i); // i = 0
如何去理解自己赋值的时候 i
最后还是等于0呢?其字节码如下所示(不包含print输出语句):
0 iconst_0 1 istore_1 2 iload_1 3 iinc 1 by 1 6 istore_1 7 return
由字节码指令可以看出,当 i++;
变为 i=i++;
后,第2行通过 load
命令先将 i
的值拷贝至栈顶,然后在第3行对 i
进行自增,但此时自增在局部变量表中执行 0->1
,但是在栈中的值不变;之后在第6行上,再次将栈顶的值赋给了局部变量表中index为1,也就是赋给变量 i
,因此 i
仍然为0。
这样的结果仍然于 i++
的先赋值,后自增不冲突。 i
先复制了一份值在栈顶,自增,只不过再把栈顶赋值给左端项 i
正好还是自己。
接下来看一下 ++i
赋值的情况:
int i = 0; i = ++i; System.out.println(i); // i = 1
其字节码如下所示(不包含print输出语句):
0 iconst_0 1 istore_1 2 iinc 1 by 1 5 iload_1 6 istore_1 7 return
由字节码可以看出,在 i=++i
中,第2行为自增后,局部变量表中 i
变为1,之后第5行才从变量表中将i加载出来,第6行再赋值回去。与此同时, i=++i;
这行语句还会提示“The assignment to variable i has no effect”的信息,意为这样的赋值是没有意义的,正如字节码解释,我们把 i
的值加载出来,又赋值回去。
i++
是先加载原值到栈中,自增变量表,再把栈中的值赋给相应变量;
++i
是先自增变量表,加载其到栈中,再把栈中的值赋给相应变量。
例1(选自 用java字节码解释i++和++i ):
int i = 0; int j = 0; j = i++ + i++; System.out.println(j); //输出j=1 System.out.println(i); //输出i=2
为什么j=1, i=2呢?字节码解释为:
0 iconst_0 1 istore_1 2 iconst_0 3 istore_2 4 iload_1 5 iinc 1 by 1 8 iload_1 9 iinc 1 by 1 12 iadd 13 istore_2 14 return
由字节码可知,第0-3行为初始化 i
和 j
的值,每次计算 i++
时,都是先把 i
的旧值加载到栈中,然后 i
自增。第1次 i++
时,栈中的 i=0
,局部变量表为 i=1
;第2次 i++
时,栈中载入的就是自增后的 i
,即1,之后 i
在变量表中再次自增,此时 i=2
。最后要计算 i++ + i++
的值,因此 iadd
指令将栈中两个元素连续弹出相加得1,再将其返回值栈顶,最后 istore
指令将该值赋给 j
,运算结束。因此,我们最终得到了 i=2,j=0+1=1
的结果。
int i = 0; int j = 0; j = i++ + i++ + i++;
字节码文件如下:
0 iconst_0 1 istore_1 2 iconst_0 3 istore_2 4 iload_1 5 iinc 1 by 1 8 iload_1 9 iinc 1 by 1 12 iadd 13 iload_1 14 iinc 1 by 1 17 iadd 18 istore_2 19 return
该运算结果为 i=3,j=3
。
根据前一案例可以推导出: i++计算了3次,因此 i=3
; j=0 + 1 + 2 =3
例3: 从JVM角度看i++ 与++i
int i = 0; i = i++ + ++i; //i最终为2
字节码为:
0 iconst_0 1 istore_1 2 iload_1 3 iinc 1 by 1 6 iinc 1 by 1 9 iload_1 10 iadd 11 istore_1 12 return
具体执行流程我就不写了,相信大家看图肯定能理解的。
开头的例子我也略过去了,大家如果仔细推到的话,肯定也能推出来的。
以前总觉得讨论 i++
或者 ++i
等一些考题就好像“茴”的几种写法一样无聊,但直到看开Java虚拟机和并发之后,才真的明白了为什么 i++
操作不具备原子性,就是因为其在指令码中并不是一条指令,而是具有读取,增加,赋值三种操作的。