深入理解JVM(17)——运行时栈帧结构

目录

1.概览

2.栈帧结构详解

2.1 局部变量表

(1)变量槽Slot

(2)虚拟机使用局部变量表的方式

(3)实例方法的参数和局部变量的Slot分配顺序

(4)局部变量表Slot复用

(5)局部变量必须被初始化

2.2 操作数栈

2.3 方法返回值

2.4 动态连接


1.概览

  • 栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构
  • 每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈中从 入栈到出栈的过程
  • 每一个栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回值和一些额外的附加信息
  • 编译时,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定,并且写入到方法表的Code属性中,因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现

  • 一个线程中方法的调用链可能很长,同时处于执行状态,
    • 如上图中的当前线程中,方法1调用方法2
    • 当前栈帧:栈顶的栈帧,即正在执行的栈帧
    • 当前方法:与当前栈帧相关联的方法
  • 执行引擎运行的所有字节码指令都只针对当前栈帧进行操作

2.栈帧结构详解

实例代码用于后续演示:

public class Compute {
    public static int initData  = 100;
    public static User user = new User();

    public int compute(int arg){
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Compute compute = new Compute();
        compute.compute();

    }
}

2.1 局部变量表

  • 是一组变量值存储空间,用于存放方法参数方法内部定义的局部变量
  • 在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量

(1)变量槽Slot

  • 局部变量表的最小单位:变量槽(Slot)

大小说明:

  • 虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、refercence或returnAddress类型的数据,这8种数据类型都可以使用32位或更小的物理内存来存放
    • 首先说明这8中类型并非Java语言中的类型,Java语言与Java虚拟机中的基本数据类型是存在本质差别的
  • 也就是说虚拟机规范允许Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化,只要保证即使在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致

对于32位以内的数据类型:

  • boolean、byte、char、short、int、float、refercence或returnAddress
  • 两种数据类型解释:
    • reference表示对一个对象实例的引用,虚拟机规范中既没有说明它的长度,也没有明确指出这种引用应有怎样的结构,但一般要做到两点:
      • 1.从此引用中直接或间接地查找到对象在Java堆中地数据存放的起始地址索引
      • 2.此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息
    • refenceAddress类型目前很少见了,他为字节码指令jsr、jsr_w和ret服务的,指向一条字节码指令的地址,比较早的虚拟机使用这几条指令实现异常处理,现在已经由异常表代替

对于64位的数据类型:

  • long和double(Java中明确规定为64位的)、reference(可能是32位也可能是64位)
  • 虚拟机会以高位对齐的方式为其分配两个连续的Slot空间
  • 这里虽然把long和double数据类型分割存储,但因为局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题,

(2)虚拟机使用局部变量表的方式

  • 方式:索引定位
  • 索引范围:从0到局部变量表的最大Slot数量
  • 访问32位数据类型的变量:
    • 索引n代表使用第n个Slot
  • 访问64位数据类型的变量,
    • 索引n代表会同时使用n和n+1两个Slot
    • 对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个(Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常)

(3)实例方法的参数和局部变量的Slot分配顺序

  • 方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程
    • 如果执行的是实例方法(即非static方法)
      • 局部变量表中第0索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this访问该隐含参数
      • 其余参数按照参数表顺序排列,占用从1开始的局部变量Slot
      • 参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot
      • 如上述的compute方法的局部变量表:

(4)局部变量表Slot复用

  • 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的
    • 方法体中定义的变量,其作用域并不一定覆盖整个方法体(如定义在代码块{}中的局部变量),如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用
  • 但是这样的节省带来的副作用:影响垃圾收集

示例代码1:

public class SlotReuse {
    public static void main(String[] args) {
        byte[] bytes = new byte[1024*1024*1024];
        System.gc();
    }
}

  • 可以发现垃圾回收并没有回收bytes所占用的内存
  • 原因:变量bytes还处于作用域之内

示例代码2:

public class SlotReuse {
    public static void main(String[] args) {
        {
            byte[] bytes = new byte[1024*1024*1024];
        }
        System.gc();
    }
}

  • 可以发现垃圾回收并没有回收bytes所占用的内存
  • 原因:此示例中没有任何对局部变量表的读写操作,局部变量表中还存在着bytes数组对象的引用,它所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联,GC就不能把它当作垃圾

示例代码3:

public class SlotReuse {
    public static void main(String[] args) {
        {
            byte[] bytes = new byte[1024*1024*1024];
        }
        int a = 0;
        System.gc();
    }
}

  • 可以发现垃圾回收已经回收bytes所占用的内存
  • 原因:引用bytes所占用的Slot被变量a复用,这时再没有引用指向原来bytes指向的数组对象,所以别回收

示例代码4:

public class SlotReuse {
    public static void main(String[] args) {
        {
            byte[] bytes = new byte[1024*1024*1024];
        }
        int a = 0;
        System.gc();
    }
}

  • 我们也可以在使用完bytes通过将其赋值为null,来清空bytes引用在局部变量表中所占用的Slot

(5)局部变量必须被初始化

  • 我们知道类变量有两次赋初始值的过程,
    • 一次在准备阶段,赋予系统初始值
    • 另一次在初始化阶段,赋予程序员定义的初始值
    • 因此类变量在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然有一个初始值
  • 但局部变量是不会被赋予系统初始值的,如果一个局部变量在定义了后,程序员没有赋予初始值是不能使用的,会编译报错

示例代码:

public class LocalVar {
    public static void main(String[] args) {
        int a;
        System.out.println(a);
    }
}

2.2 操作数栈

  • 后进先出
  • 同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中
  • 操作栈的每一个元素可以是任意的Java数据类型,包括long和double
    • 32位数据类型所占的栈容量为1
    • 64位数据类型所占的栈容量为2
  • 在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值
  • 当一个方法开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作,如下演示示例代码中执行字节码指令的过程:

我们接下来只看compute方法的执行过程吧:(指令含义可查看我的另一篇博客:待补充链接

     

      

     

      

      

      

     

      

     

     此处的4为局部变量表的索引

      

       即返回栈顶的int

  • 操作数栈中元素的数据类型必须与字节码指令所操作的数据类型严格匹配,
    • 在编译程序代码的时候,编译器要严格保证这一点
    • 在类校验阶段的数据流分析中还要再次验证这一点
    • 如iadd指令用于整型数加法,它在执行的时候,最接近栈顶的两个元素的数据必须为int型,不能出现一个long和一个float使用iadd相加的情况

在理论上,两个栈帧作为虚拟机栈的元素,是完全相互独立的,但在大多数虚拟机的实现里都会做一些优化处理——让下面栈帧的部分操作数栈和上面栈帧的部分局部变量表重叠在一起,如下:

好处:在进行方法调用的时候就可以共用一部分数据,无须进行额外的参数复制传递

2.3 方法返回值

方法退出的两种方式:

  • 1.正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定
  • 2.异常完成出口:方法在执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,而一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的

方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用于恢复到它的调用者的调用位置

  • 正常退出时,调用者的PC计数器的值可以作为返回值
  • 异常退出时,返回地址要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息

方法退出可能执行的操作:

  • 1.恢复上层方法的局部变量表和操作数栈,
  • 2.把返回值(如果有的话)压入调用者栈帧的操作数栈中,
  • 3.调用PC计数器的值以指向方法调用指令后面的一条指令

2.4 动态连接

待补充

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值