深入理解 JVM 垃圾回收机制 - 运行时栈帧的内存变化
JVM以方法作为执行的基本单位,栈帧(Stack Frame)则是用于支持JVM进行方法调用和执行的数据结构。每个方法在执行的同时都会创建一个栈帧用于存储方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。每一个方法从调用开始至执行结束,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。当方法调用结束,栈帧即被销毁,局部变量表、操作数栈等也随之消失。
Java代码在编译时就已经计算出栈帧需要多大的局部变量表,需要多深的操作数栈,并把它们写入到方法表的Code属性之中。换句话说,一个栈帧需要分配多少内存,在编译时已经确定,并不会受到程序运行期间变量数据的影响,仅取决于源码和具体的虚拟机是如何实现栈帧的。
来看下面的方法:
其编译过的字节码为:
通过字节码非常清楚的看到:栈帧堆栈深度以及局部变量表在编译期已被写入Code属性。
局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。它的容量是以变量槽 (Variable Slot)为最小单位的,但JVM规范中并未规定一个Slot应占用多大空间,只是要求每个Slot都应该能容纳一个boolean、byte、char、short、int、float、reference和retureAddress类型的数据,这8种数据类型,都可以使用32位(4字节)或更小的物理内存来存储。前面6种不需要解释,可以按照Java语言中对应数据类型的概念去理解它们,比如:short取值范围为-32768~32767,占用2个字节,肯定可以使用4个字节的物理内存来存储。但仅仅是这样理解而已,Java语言和JVM中基本数据类型是有本质差别的,毕竟连语言都不一样。
第7种是reference类型表示对一个对象实例的引用,JVM规范既没有说明它的长度,也没有指明其应有怎样的结构,但一般来说,reference的职责至少有亮点:
直接或间接地找到对象在堆中数据的起始地址
直接或间接地找到对象所属数据类型在方法区中存储的类型信息
这样才能实现Java语言规范中定义的语法约束,需要注意的是,并不是所有语言中的引用都满足这两点,比如C++在默认情况下就不满足第2点,所以其无法提供Java语言中常见的反射特性。
第8种是returnAddress类型,它为字节码指令jsr,ret服务,其指向一条指令的地址。但从JDK1.7开始,就已经禁用这两指令,因此,这种数据类型已很少使用。
Java中的long和double是64位数据类型,JVM会以高位对齐的方式为其分配两个连续的变量槽空间。比如下面这段代码:
其字节码中局部变量表是:
可以看到JVM为变量x分配的是两个slot。这里把long和double数据类型分割存储的做法与“非原子性协定”中允许把一次long和double的读写分为两次32位读写的做法类似。但由于局部变量表在线程堆栈中,属于线程私有,无论读写两个连续slot是否为原子操作,都不会引起数据竞争和线程安全问题。
关于局部变量表还有一点需要注意的是,局部变量不存在类变量那样的‘’准备阶段“。在深入理解 JVM 类加载机制一文中已经详述,类变量有两次赋初始值的过程,一次在准备阶段,一次在初始化阶段。所以在初始化阶段,即使并未在代码中并为给某个类变量赋值,我们仍然可以使用这个变量。但局部变量不一样,如果在定义一个局部变量时没有赋初始值,那它完全不能使用的。因此,不要认为Java中任何情况下都存在诸如整型变量默认为0,布尔类型默认为false这样的默认规则。就比如,下面这段代码其实不能运行,只不过编译器在编译期间就检查到这一点,现代的IDE也能检查到这一点并给出提示。
如果想了解更多关于局部变量表的内容,可以参考 ( 强烈建议大家阅读 ) :
JVM jsr和ret指令始终理解不了?returnAddress又怎么理解呢?
操作数栈
操作数栈也常称为操作栈,是一个后入先出 (LIFO
) 栈,操作数栈的元素可以是任意Java数据类型,包括long和double。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。比如执行iadd
指令时,接近栈顶的两个元素数据类型必须时整型的。
关于操作数栈有一点需要注意的是,在概念模型中,不同方法会创建不同的栈帧,是完全相互独立的。但在大多数虚拟机的实现都会做一些优化,让两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。
如果一个方法想要调用其他方法或者访问成员变量,需要通过符号引用来表示,动态链接的作用就是将这些以符号引用所表示的方法转换为对实际方法的直接引用。其中一部分符号引用会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态链接。
方法返回地址
当一个方法开始执行后,只有两个方式可以退出这个方法:
正常退出:执行引擎遇到方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者
异常退出:在方法执行过程中遇到异常,且异常没有在方法体内得到处理
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
运行时栈帧内存变化过程
再回顾一下foo方法的字节码:
接下来以调用foo("123")
为例,介绍整个方法调用过程中栈帧的内存变化过程。
1. new指令
new
指令用于创建一个对象,其格式: new indexbyte1 indexbyte2
,无符号数indexbyte1和indexbyte2用于构建一个指向当前类的运行时常量池索引值,构建方式:(indexbyte1<<8)|indexbyte2
,该索引指向运行时常量池中的一个类或接口的符号引用,且这个类或者接口应当是已经解析过的,并且最终解析结果为某个具体的类。比如这个例子中,(indexbyte1<<8)|indexbyte2 = 2
,而 new #2
中的#2
代表:class java/lang/Integer
。
当new
指令执行完成后,一个Integer类的新实例将会被分配在堆中,并且它所有的实例变量都会初始化为相应类型的初始值,一个代表该对象实例的reference类型数据objectref将入栈到操作数栈中。这里Integer属于引用类型,因此它的初始值默认为null值。
2. dup指令
dup
指令用于复制操作数栈顶的值,并插入到栈顶。一般来说,每个new指令之后都会跟一个dup指令,这是因为调用后面调用对象的构造方法会消耗一个栈顶操作数,为了保证对象初始化完成后,栈顶的引用仍指向该对象,所以都会在调用构造方法(invokespecial指令)之前调用dup指令。更具体的内容可以参考 深入理解 JVM 内存管理。
3. aload指令
aload_n
指令用于从局部变量表加载一个reference类型值到操作数栈,<n>代表当前栈帧中局部变量表的索引值,<n>作为索引定位的局部变量必须是reference类型,称为objectref。指令执行后,objectref将会入栈到操作数栈栈顶。再看下本例的局部变量表:
aload_0
即把局部变量表中第0个slot中的值压入栈顶,即当前操作数的栈顶objectref指向的是局部变量表中第0个变量指向的值,其大致的内存结构示意图如下所示:
为什么要把局部变量压入栈顶?这是因为后面调用Integer构造方法时需要传递一个字符串参数,所以在调用之前需要把数据压入栈顶。
4. invokespecial指令
invokespecial
指令专门用于调用父类方法、私有方法和实例初始化方法。其格式与new
指令类似,都是通过两个无符号数计算出方法在常量池中的索引以便得到该方法所在类或者接口的符号引用。
在本例中,invokespecial执行会消耗操作数栈栈顶的两个元素,因为构造方法有两个参数。以Integer为例,除了显式字符串参数,还隐藏着一个默认的参数this,JVM会把操作数栈顶的对象拿出来作为构造方法的this参数,这样在构造方法中才能使用this.
来赋值。而当执行invokespecial
指令后还需要在操作数栈顶维持有一个指向新建对象的引用,就得在invokespecial之前复制一份引用,这也就是之前所说的,一般在new指令后,都会跟一个dup指令的原因 ( 这已经是这个系列第3次强调这个问题 ),invokespecial指令执行后的内存结构示意图如下所示。
5. astore指令
astore_n
指令将一个reference类型的数据保存到局部变量表中,与aload
指令一样,n
也表示指向当前栈帧局部变量表的索引值,而操作数栈栈顶的objectref必须是reference类型的数据。本例中n=1,执行指令后,数据将从操作数栈中出栈,然后保存到baz
所指向的局部变量表中,最后其大致的内存结构示意图如下所示。
关于JVM指令可以参考oracle官方,如果英文阅读有困难可以直接阅读周志明翻译的Java虚拟机规范,里面对每条指令的用法、结构、注意事项等都有详细的描述,大家在阅读字节码时可以作为参考。
最后
其实,引用在JVM垃圾回收中是一个特别重要的概念,毕竟垃圾回收是从枚举GC Root开始的,栈帧中的局部变量则是GC Root中非常重要的组成部分,所以正确的理解运行时栈帧的内存结构变化,也算是理解垃圾回收的开端吧。
深入理解JVM系列的第8篇,完整目录请移步:深入理解JVM系列文章目录
封面图:Jarrett Kow on Unsplash
参考资料
版权声明: 本文为 InfoQ 作者【NORTH】的原创文章。
原文链接:【http://xie.infoq.cn/article/951fa18f83ba9eaafe2e02997】。未经作者许可,禁止转载。
评论