JVM(四)虚拟机栈(一)栈帧结构:局部变量表与操作数栈

JVM(四)虚拟机栈(一)栈帧结构:局部变量表与操作数栈


1 虚拟机栈

1.1 简介

虚拟机栈出现的背景:由于跨平台性的设计,Java的指令都是根据栈来设计的,不同平台的CPU架构不同,所以不能基于寄存器。这样做的优点是跨平台,指令集更小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

栈是运行时单位而堆是存储单位栈解决的是程序如何运行的问题,即程序如何运行、如何处理数据,涉及局部变量表操作数栈(包含操作的字节码指令),而堆解决的是数据存储的问题,即数据应该怎么放、放在哪里。

定义与作用:Java虚拟机栈,早期也称作是Java栈,每个线程在创建的时候都会创建一个虚拟机栈,虚拟机栈是线程私有的,其内部保存一个个的栈帧,对应类的一个个的方法调用。栈的声明周期和线程是一致的。

主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)部分结果(中间运行结果),并参与方法的调用和返回

image-20221215175647110

如上是主线程main嵌套调用的两个方法,主线程启动的时候就会创建虚拟机栈,然后两个对应着方法的栈帧就会依次入栈,执行完成之后一次出栈。

栈的优点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序寄存器

  • JVM对Java栈的操作只有两个:

    • 每个方法的执行,伴随着入栈、压栈操作
    • 执行结束返回后的出栈操作
  • 对于栈来说不存在GC问题,但存在OOM问题

栈在开发中遇到的异常

Java虚拟机允许Java栈的大小是动态的或者固定不变的:

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的栈容量在线程创建的时候独立选定,如果线程请求分配的栈容量超过了Java虚拟机栈允许使用的最大容量,则会抛出StackOverFlow异常。
  • 如果Java虚拟机栈跨域动态扩展,但是在动态扩展的过程中无法申请到足够的内存空间,或者在创建新的线程的时候就无法创建虚拟机栈,就会抛出OutOfMemory异常。

如下程序,重复的递归调用导致栈帧过多

public class StackErrorTest {
    public static void main(String[] args) {
        main(args);
    }
}

设置栈的大小

使用参数-Xss+大小单位即可设置java栈的大小

image-20221215191202162
public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        //9843

        //2180
        System.out.println(count++);
        main(args);
    }
}

可以看到设置之前栈中能有9843个栈帧,设置之后只能2180个了

1.2 ★栈的存储结构和运行原理
  • 每个线程都有自己的数据,数据是以栈帧的格式存在的

  • 这个线程上正在执行的方法都对应着一个栈帧

  • 栈帧是一个内存区域,是一个数据集,维系着方法执行过程中的各种数据信息

  • JVM对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进先出的准测

  • 在一条活动的线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称作是当前栈帧,对应的方法称作当前方法,定义当前方法的类就称作当前类

  • 执行引擎的所有字节码指令只针对当前栈帧进行操作

    执行引擎会先从程序寄存器获取当前栈帧的字节码的地址对应的字节码,然后结合和当前栈帧中的存储的方法需要的数据进行操作

  • 如果方法调用了其他的方法,那么其他方法对应的栈帧将会被创建并压入栈顶,称为新的当前栈帧

分析下面程序的栈帧情况:

public class StackFrameTest {
    public static void main(String[] args) {
        StackFrameTest stackFrameTest = new StackFrameTest();
        stackFrameTest.method1();
    }

    public void method1() {
        System.out.println("method1()开始执行");
        method2();
        System.out.println("method1()执行结束");
    }

    public int method2() {
        System.out.println("method2()开始执行");
        int i = 10;
        int m = (int) method3();
        System.out.println("method2()执行结束");
        return i + m;
    }

    public double method3() {
        System.out.println("method3()开始执行");
        double j = 20.0;
        System.out.println("method3()执行结束");
        return j;
    }
}
method1()开始执行
method2()开始执行
method3()开始执行
method3()执行结束
method2()执行结束
method1()执行结束

方法一和方法二都有两次成为栈帧的情况,方法三只有一次

  • 不同线程的栈帧是不允许相互引用的,即不可能在一个栈帧之中引用另一个线程的栈帧

    这是因为Java虚拟栈是线程私有的

  • 如果当前方法调用了其他方法,其他方法返回之际,会将方法的执行结果返回给前一个栈帧,然后虚拟机会舍弃当前栈帧,前一个栈帧会重新称为新栈帧

  • Java方法有两种返回函数的方式,一种是正常return返回,另一种是抛出异常给上一个栈帧,无论是哪种返回,都会导致当前栈帧被弹出

