最近在朋友圈看到一篇解释 i++
和 ++i
的文章,觉得非常有意思,就把要点记录下来。
你的解释不是我想要的…
先看两段代码 HelloWorld_1
和 HelloWorld_2
:
1 | // HelloWorld_1.java |
1 | // HelloWorld_2.java |
大家应该一眼就知道运行结果,第一段代码是 0,第二段代码是 1,但关键是:为什么只是换了一下顺序,结果就不一样了?
一般的解释:i = i++
,会先赋值,再加一,所以结果是 0,而 i = ++i
,会先把 i 加一,然后再赋值,所以结果是 1。
全场感叹,都向那位同学投以敬佩的目光,毕竟他的理论足以解释现象。
然而,提问的人说了一句,“你的解释不是我想要的……”
一般的解释不能说不对,但是没有说到关键点上,因为这是以高级语言的角度来解释的。为了深入理解,我们需要从更低级的层面去获取答案。
Java 的跨平台靠 JVM(Java 虚拟机)这个翻译官来实现。你写好的代码,会被编译成一个 .class
文件,也就是 Java 字节码文件,这里面记录的是一系列要在 JVM 执行的指令。接着,你拿着这份字节码指令,去到任意一个 JVM(Linux 的 JVM、OS X 的 JVM),它们都会帮你把它翻译成不同平台所对应的机器指令(机器码)。这就实现了跨平台。
回到问题,i++
和 ++i
为什么会不一样呢?我们先通过 javac
和 javap
命令获得字节码指令。
1 | javac HelloWorld_1.java |
可以得到 HelloWorld_1.class
文件。class 文件里面都是二进制的数据,因为这些都是告诉 JVM 要做什么事情的指令,而机器只看得懂二进制。所以,我们需要对这个二进制反汇编,把它变成人类看得懂的语言。
1 | javap -c HelloWorld_1.class |
注意:为了方便比较,源代码中的 System.out.println(i)
已被删掉。
命令执行后,控制台打印出一系列的字节码指令,其中main函数的字节码指令如下:
1 | public static void main(java.lang.String[]); |
这一串的指令,主要涉及到两个数据结构,一个是操作数栈(operand stack),另一个是局部变量表(local variable)。前者是栈,后者是数组。
iconst_0
把一个值为 0 的 int 值压到操作数栈中。
istore_1
从操作数栈中弹出一个值,存放到局部变量表下标为 1 的位置(为什么不是 0 或者 2 ?因为前面有个 String 数组的参数 args,所以不是 0。又因为方法是 static 的,没有 this 引用,所以不是 2)。
以上两条指令对应的是第一行代码
int i = 0
,它实现了给 i 赋值,并且把 i 放到局部变量表的功能。接下来是i = i++
对应的指令。iload_1
把局部变量表中索引为 1 的值压到操作数栈中(表中的值仍存在)。
iinc 1, 1
对局部变量表索引为 1 的值进行加一操作。iinc 指令第一个参数代表索引值,第二个参数代表加多少。
此时,局部变量表中 i 的值等于 1,为什么最后打印出来还是 0 呢?问题出在最后一条指令。
istore_1
从操作数栈中弹出一个值,将它赋值给局部变量表中索引为 1 的值。
完蛋,这下 i 又变成 0 了!
这就是 i 为 0 的本质原因。向别人解释的时候可以总结为以下三点:
- 赋值的本质是两步操作,先将等号右边的值压进操作数栈(iload),再将操作数栈中的值弹出至局部变量表中对应的位置上(istore)。
- ++ 的本质是
iinc
指令,操作的对象局部变量表,而不是操作数栈。 i = i++
对应三条指令的顺序是:iload_1、iinc 1,1、istore_1。
至于 i = ++i
,它与前者的区别在 iload_1 和 iinc 的顺序上。当 iinc 在前,赋值前局部变量表中对应的值已为 1,且赋值操作的两条指令相邻,不存在归零现象。
最后,本文所提到的操作数栈和局部变量表只是 JVM 运行时数据区域中很小的一块(JVM Stack, 虚拟机栈),完整的模型图如下: