对于 a = i++a = ++i 的区别,一般回答是:一个是“先赋值再自增”,另一个是“先自增再赋值”,这个回答无可厚非,但不够深入,本文主要从字节码分析二者的不同。

JVM 内存布局

下图是一个经典的 JVM 内存分布图,本文仅对与本文内容有关的 JVM Stacks 进行简要介绍。
string.svg
*本图参考了《码出高效 —— Java 开发手册》

JVM Stacks 是描述 Java 方法执行的内存区域,它是线程私有的。

栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到被执行完成的过程,就是栈帧从入栈到出栈的过程。

栈帧是方法运行的基本结构,一个方法在被执行时,所有指令都只能针对当前栈帧进行操作。

栈帧

一个栈帧包括局部变量表、操作栈、动态连接、方法返回地址等,这里仅对局部变量表和操作栈进行简要介绍。

  • 局部变量表是存放方法参数和局部变量的区域,最小的存储单元称为 Slot(槽);
  • 操作栈是一个初始状态为空的桶式结构栈,在方法执行过程中,会有各种指令往栈中写入和提取信息。与局部变量表不同的是,操作栈对于数据的访问不是通过下标,而是通过标准的栈操作来进行的。

下面以 i++ 和 ++i 为例,说明操作栈与局部变量表的交互。

i++ 与 ++i

我们先对源代码进行编译,再用 javap 工具进行反编译。

  • a = i++

    1. // 源码
    2. public int method(int i) {
    3. int a = i++;
    4. return a;
    5. }
    1. // 字节码
    2. public int method(int);
    3. descriptor: (I)I
    4. flags: (0x0001) ACC_PUBLIC
    5. Code:
    6. stack=1, locals=3, args_size=2
    7. 0: iload_1 // 将局部变量表1号槽位的元素的值(即变量i的值)压入操作栈
    8. 1: iinc 1, 1 // 局部变量表1号元素自增1
    9. 4: istore_2 // 将操作栈栈顶元素弹出,并将值赋值给局部变量表2号槽位元素(即变量a)
    10. 5: iload_2 // 将局部变量表2号槽位元素的值压入操作栈
    11. 6: ireturn // 返回操作栈栈顶元素
    12. LocalVariableTable:(局部变量表)
    13. Start Length Slot Name Signature
    14. 0 7 0 this Ltest/Demo;
    15. 0 7 1 i I
    16. 5 2 2 a I
  • a = ++i

    1. // 源码
    2. public int method(int i) {
    3. int a = ++i;
    4. return a;
    5. }
    1. // 字节码
    2. public int method(int);
    3. descriptor: (I)I
    4. flags: (0x0001) ACC_PUBLIC
    5. Code:
    6. stack=1, locals=3, args_size=2
    7. 0: iinc 1, 1 // 局部变量表1号元素(即变量i)自增1
    8. 3: iload_1 // 将局部变量表1号槽位的元素的值压入操作栈
    9. 4: istore_2 // 将操作栈栈顶元素弹出,并将值赋值给局部变量表2号槽位元素(即变量a)
    10. 5: iload_2 // 将局部变量表2号槽位元素的值压入操作栈
    11. 6: ireturn // 返回操作栈栈顶元素
    12. LocalVariableTable:(局部变量表)
    13. Start Length Slot Name Signature
    14. 0 7 0 this Ltest/Demo;
    15. 0 7 1 i I
    16. 5 2 2 a I

我们对比两份字节码,不同之处如下所示:

a = i++ a = ++i
0: iload_1
1: iinc 1, 1
4: istore_2
0: iinc 1, 1
3: iload_1
4: istore_2

综上所述,从字节码分析来看,i++ 是先将变量 i 的值入栈,再对局部变量表中的 i 进行自增;++i 是先对局部变量表中的 i 进行自增,再将 i 的值压入操作栈。

上面的结论有意的指出 iload_1 命令是元素的值入栈,而不是元素入栈,这是为了避免使用“元素入栈”而让人觉得 iinc 1, 1 命令会使已经入栈的值自增 1,其实 iinc 命令仅操作局部变量表中的元素,对操作栈无影响。

一道面试题

  1. // 问题:该函数的返回值是多少?
  2. public int method() {
  3. int count = 0;
  4. for (int i = 0; i < 10; i++) {
  5. count = count++;
  6. }
  7. return count; // return 0
  8. }

上述代码的返回值为 0,我们先对源代码进行编译,再反编译为字节码。

  1. public int method();
  2. descriptor: ()I
  3. flags: (0x0001) ACC_PUBLIC
  4. Code:
  5. stack=2, locals=3, args_size=1
  6. 0: iconst_0 // 将常量0入入栈
  7. 1: istore_1 // 将栈顶元素弹出,并赋值给局部变量表1号槽位(对应的变量)
  8. 2: iconst_0 // 将常量0入入栈
  9. 3: istore_2 // 将操作栈栈顶元素弹出,并赋值给局部变量表2号槽位(对应的变量)
  10. 4: iload_2 // 将局部变量表2号槽位的值入栈
  11. 5: bipush 100 // 将常量100入栈
  12. 7: if_icmpge 21 // 如果栈顶元素的值大于等于其后一个元素的值,则跳至第21行
  13. 10: iload_1 // 将局部变量表1号槽位的值入栈
  14. 11: iinc 1, 1 // 局部变量表1号槽位自增1
  15. 14: istore_1 // 将操作栈栈顶元素弹出,并赋值给局部变量表1号槽位(对应的变量)
  16. 15: iinc 2, 1 // 局部变量表1号槽位自增1
  17. 18: goto 4 // 跳转到第4行
  18. 21: iload_1 // 将局部变量表1号槽位的值入栈
  19. 22: ireturn // 返回栈顶元素
  20. LocalVariableTable:(局部变量表)
  21. Start Length Slot Name Signature
  22. 4 17 2 i I // 2号槽位
  23. 0 23 0 this Ltest/Demo;
  24. 2 21 1 count I // 1号槽位

使得返回值为 0 的关键在于下面 3 行字节码:

  1. 10: iload_1 // 将局部变量表1号槽位的值入栈
  2. 11: iinc 1, 1 // 局部变量表1号槽位自增1
  3. 14: istore_1 // 将操作栈栈顶元素弹出,并赋值给局部变量表1号槽位(对应的变量)

下图模拟了这三条指令的执行过程。
string.svg

该题也提醒我们 i++ 其实并非原子操作,即使对 i 使用 volatile 关键字修饰,如果多线程进行写操作,同样会产生数据互相覆盖的问题。