通过一道面试题来说明i=i++和JVM操作栈原理

在正式开始讲之前,我们先来看一下一道题目,大家可以尝试思考一下

检查程序,是否存在问题,如果存在指出问题所在,如果不存在,说明输出结果。


package algorithms.com.guan.javajicu; 
public class Inc { 
    public static void main(String[] args) { 
       Inc inc = new Inc(); 
       int i = 0; 
       inc.fermin(i); 
       i= i ++; 
       System.out.println(i);
    
    } 
    void fermin(int i){ 
       i++; 
    } 
}

A、0 B、1 C、2 D、3

答案是选A,你答对了吗?

我们可以单独看问题中的这两句代码:

int i=0;
i=i++;

根据我们通常所知道的后自增(i++)的先返回在自增的道理(可以将i++理解为先人后己,++i为先己后人),i++在返回后i自身会增加一个数值,初始值为0的时候,自增后就是1,那么输出结果应该是1,但实际输出的确实0

我们先来看看我们之前常会遇到的:

int i=0;
System.out.println(i++);//输出0.先返回后自增
System.out.println(i);//输出1,这时才已经自增输出
int i=0;
System.out.println(++i);//输出1.先自增再返回
System.out.println(i);//输出1

可以看出,题目中和我们之前经常遇到的情况有所不一样的地方在于:i++后,还把在这个值重新赋值给了i,问题就是出在这里:

这是因为JVM在处理i=i++时会建立一个临时变量来接收i++的值,然后返回这个临时变量的值,返回的是再被等号左边的变量接受了,这样就是说i虽然确实自增了但是又被赋值了0,这样输出的结果自然就是0了。

不妨我们用temp临时变量来接收i++值,来看一下结果:

int i=0;
int temp=i++;
System.out.println(temp);//2、3两句合并起来就是我们之前举例的System.out.println(i++);

输出是0;

也就是说赋值号右边传递过来的是i未自增前的值,这也符合后自增(i++)先返回后自增的原则,这时我们 再输出以下i的值:

int i=0;
int temp=i++;
System.out.println(temp);//输出0
System.out.println(i);//输出1 i自增完成

可以看出i也的确自增了一个值,只不过题目中,在自增前就将i返回了,且重新赋给了i。这就可以解释为什么题目中i=i++输出的是0了,因为i在自增后又被等式右边的值给覆盖了,也就是说第一个i的值实际上发生了三次变化,第一次是我们定义的i=0;第二次就是i自增变成了1,第三次就是i又被i++自增前的返回值0给覆盖了。

然后,我们从字节码的角度来分析这个问题:

首先我们来复习以下有关Java虚拟机栈的有关内容

我们常说的,Java内存可以粗糙的区分为堆内存和栈内存,其中栈就是我们要说的虚拟机栈。和计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 栈帧随着方法调用而创建,随着方法结束而销毁
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表(存放了编译器可知的各种数据类型:boolean、byte、char、short、int、float、long、double、对象引用)、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

在活动线程中,只有位于栈顶的 栈帧才是有效的,称之为当前栈帧。当前栈帧正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。

每一次函数调用都会有一个对应的栈帧被压入Java栈,每一个函数调用结束后,都会有一个栈帧被弹出。Java方法有两种返回方式:return语句或抛出异常。不管哪种方式都会导致栈帧被弹出。

那么操作栈又是什么呢?

操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。

Java虚拟机栈(JVM Stack)描述的是Java方法执行的内存模型,而JVM内存模型是基于“栈帧”的,每个栈帧中都有 局部变量表操作数栈 (还有动态链接、return address等),那么JVM是如何执行这个语句的呢?通过javap大致可以将上面的两行代码翻译成如下的JVM指令执行代码。

0: iconst_0
1: istore_1
2: iload_1
3: iinc      1, 1
6: istore_1
7: iload_1

我们来分析一下JVM是如何执行的:

  • 第0:将int类型的0入栈,就是放到操作数栈的栈顶
  • 第1:将操作数栈栈顶的值0弹出,保存到局部变量表 index (索引)值为1的位置。(局部变量表也是从0开始的,0位置一般保存当前实例的this引用,当然静态方法例外,因为静态方法是类方法而不是实例方法)
  • 第2:将局部变量表index 1位置的值的副本入栈。(这时局部变量表index为1的值是0,操作数栈顶的值也是0)
  • 第3:iinc是对int类型的值进行自增操作,后面第一个数值1表示,局部变量表的index值,说明要对此值执行iinc操作,第二个数值1表示要增加的数值。(这时局部变量表index为1的值因为执行了自增操作变为1了,但是操作数栈中栈顶的值仍然是0)
  • 第6:将操作数栈顶的值弹出(值0),放到局部变量表index为1的位置(旧值:1,新值:0),覆盖了上一步局部变量表的计算结果。
  • 第7:将局部变量表index 1位置的值的副本入栈。(这时局部变量表index为1的值是0,操作数栈顶的值也是0)

i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。

那如果是i=i++呢?,是否就不必从局部变量表取值到操作数栈中?
我们先将题目中的i++改为++i,运行之,

package algorithms.com.guan.javajicu; 
public class Inc { 
    public static void main(String[] args) { 
       Inc inc = new Inc(); 
       int i = 0; 
       inc.fermin(i); 
       i= ++i; //++i是先自增再返回,所以++i的返回值是1,即赋值号的右边赋值给左值i的是1
       System.out.println(i);
    
    } 
    void fermin(int i){ 
       i++; 
    } 
}