public class StackFrameTest {
    public static void main(String[] args) {
        StackFrameTest stackFrameTest = new StackFrameTest();
        stackFrameTest.method1();
    }

    public void method1() {
        System.out.println("method1()开始执行");
        method2();
        System.out.println("method1()执行结束");
    }

    public int method2() {
        System.out.println("method2()开始执行");
        int i = 10;
        int m = (int) method3();
        System.out.println("method2()执行结束");
        return i + m;
    }

    public double method3() {
        System.out.println("method3()开始执行");
        double j = 20.0;
        System.out.println("method3()执行结束");
        return j;
    }
}

反编译结果(以method2为例)

  public int method2();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #10                 // String method2()开始执行
         5: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: bipush        10
        10: istore_1
        11: aload_0
        12: invokevirtual #11                 // Method method3:()D
        15: d2i
        16: istore_2
        17: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        20: ldc           #12                 // String method2()执行结束
        22: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        25: iload_1
        26: iload_2
        27: iadd
        28: ireturn
      LineNumberTable:
        line 16: 0
        line 17: 8
        line 18: 11
        line 19: 17
        line 20: 25
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      29     0  this   Lcom/hikaru/java1/StackFrameTest;
           11      18     1     i   I
           17      12     2     m   I

1.3 ★栈帧的内部结构

每个栈帧存储着:

  1. 局部变量表(Local Variable)
  2. 操作数栈(Oprand Stack)或表达式栈
  3. 动态链接(Dynamic Linking)或 指向运行时的常量引用
  4. 方法返回地址(Return Address)或 方法正常退出或异常退出的定义
  5. 附加信息
image-20221215205750088

2 局部变量表

局部变量表也称之为局部变量数组本地变量表

  • 定义为一个数字数组,主要用于存储方法参数定义在方法内部的局部变量,这些数据类型包括基本数据类型引用数据类型(对象引用reference和数组)ReturnAddress类型

  • 由于局部变量表建立在线程的栈上,是线程的私有数据,因此不存在线程安全问题。

  • 局部变量表所需的容量是在编译期间就确定下来的,并保存在Code属性的maximum local variable数据项中,在方法运行期间是不会改变局部变量表的大小的

    image-20221215212514595
  • 方法嵌套调用的次数由栈的大小决定,一般来说,栈越大方法嵌套调用的次数越多,对于一个函数而言,它的参数和局部变量越多,就会使得局部变量表膨胀,进而导致栈帧越大,以满足方法调用所需传递参数信息增加的需求,进而导致函数调用需要更多的栈空间,方法嵌套调用的次数也就随之下降

  • 局部变量表只在当前方法调用中有效,在方法执行时,虚拟机通过局部变量表实现参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也随之销毁。

public class LocalVariablesTest {
    public static void main(String[] args) {
        LocalVariablesTest test = new LocalVariablesTest();
        int num = 10;
        test.test1();
    }

    public void test1() {
        Date date = new Date();
        System.out.println(date);
    }
}
image-20221215220253933

对于上面main方法,右边三行分别表示方法的方法名、参数的类型、访问的标志。

image-20221215220423912

然后字节码部分,第一列是编译出的字节码,第二列是方法产生的异常表,杂项记录Misc记录局部变量表的大小、操作数栈的最大深度以及字节码的长度

image-20221215220635619

然后又分为 LineNumberTableLocalVariableTable 两部分,LineNumberTable记录的是编译的字节码行数代码行数的映射关系

image-20221215220827827

LocalVariableTable中包含 startPC 变量的起始字节码位置,length 变量的作用域。

可以看到起始位置+长度都是16,这是因为局部变量只在方法体内部有效

2.1 局部变量表的Slot
  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束

  • 局部变量表中,最基本的存储单位就是slot 变量槽

  • 局部变量表中存放编译期可知的8中基本数据类型引用数据类型(reference)以及ReturnAddress类型的变量

  • 在局部变量表中32位以内的数据类型只占一个slot(包括ReturnAddress和reference),64位的类型(long、double)占两个slot

    byte、char、short在存储之前会被转换为int,boolean也会被转换为int,0表示false,非0表示true;

  • JVM会为局部变量表中的每一个slot分配一个索引,通过这个索引就能够访问到slot变量槽中的局部变量

  • 当一个实例方法被调用的时候,它的参数和方法体内部定义的局部变量将会按照顺序复制到局部变量表上的每一个slot变量槽上

  • 如果要访问64位的局部变量只需要访问前一个slot的索引即可

  • 如果当前栈帧是由构造方法或者实例方法创建的,那么该对象的引用this会存放在index为0的slot处,其余参数按照参数表继续排列

    这也就解释了为什么构造方法和实例方法能够使用this而静态方法不能的原因

    image-20221215224002810
  • 栈帧的局部变量表的槽位是可以重复利用的,如果一个局部变量超过了它的作用域,那么在这个局部变量之后申明的局部变量很有可能会重复利用过期了的局部变量的槽位,从而达到节省资源的目的。

    对于下面的代码,反编译结果为

        public void test2() {
            int a = 1;
            {
                int b = 0;
                b = a + 1;
            }
            int c = 2;
        }
    

    image-20221215225252995

