栈空间-虚拟机栈

虚拟机栈

概述

虚拟机栈基本概念

  • 定义:虚拟机栈依然是一块内存区域

  • 作用:[临时存储所有 已经被调用 的方法拥有的数据]{.red}

  • 组成:[栈帧是虚拟机栈中最小的存储单位]{.red}

    • 每个栈帧对应保存每个被调用的方法,没有被调用的方法是不会产生相应的栈帧进行保存的
    • 每个方法执行结束后,栈帧就会出栈(销毁)
  • 特点:

    • [虚拟机栈是线程私有空间:每个线程都会占用虚拟机栈的中的 固定空间,每个固定空间都是私有的]{.red}

      • 虚拟机栈总容量:[操作系统分配给虚拟机的内存 - 堆区内存 - 方法区内存 - 本地方法栈内存]{.red}

        • 虚拟机栈的总容量我们是无法直接使用参数来控制的,取决于虚拟机获得的内存大小
      • [虚拟机栈的大小可以是固定的也可以是动态扩展的]{.red}

        • 解释:[这里指的虚拟机栈大小是每个线程被分配的大小而不是虚拟机栈的总容量]{.red}

        • 固定容量:

          • +++info 各个操作系统的默认的固定容量不同

            • Linux:默认每个线程分配的虚拟机栈区域大小 1MB

            • macOS:默认每个线程分配的虚拟机栈区域大小 1MB

            • Solaris:默认每个线程分配的虚拟栈区域大小为 1MB

            • Windows:[取决于操作系统设置的虚拟内存大小]{.red}

              虚拟机栈大小
          • 固定容量设置得太小也会导致虚拟机无法运行

            《深入理解虚拟机》上写的原话,但是我将其设置为1k之后依然能够运行,很奇怪

        • 动态扩展容量:

          • 定义:虚拟机栈的容量不足时可以使用虚拟机内存中剩余空闲的内存作为扩展
          • 现状:HotSpot 虚拟机并没有选择支持动态扩展技术,以前的 Classic 虚拟机支持动态扩展
        • 设置线程分配的栈大小:[-Xss Size]{.blue}

          # 注意命令和大小之间是没有空格的
          -Xss1k
          -Xss1m
          -Xss1g
    • [虚拟机栈不存在垃圾回收机制,但是存在 OutOfMemoryError 异常和 StackOverFlowError 异常]{.red}

      • [线程获取的虚拟机栈深度不够无法容纳太多的栈帧,就会抛出StackOverFlowError 异常]{.red}

        测试代码:

        // 每个栈帧并不大,但是栈帧数量太多
        public static void main(String[] args){
        // 静态变量 count: 记录递归的次数
        System.out.println(++count);
        // 无限递归
        main(args);
        }
        // 大小不够:栈帧数量不多,但是每个栈帧的大小太大(测试见《深入理解JVM》)

        测试结果:

        虚拟机栈溢出

        :::warning

        ① 单线程情况下,无论是栈帧的数量太多还是栈帧太大,都会因为栈帧的深度不够而抛出的 StackOverFlowError 异常

        ② 单线程情况下,如果线程创建时申请的空间超过了虚拟机栈总容量大小,会抛出 OutOfMemoryError 异常

        (把虚拟机栈空间设置为10g也没有出现这种情况)

        :::

      • [虚拟机栈在动态扩展线程的栈空间时, 无法申请到足够的内存就会抛出 OutOfMemoryError 异常]{.red}

        :::warning

        ① Java 大多数虚拟机都支持动态扩展技术(存疑),不过 HotSpot 并不支持动态扩展技术,远古的 Classic 支持

        (可以使用早期的 JDK 版本进行测试)

        ② 多线程情况下,创建的线程太多就会导致虚拟机栈的总容量不够,从而抛出 OutOfMemoryError 异常

        ③ 多线程情况下测试 OOM 异常容易导致操作系统假死,因为Java 中每个线程都是映射一个内核级线程

        :::

        测试代码

        // 每个线程分配的栈空间设置为 1g
        public static void main(String[] args){
        while (true)
        {
        // 无限创建线程
        new Thread(()->{
        System.out.println(Thread.currentThread().getName() + "被创建");
        // 保证线程无限运行
        while (true){

        }
        }).start();
        }
        }

        测试结果:在创建了 1000+ 左右的线程之后,IDEA 直接卡死,被强制结束了(不过之前确实测试出了 OutOfMemoryError 异常)

    • [每个线程和其拥有的虚拟机栈空间生命周期一致:线程结束后拥有虚拟机栈空间也相应释放]{.blue}

    • 栈空间主要涉及 [进程运行的管理]{.red},所以存储被调用的方法相关信息

虚拟机栈内部结构图示

虚拟机栈结构

