美文网首页
JVM 系列 - 内存区域 - Java 虚拟机栈(三)

JVM 系列 - 内存区域 - Java 虚拟机栈(三)

作者: _晓__ | 来源:发表于2018-12-04 17:32 被阅读0次

    特点

    • Java 虚拟机栈(Java Virtual Machine Stacks)是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
    • Java 虚拟机栈描述的是 Java 方法执行的内存模型,用于存储栈帧。线程启动时会创建虚拟机栈,每个方法在执行时会在虚拟机栈中创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法返回地址、附加信息等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈(压栈)到出栈(弹栈)的过程。
    • Java 虚拟机栈使用的内存不需要保证是连续的。
    • Java 虚拟机规范即允许 Java 虚拟机栈被实现成固定大小(-Xss),也允许通过计算结果动态来扩容和收缩大小。如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候就已经确定。

    Java 虚拟机栈会出现的异常

    • 如果线程请求分配的栈容量超过了 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出 StackOverflowError 异常。
    • 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将抛出一个 OutOfMemoryError 异常。

    Java 虚拟机栈执行过程

    可以参考一下这篇文章:https://blog.csdn.net/azhegps/article/details/54092466


    栈帧(Stack Frame)

    • 栈帧存在于 Java 虚拟机栈中,是 Java 虚拟机栈中的单位元素,每个线程中调用同一个方法或者不同的方法,都会创建不同的栈帧(可以简单理解为,一个线程调用一个方法创建一个栈帧),所以,调用的方法链越多,创建的栈帧越多(代表作:递归)。在 Running 的线程,只有当前栈帧有效(Java 虚拟机栈中栈顶的栈帧),与当前栈帧相关联的方法称为当前方法。每调用一个新的方法,被调用方法对应的栈帧就会被放到栈顶(入栈),也就是成为新的当前栈帧。当一个方法执行完成退出的时候,此方法对应的栈帧也相应销毁(出栈)。
      栈帧结构如图:
      栈帧结构

    局部变量表(Local Variable Table)

    • 每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译成 Class 文件时,在 Class 文件格式属性表中 Code 属性的 max_locals(局部变量表所需的存储空间,单位是 Slot) 数据项中确定了需要分配的局部变量表的最大容量。
    • 局部变量表的容量以变量槽(Variable Slot)为最小单位,不过 Java 虚拟机规范中并没有明确规定每个 Slot 所占据的内存空间大小,只是有导向性地说明每个 Slot 都应该存放的8种类型: byte、short、int、float、char、boolean、reference(对象引用就是存到这个栈帧中的局部变量表里的,这里的引用指的是局部变量的对象引用,而不是成员变量的引用。成员变量的对象引用是存储在 Java 堆(Heap)中)、returnAddress(虚拟机数据类型,Sun JDK 1.4.2版本之前使用 jsr/ret 指令用于进行异常处理,后续版本已废弃这种实现方式,目前使用异常处理器表代替)类型的数据,这8种类型的数据,都可以使用32位或者更小的空间去存储。Java 虚拟机规范允许 Slot 的长度可以随着处理器、操作系统或者虚拟机的不同而发生变化。对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的 Slot 空间。即 long 和 double 两种类型。做法是将 long 和 double 类型速写分割为32位读写的做法。不过由于局部变量表建立在线程的堆栈上,是线程的私有数据,无论读写两个连续的 Slot 是否是原子操作,都不会引起数据安全问题。
    • Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的 Slot 数量。如果是32位数据类型的数据,索引 n 就表示使用第 n 个 Slot,如果是64位数据类型的变量,则说明要使用第 n 和第 n+1 两个 Slot。
    • 在方法执行过程中,Java 虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法(非 static 方法),那么局部变量表中的第0位索引的 Slot 默认是用来传递方法所属对象实例的引用,在方法中可以通过关键字 this 来访问这个隐含的参数。其余参数按照参数表的顺序来排列,占用从1开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。
    • 局部变量表中的 Slot 是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超过了某个变量的作用域,那么这个变量相应的 Slot 就可以交给其他变量去使用,节省栈空间,但也有可能会影响到系统的垃圾收集行为。
    • 局部变量无初始值(实例变量和类变量都会被赋予初始值),类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予开发者定义的值。因此即使在初始化阶段开发者没有为类变量赋值也没有关系,类变量仍然具有一个确定的默认值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。

    使用一段代码说明一下局部变量表:

    // java 代码
    public int test() {
        int x = 0;
        int y = 1;
        return x + y;
    }
    
    // javac 编译后的字节码,使用 javap -v 查看
    public int test();
        descriptor: ()I
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=3, args_size=1
             0: iconst_0
             1: istore_1
             2: iconst_1
             3: istore_2
             4: iload_1
             5: iload_2
             6: iadd
             7: ireturn
          LineNumberTable:
            line 7: 0
            line 8: 2
            line 9: 4
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0       8     0  this   Lcom/alibaba/uc/TestClass;
                2       6     1     x   I
                4       4     2     y   I
    

    对应上面的解释说明,通过 LocalVariableTable 也可以看出来:
    Code 属性:
    stack(int x(1个栈深度)+ int y(1个栈深度))=2, locals(this(1 Slot)+ int x(1 Slot)+ int y(1 Slot))=3, args_size(非 static 方法,this 隐含参数)=1

    验证 Slot 复用,运行以下代码时,在 VM 参数中添加 -verbose:gc

    public void test() {
       {
          byte[] placeholder = new byte[64 * 1024 * 1024];
       }
       int a = 0; // 当这段代码注释掉时,System.gc() 执行后,也并不会回收这64MB内存。当这段代码执行时,内存被回收了
       System.gc();
    }
    

    局部变量表中的 Slot 是否还存在关于 placeholder 数组对象的引用。当 int a = 0; 不执行时,代码虽然已经离开了 placeholder 的作用域,但是后续并没有任何对局部变量表的读写操作,placeholder 原本所占用的 Slot 还没有被其他变量所复用,所以 placeholder 作为 GC Roots(所有 Java 线程当前活跃的栈帧里指向 Java 堆里的对象的引用) 仍然是可达对象。当 int a = 0; 执行时,placeholder 的 Slot 被变量 a 复用,所以 GC 触发时,placeholder 变成了不可达对象,即可被 GC 回收。

    操作数栈(Operand Stack)

    • 操作数栈是一个后入先出(Last In First Out)栈,方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程。
    • 同局部变量表一样,操作数栈的最大深度也是Java 程序编译成 Class 文件时被写入到 Class 文件格式属性表的 Code 属性的 max_stacks 数据项中。
    • 操作数栈的每一个元素可以是任意的 Java 数据类型,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值(指的是进入操作数栈的 “同一批操作” 的数据类型的栈容量的和)。
    • 当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,通过一些字节码指令从局部变量表或者对象实例字段中复制常量或者变量值到操作数栈中,也提供一些指令向操作数栈中写入和提取值,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。例如,整数加法的字节码指令 iadd(使用 iadd 指令时,相加的两个元素也必须是 int 型) 在运行的时候将操作数栈中最接近栈顶的两个 int 数值元素出栈相加,然后将相加结果入栈。
      以下代码会以什么形式进入操作数栈?
    // java 代码
    public void test() {
         byte a = 1;
         short b = 1;
         int c = 1;
         long d = 1L;
         float e = 1F;
         double f = 1D;
         char g = 'a';
         boolean h = true;
    }
    
    // 字节码指令
    0: iconst_1   // 把 a 压入操作数栈栈顶
    1: istore_1   // 将栈顶的 a 存入局部变量表索引为1的 Slot
    2: iconst_1  // 把 b 压入操作数栈栈顶
    3: istore_2   // 将栈顶的 b 存入局部变量表索引为2的 Slot
    4: iconst_1   // 把 c 压入操作数栈栈顶
    5: istore_3    // 将栈顶的 c 存入局部变量表索引为3的 Slot
    6: lconst_1   // 把 d 压入操作数栈栈顶
    7: lstore        4   // 将栈顶的 d 存入局部变量表索引为4的 Slot,由于 long 是64位,所以占2个 Slot
    9: fconst_1   // 把 e 压入操作数栈栈顶
    10: fstore        6   // 将栈顶的 e 存入局部变量表索引为6的 Slot
    12: dconst_1   // 把 f 压入操作数栈栈顶
    13: dstore        7   // 将栈顶的 f 存入局部变量表索引为4的 Slot,由于 double 是64位,所以占2个 Slot
    15: bipush        97   // 把 g 压入操作数栈栈顶
    17: istore        9   // 将栈顶的 g 存入局部变量表索引为9的 Slot
    19: iconst_1   // 把 h 压入操作数栈栈顶
    20: istore        10   // 将栈顶的 h 存入局部变量表索引为10的 Slot
    

    从上面字节码指令可以看出来,除了 long、double、float 类型使用的字节码指令不是 iconstistore,其他类型都是使用这两个字节码指令操作,说明 byte、short、char、boolean 进入操作数栈时,都会被转化成 int 型。

    • 在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机实现会做一些优化,令两个栈帧出现一部分重叠。让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。


      栈帧共享
    • Java 虚拟机的解释执行引擎称为 “基于栈的执行引擎”,其中所指的 “栈” 就是操作数栈。

    动态连接(Dynamic Linking)

    • 每个栈帧都包含一个指向运行时常量池(JVM 运行时数据区域)中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
    • 在 Class 文件格式的常量池(存储字面量和符号引用)中存有大量的符号引用(1.类的全限定名,2.字段名和属性,3.方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载过程的解析阶段的时候转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
      看看以下代码的 Class 文件格式的常量池:
    // java 代码
     public Test test() {
        return new Test();
     }
    
    // 字节码指令
    Constant pool:
       #1 = Methodref          #4.#19         // java/lang/Object."<init>":()V
       #2 = Fieldref           #3.#20         // com/alibaba/uc/Test.i:I
       #3 = Class              #21            // com/alibaba/uc/Test
       #4 = Class              #22            // java/lang/Object
       #5 = Utf8               i
       #6 = Utf8               I
       #7 = Utf8               <init>
       #8 = Utf8               ()V
       #9 = Utf8               Code
      #10 = Utf8               LineNumberTable
      #11 = Utf8               LocalVariableTable
      #12 = Utf8               this
      #13 = Utf8               Lcom/alibaba/uc/Test;
      #14 = Utf8               test
      #15 = Utf8               ()I
      #16 = Utf8               <clinit>
      #17 = Utf8               SourceFile
      #18 = Utf8               Test.java
      #19 = NameAndType        #7:#8          // "<init>":()V
      #20 = NameAndType        #5:#6          // i:I
      #21 = Utf8               com/alibaba/uc/Test
      #22 = Utf8               java/lang/Object
    
    public int test();
        descriptor: ()I
        flags: ACC_PUBLIC
        Code:
          stack=1, locals=1, args_size=1
             0: getstatic     #2                  // Field i:I
             3: areturn
          LineNumberTable:
            line 8: 0
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0       4     0  this   Lcom/alibaba/uc/Test;
    

    从上面字节码指令看出 0: getstatic #2 // Field i:I 这行字节码指令指向 Constant pool 中的 #2,而 #2 中指向了 #3 和 #20 为符号引用,在类加载过程的解析阶段会被转化为直接引用(指向方法区的指针)。

    方法返回地址

    • 当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:areturn),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)
    • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
    • 无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的程序计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
    • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。

    简述:

    虚拟机会使用针对每种返回类型的操作来返回,返回值将从操作数栈出栈并且入栈到调用方法的方法栈帧中,当前栈帧出栈,被调用方法的栈帧变成当前栈帧,程序计数器将重置为调用这个方法的指令的下一条指令。

    附加信息

    虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

    相关文章

      网友评论

          本文标题:JVM 系列 - 内存区域 - Java 虚拟机栈(三)

          本文链接:https://www.haomeiwen.com/subject/tduccqtx.html