可以看到索引为2的变量槽被重复利用了

2.2 静态变量和局部变量的对比

变量按照类型,可以分为基本数据类型引用数据类型

按在类中声明的位置,可以分为:

  • 成员变量:成员变量在使用之前,都经历过默认初始化赋值
    • 类变量:在类加载的linking阶段的prepare,会给类变量赋值,然后在initial阶段,会给类变量显示赋值,即静态代码块赋值
    • 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
  • 局部变量:局部变量在使用前,必须要进行显示地赋值,否则编译会不通过
2.3 补充说明
  • 在栈帧中,与虚拟机调优关系最密切的部分就是局部变量表,在方法执行时,使用局部变量表完成方法的传递
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或简介引用的对象不会被回收

3 操作数栈

3.1 操作数栈的特点

每一个独立的栈帧中除了包含局部变量表外,还包含一个后进先出的操作数栈,也可以称为表达式栈。操作数栈在方法执行过程中,会根据字节码指令,在栈中写入数据或者提取数据,即入栈、出栈。

  • 某些字节码执行会将值压入栈,其余的字节码指令则会将操作数取出栈,然后再把结果压入栈(比如执行复制、交换、求和等操作)

  • 操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧就会被创建出来,此时方法区也是空的

  • 每个操作数栈都会有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保存在方法的Code属性中,即max_stack

  • 栈中的元素可以是任意的Java数据类型,其中32位的类型占一个栈深度,64位的占两个

  • 操作数栈只能通过入栈出栈来完成一次数据访问,而不能采用索引的方式来进行数据访问

  • 如果被调用的方法带有返回值的话,返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令

  • 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由前端编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段再次验证

  • Java虚拟机的解释引擎就是基于栈的执行引擎,这里的栈就是指的操作数栈

3.2 涉及操作数栈的字节码指令分析
public void testAddOperation() {
    byte i = 15;
    int j = 8;
    int l = i + j;
}

对应字节码

 0 bipush 15
 2 istore_1
 3 bipush 8
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return
  • byte short char boolean 在数组中存放是以int类型存放的,所以这里的字节码指令是bipush
image-20230307185955776
  • 首先pc寄存器指向指令地址为0的指令,将15压入操作数栈
  • istore将整型变量从操作数栈中取出放入局部变量表的1下标位置(0存放的是this
image-20230307185526656
  • 执行iadd指令,从操作数栈中取出两个数,由执行引擎将字节码指令翻译为机器指令交由CPU执行
image-20230307185629937
  • 执行结果压入栈顶,然后执行istore_3,将栈顶整型结果放入局部变量表的三号位置
int m = 8;

10 bipush 8

8在byte范围内,所以被认为是byte类型,然后转化为int类型,因此字节码指令为bipush

  • 如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
public int getSum() {
    int m = 10;
    int n = 10;
    int k = m + n;
    return k;
}

public void testGetSum() {
    int i = getSum();
    int j = 10;
}
0 aload_0
1 invokevirtual #2 <com/hikaru/java/OperandStackTest.getSum : ()I>
4 istore_1
5 bipush 10
7 istore_2
8 return

这里aload_0首先将局部变量表中的0下标元素(this)推送至栈顶,然后invokevirtual执行方法调用,istore_1存储到了局部变量表1号下标位置

3.3 栈顶缓存计数(了解)

基于栈式架构的虚拟机使用的是零地址指令(相对于寄存器的二地址、三地址指令,栈的操作只需要进行进栈、出栈)更加紧密,因此指令的数量相对来说也会更多,也就意味着更多的指令分派次数内存读写次数

而栈顶缓存技术则是由HotSpot提出的,会将栈顶元素存储到CPU寄存器中,以减少内存的读写次数。

posted @ 2023-05-17 18:56  Tod4  阅读(250)  评论(0编辑  收藏  举报