栈帧

  • 定义:虚拟机栈中存储方法的最小单位

  • 特点:

    • 每个栈帧都对应存储一个方法
    • 虚拟机栈顶的栈帧也被称作[当前栈帧]{.red}
  • 组成:

  • 入栈/出栈过程:

    • 虚拟机在每个方法被调用时都会创建相应的栈帧并将其入栈

    • 虚拟机在每个方法执行结束时会将该方法出栈(两种结束/出栈方式)

      • [方法正常结束:return 返回]{.blue}

      • [方法异常结束:throw 返回]{.blue}

        注:方法异常结束就是说方法的执行过程中出现了异常,但是这个异常并没有被正常处理,所以导致方法结束

    • [恢复上层方法的局部变量表和操作数栈,并将方法的返回值压入操作数栈]{.red}

    • [根据返回地址调整程序计数器的值,继续执行此前没有执行完的方法]{.red}

局部变量表

  • 定义:栈帧中 [存储局部变量和方法参数]{.red} 的内存区域

    • 局部变量和成员变量:
      • 定义:方法中声明的变量都是局部变量,类中声明的变量都是成员变量
      • 区别:
        • 两种变量的[作用域不同]{.blue}:前者出了方法就不可以被调用,后者可以在类中随意调用
        • 两种变量的[赋值情况不同]{.blue}:
          • 前者必须赋值才能够使用
          • 后者 [类变量会在链接准备阶段]{.red}赋默认值,[实例变量会在对象创建时分配默认值]{.red},所以不赋值也可以使用
    • 基本数据类型和引用类型:
      • 基本数据类型就是 int、short、byte、boolean、double、long、char、float 8 种
      • 引用类型:各种类(String、自定义类等等)
      • 变量类型:无论是局部变量还是成员变量都有相应的数据类型

    :::info

    ① 局部变量表存储的引用类型局部变量,实际上存储的是该对象的引用,对象的实际数据分配在堆中

    ② 局部变量的类型还可以是返回地址(returnAddress 类型)!!我不太清楚这是个什么,反编译过程没见过!!{.bulr}

    :::

  • 组成:[最小存储单位是变量槽]{.red}

  • 变量槽特点:

    • 每个变量槽都具有对应索引,执行引擎通过索引访问变量槽存储的变量

    • [非静态方法会默认拥有 this 变量,始终占用索引为 0 的变量槽]{.red}

      注:正因为默认在非静态方法中添加了 this 变量,所以你才能够使用 this 关键字

    • 不同类型的局部变量占用的变量槽数量不同

      • [long 和 double 型占用两个变量槽(64位)]{.red}
      • 其余类型仅占用一个变量槽(32位)
      变量槽
    • [变量槽存储的局部变量如果在方法未结束前就不再使用,那么该变量槽会被其他变量复用(变量槽复用)]{.red}

      public void methodC(){
      int first = 10;
      { // second 变量只在这个代码块中有效,出了代码块就无效了
      double second = first + 10;
      }
      // second 占用的变量槽将会被 third 变量占据
      // second 变量占据两个变量槽,third 只占据一个,所以最后会有一个变量槽空着
      int third = 30;
      }

      +++info 反编译解析之前可以先想想局部变量表的大小是多少?到底有几个局部变量?

      变量槽复用

      +++

    • [变量槽会将 short、byte、boolean、char类型转换为 int 类型存储]{.blue}

      :::warning

      这个点我暂时不太明白

      :::

  • 局部变量表特点:

    • 局部变量表中的参数和局部变量越多,局部变量表越大

    • [局部变量表的大小在编译期间就已经确定]{.red}

      局部变量表大小

操作数栈

:::info

前提:如果熟悉数据结构的读者,尤其是利用栈结构写过计算器的读者,将会很容易理解操作数栈这个区域

建议阅读:

:::

  • 定义:临时存储 [将要进行运算的局部变量和计算结果]{.red} 的内存区域

  • 基于栈的字节码执行引擎

    • 执行引擎:

      • 物理机的执行引擎是建立在具体硬件之上:[处理器、缓存、指令集(x86)、操作系统]{.blue}
      • 虚拟机的执行引擎是建立在软件之上:[解释器、即时编译器、指令集(字节码)、垃圾回收]{.blue}
    • 基于栈和基于寄存器

      • 基于寄存器:

        • 定义:[变量临时存放在处理器的寄存器中]{.red},执行引擎每次根据寄存器地址获取变量进行计算

        • 操作:每个寄存器都有相应的地址,每次使用指令调用寄存器都需要按照寄存器地址选用

        • 特点:

          • [每个指令依赖于具体寄存器地址就会导致指令集的设计非常复杂]{.green}

          • [指令集的设计依赖于寄存器这种硬件就会导致其移植性差]{.green}

          • [指令集的设计复杂带来的好处就是每条指令的功能强大,完成一个方法需要的指令数量少,执行效率高,寄存器执行效率高也是其中一个原因]{.red}

            # 完成加法计算 a = b + 4
            # 取寄存器 $s1 中的值加上常数 4 然后赋值给 $s0
            # 这个指令不是 x86 指令集中的而是 MIPS 指令集中的
            addi $s0, $s1, 4
      • 基于栈

        • 定义:[变量临时存储在操作数栈中]{.red},执行引擎每次执行出栈和入栈操作进行计算

        • 操作:操作数栈仅存在出栈和入栈的操作,没有地址这种设计

        • 特点:

          • [每个指令只涉及出栈入栈操作并不依赖于地址所以指令集的设计相对精简]{.red}

          • [指令集的设计不依赖于物理硬件的好处就是其可移植性高]{.red}

          • [指令集的设计精简的缺点就是指令的数量非常多,完成一个方法可能需要大量的指令,从而导致效率低下]{.green}

            # 完成加法计算: a = b + 10
            0: bipush 10
            2: istore_1
            3: iload_1
            4: bipush 10
            6: iadd
            7: istore_2
            8: return
    • 目的:[采用基于栈结构的执行引擎就是为了能够不依赖于硬件从而实现 跨平台,并且编译器容易实现]{.red}

    • 执行过程:

      • 测试代码:

        // 执行两数求和的计算
        public static void main(String[] args){
        int first = 10;
        int second = 20;
        int result = first + second;
        }
      • 字节码指令

        0: bipush        10
        2: istore_1
        3: bipush 20
        5: istore_2
        6: iload_1
        7: iload_2
        8: iadd
        9: istore_3
        10: return
      • 图解:(参考《深入理解虚拟机》)

        基于栈的执行引擎
  • 栈顶缓存技术

    • 引入:
      • 因为虚拟机的内存实际使用的是物理机的内存,频繁的出栈入栈实际上是在频繁读写内存
      • 又因为虚拟机采用的是基于栈式的体系结构,所以指令的读写更加频繁
      • 频繁的读写内存会降低操作系统的效率,也就会导致虚拟机的性能降低
    • 解决方式:[将栈顶元素全部缓存到处理器的寄存器中,防止多次使用造成的频繁读写操作]{.red}
  • 特点:

    • [操作数栈的深度也在编译期间就已经确定]{.red}
    • [操作数栈的深度和局部变量表的大小没有直接的关系:局部变量的数量会 间接影响 到操作数栈开辟的深度]{.red}

动态链接

什么是动态链接?

  • 定义:[将字节码指令使用的符号引用解析成运行时常量池保存的直接引用]{.red}

  • 特点

    • [动态链接是在方法执行期间发生的,静态解析是在连接解析阶段发生的]{.red}
    • 并不是所有方法调用都会触发动态链接,部分方法调用可能在解析阶段发生的
  • 解析过程

    测试代码:

    public static void main(String[] args){
    new DynamicLink().methodA();
    }

    public void methodA(){}

    字节码指令:

    // #数字 就是符号引用
    0: new #2
    3: dup
    // 方法调用会使用符号引用,动态链接会进行解析
    // 构造方法调用
    4: invokespecial #3
    // methodA() 方法调用
    7: invokevirtual #4
    10: return

    常量池保存符号引用和直接引用的关系

    // 中间不重要的常量省略了
    Constant pool:
    // ① #4 符号引用要求我们再去查找 #2 #22 两个符号引用
    #4 = Methodref #2.#22 // chapter05/DynamicLink.methodA:()V
    // ② #2 符号引用要求我们继续查找 #21 符号引用
    #2 = Class #21 // chapter05/DynamicLink
    // ③ #22 是方法签名,要求继续查找 #17 #7 两个符号引用
    #22 = NameAndType #17:#7 // methodA:()V
    // ④ 调用方法的对象的符号引用就被解析完成了
    #21 = Utf8 chapter05/DynamicLink
    // ⑤ 被调用方法的返回值和方法名就被解析成功了
    #7 = Utf8 ()V
    #17 = Utf8 methodA

返回地址

什么是返回地址?

  • 定义:方法执行结束后回到的最初被调用的位置称为返回地址

为什么需要返回地址?

  • 原因:
    • 程序计数器保存的是当前执行的指令地址,而此前的指令地址是不知道的
    • 如果没有此前的指令地址,执行引擎就不知道此前的栈帧执行到哪里了
    • 被调用的方法执行结束之后,执行引擎在没有返回地址的情况下就回不去了

如何保证能够返回?

  • 方式:[方法被调用时栈帧就会记录当前程序计数器的值作为返回地址]{.red}

  • 特点

    • 方法正常退出:执行引擎使用 [栈帧中保存的返回地址]{.red} 回到此前执行的位置
    • 方法异常退出:执行引擎使用 [异常表中的信息确定返回地址,栈帧几乎不会保存这种信息]{.red}.
Author: Fuyusakaiori
Link: http://example.com/2021/09/23/jvm/runtime/stackspace/虚拟机栈/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.