打印的是1

单独从字节码层面上来看,不是的,不管将问题中的代码改成i=++i还是i=i++,都会执行iload_1操作用来将局部变量表的值的副本入栈到操作数栈的栈顶,然后在之后执行istore_1操作出栈赋值给局部变量表。
i=++i是一条复合表达式,不像++i或i++一样可用一条JVM指令(iinc)完成。虽然i=++i的执行结果与++i一样,但是却需要额外的指令来完成=号赋值,需要拆分成先执行自身的自增赋值运算,再执行最后的等号赋值运算。
i=i++ 对应指令 iload_1; iinc 1, 1; istore_1;
i=++i 对应指令 iinc 1, 1; iload_1; istore_1;
可以看到他们的区别仅仅是顺序上不一样,其中i=++i的指令中,看似最后两条指令冗余了,但是至少目前的编译期(前期)优化还不能将i=++i优化成++i;
然而运行期(晚期)优化是否有这种优化的可能性呢?
基于即时编译器(JIT)优化类型——内存及代码位置变换(memory and placement transformation)策略的表达式下沉(expression sinking)技术,将i=++i;表达式下沉为int temp=++i;i=temp;,再根据冗余存储消除(redundant store elimination)技术将代码int temp=++i;i=temp;替换为++i;。
以上运行期优化仅仅是个人猜,实际上可能会有出入,在此提出这种不确定的猜测只是想抛砖引玉,但是以JVM虚拟机团队的智慧,我相信这种类似的优化是非常有可能的。

关于第二个陷阱(为什么 fermin方法没有影响到i的值 )的解答看下面。

inc.fermin(i);
  1. java方法之间的参数传递是 *值传递* 而不是 *引用传递*

  2. 每个方法都会有一个栈帧,栈帧是方法运行时的数据结构。这就是说每个方法都有自己独享的局部变量表。(更严谨的说法其实是每个线程在执行每个方法时都有自己的栈帧,或者叫当前栈帧 current stack frame)

  3. 被调用方法fermin()的形式参数int i 实际上是调用方法main()的实际参数 i 的一个副本。

  4. 方法之间的参数传递是通过局部变量表实现的,main()方法调用fermin()方法时,传递了2个参数:

第0个隐式参数是当前实例(Inc inc = new Inc(); 就是inc引用的副本,引用/reference 是指向对象的一个地址,32位系统这个地址占用4个字节,也就是用一个Slot来保存对象reference,这里传递的实际上是reference的一个副本而不是 reference本身 );

第1个显示参数是 i 的一个副本。所以 fermin()方法对 i 执行的操作只限定在其方法独享或可见的局部变量表这个范围内,main()方法中局部变量表中的i不受它的影响;

如果main()方法和fermin()方法共享局部变量表的话,那答案的结果就会有所不同。 其实你自己思考一下,就会发现, JVM虚拟机团队这么设计是有道理的。

如想详细了解关于Java内存区域,可以参考这篇文章

浅显的说明:
jvm里面有两个存储区,一个是暂存区(是一个堆栈,以下称为堆栈),另一个是变量区。jvm会这样运行这条语句, JVM把count值(其值是0)拷贝到临时变量区。 步骤2 count值加1,这时候count的值是1。 步骤3 返回临时变量区的值,注意这个值是0,没修改过。 步骤4 返回值赋值给count,此时count值被重置成0。 c/c++中没有另外设置一个临时变量或是临时空间来保存i,所有操作都是在一个内存空间中完成的,所以在c/c++中是1。

值得说明的是如果我们把i=i++改为i=i+1,结果输出的是1,那是因为 “+” 号的优先级高于复制操作“=” 所以会先执行完i+1,然后赋给左侧的i 。这个操作不存在暂存区的概念

Java内存区域详解
题目中的i是基本数据类型,他存放在栈上,且放的就是他本身的值,而如果形参是引用数据类型,如String str=new String(“Hello”);,我们会在堆区开辟一个内存存放Hello(如果没有赋值也会开辟内存空间存放存放无效值),并将这个内存的地址赋给str,所以str是地址值,如果方法传递的是str,就会指向所在内存,从而改变堆内存中的值,所以方法才会对引用数据类型做出改变(堆上的对象的引用也在栈上)

举例

如果是new的:

package com.jc;

public class Test02 {

    public static void main(String[] args) {
      Test02 inc = new Test02();
      int[] arr;
      arr = new int[]{1,2,3};//这两行可以合并为 int[] arr={1,2,3};
      System.out.println(arr);
      System.out.println("方法前");
      for (int i : arr) {
        System.out.println(i);
      }
      System.out.println("方法后");
      inc.fermin(arr);
      for (int i : arr) {
        System.out.println(i);
      }
    }
    void fermin(int[] arr){
      for (int i = 0; i <arr.length; i++) {
        arr[i]++;
      }
    }


}

输出:

[I@4554617c
方法前
1
2
3
方法后
2
3
4

{1,2,3}不是基本数据类型,而是引用类型(数组、接口、类),所以arr是指向内存的地址。所以方法传递的是地址,能够改变堆内存中的数组值

该题及部分分析来自牛客网
链接:https://www.nowcoder.com/questionTerminal/b507628f3f0b4c85a00a7ed0de830413

猜你喜欢

转载自blog.csdn.net/weixin_45759791/article/details/107288516
今日推荐