虚拟机栈

Java虚拟机栈同程序计数器一样,都是线程私有的,生命周期跟线程相同

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈幁,用来存储局部
变量表,操作栈,动态链接,方法出口等信息。每个方法从调用直到执行完成的过程,都对应一个栈幁在虚
拟机栈中从入栈到出栈的过程。

在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的code属性中,因此一个栈帧需要分配多少内存,不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链路可能会很长,很多方法都处于同时执行的状态。对于执行引擎来说,在活动线程中,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

执行引擎运行的所有字节码指令只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图所示:

arthas 分析 java 栈内存_操作数

局部变量表

在工作和学习过程中,java程序员会把java内存分为堆内存和栈内存,这种划分方式只能说明大多数程序员最为关注的与对象分配关系最为密切的区域是这两块,实际的划分要复杂的多。其中的堆在后面再说,这里所说的栈就是java虚拟机栈,更准确的说应该是虚拟机栈中的局部变量表。

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

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指出一个slot占应用内存的大小,只是很有导向性的指出一个slot都应该可以存放一个byte、short、int、float、char、boolean、对象引用(reference)、returnAddress(指向一个字节码指令的地址),这8种类型的数据,都可以使用32位或者更小的空间去存储,但这种描述与明确指出“每个slot占用32位的内存空间”有一些区别,它允许slot的长度可以随着处理器、虚拟机、操作系统的不同而发生变化。只要保证即使在64位虚拟机下使用64位内存去实现slot,虚拟机仍需要使用对齐和补白的方式使之在外观上看起来和32位下一致。

一个slot可以存放一个32位的数据类型,Java中占用32位以内的数据类型有byte、short、int、float、char、boolean、reference(对象引用,java虚拟机没有规定reference类型的长度,它的实际长度与32位还是64位虚拟机有关,如果是64位虚拟机,他的长度还与是否开启某些对象指针的压缩优化有关)、returnAddress 8种数据类型。第7种refrence类型表示一个对象实例的引用,虚拟机规范中既没有说明长度也没有说明引用应有怎样结构。但一般情况来说,虚拟机通过这个引用应该至少做到两点,一是通过这个引用直接或间接的查找到对象在java堆中数据存放的起始位置索引,而是通过此引用查找对象所属数据类型再方法区存储的类型信息,否则无法实现java语言规范中定义的语法约束。returnAddress执行一条字节码指令的地址,为字节码指定jsr、jsr_w和ret服务的,很古老的java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。

对于64位的数据类型,虚拟机会通过高位补齐的方式为其分配两个连续的slot空间,java中明确的64位的数据类型只有long、double,(reference类型可能是32,也可能是64位的),值得一提的是,这里把long、double分割存储的做法与”long和double的飞原子性协定”把一次long和double的读写分割为两次32位的读写做法有些类型。不过,由于局部变量表在虚拟机栈中,是线程私有的数据,所以无论读写两个连续的slot是否是原子性操作,都不会出现线程安全的问题。
虚拟机通过索引定位的方式定位局部变量表,索引的范围从0开始到局部变量表最大的slot数量。如果访问的是32位数据类型,索引n就代表使用了第n个slot;如果访问的是64位数据类型,索引n就代表使用了第n和n+1个slot。对于两个相邻的存放64位数据的slot,不能单独访问其中一个,java虚拟机规范中明确要求了如果遇到了这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

在执行方法的时候,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static),那局部变量表的第0个slot默认用来传递方法所属对象的引用,在方法中通过this关键字可以访问这个隐含的参数。其余参数按照参数表顺序排列,参数表分配完毕,再根据方法内部局部变量的顺序和作用域分配slot。

为了尽可能节省栈帧空间,局部变量表中的slot是可以重用的。方法中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量所在的slot可以交给其他变量使用。不过这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用。例如,在某些情况下,slot的复用会直接影响到系统的gc。

public static void main(String[]args)(){
    {
       byte[] placeholder=new byte[64*1024*1024];
    }
    int a=0;
    System.gc();
}
运行一下程序,却发现这次内存真的被正确回收了。
[GC 66401K->65778K(125632K),0.0035471 secs]
[Full GC 65778K->218K(125632K),0.0140596 secs]

placeholder能否被回收的根本原因是:局部变量表中的slot是否还保存有关于placeholder的引用。代码虽然已经离开placeholder的作用域,但是假设后续没有任何操作(没有 int a=0),那么placeholder所在的slot并没有被其他变量重用,因此作为gc root一部分的局部变量表里还留有placeholder的引用,placeholder不会被回收。但如果遇到一个方法,后面的操作用时很长,并且很占内存,而前面已经占去了那么多内存又不会去使用,可以手动设置null。这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。

关于局部变量表,还有一点要注意,可能会影响开发的,就是他不存在类变量和实例变量那样的准备阶段,不存在初始值,在使用之前,必须要给值。在使用前,不给值,这段代码其实并不能运行,还好编译器能在编译期间就检查到并提示这一点,即使编译能通过或者手动生成字节码的方法制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败。

public static void main(String[]args){

    int a;

    System.out.println(a);

}

操作数栈

操作数栈也被称为操作栈,是一个后入先出的栈。同局部变量表一样,操作数栈的最大深度在编译的时候已经写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意java类型,包括long和double。32位数据类型占用的容量为1,64位数据类型占用的容量为2.在方法执行的任何时候,操作栈的深度最深不会超过max_stacks。

当一个方法刚刚开始的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。例如在进行数学运算的时候,就是通过操作数栈来实现的,又或者在调用其他方法时就是通过操作数栈来传递参数的。举个例子,整数加法的字节码指令iadd在运行的时候在操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数据,当执行这个命令时,这会将两个int类型的数据出栈,相加以后再把结果入栈。

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

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全互相独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分操作局部变量表重叠在一起,这种在进行方法调用时,可以共用一部分数据,无须进行额外的参数复制传递,java虚拟机的解释执行引擎被称为 基于栈的执行引擎,其中的栈就是操作数栈。

arthas 分析 java 栈内存_arthas 分析 java 栈内存_02

动态连接

每一个栈帧内部都包含一个指向运行时常量池的引用来支持当前方法的代码实现动态链接。在 Class 文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用。

类加载的过程中将要解析掉尚未被解析的符号引用,并且将变量访问转化为访问这些变量的存储结构所在的运行时内存位置的正确偏移量。

由于动态链接的存在,通过晚期绑定(Late Binding)使用的其他类的方法和变量在发生变化时,将不会对调用它们的方法构成影响。

这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。(静态分派,动态分派)

方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

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

在Java虚拟机规范中,对虚拟机栈这个区域规定了两个异常情况:
1、线程请求的栈深度>虚拟机栈的允许最大深度,抛出StackOverflowError
2、如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存是,抛出OutOfMemoryError(当前大部分虚拟机都支持动态扩展,只不过虚拟机规范中也允许固定大小的虚拟机栈)