上一篇:
浅谈JVM(一):Class文件解析
浅谈JVM(二):类加载机制
浅谈JVM(三):类加载器和双亲委派
浅谈JVM(四):运行时数据区
方法是程序执行的最小单元,每个方法被执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口(返回值、异常)等信息。每一个方法被调用直至退出的过程就对应着栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表(Local Variables)所需空间在编译阶段就已经分配,局部变量表中的存储单位为槽(Slot),可以类比数组中的槽,通过索引访问槽中的变量,索引从0开始。局部变量表可存放的数据类型有8种基本数据类型(boolean、byte、short、char、int、long、float、double)、引用类型(reference)和returnAddress(旧版JDK中的类型,指向字节码指令的地址,曾用于处理异常时的跳转,现已基本不用),其中double和long两种数据类型的变量占用两个槽,其余类型占用一个槽。虚拟机规范中并没有明确规定一个变量槽占用空间的大小,变量槽的长度随处理器、操作系统或虚拟机实现不同而变化。
假设某变量需要占用两个变量槽,就用相邻的第N个和第N+1个变量槽进行存储,虚拟机不允许单独访问其中的某一个,如果遇到这样的操作,在类加载的校验阶段就会抛出异常。
如果执行的是实例方法(无static修饰的方法),那么局部变量表的0号槽位默认是方法所属对象的实例引用,也就是通过"this"可访问到的隐含参数。其余参数按照参数列表的顺序,从索引1开始分配变量槽。参数列表分配完毕之后,再根据方法体内部定义的变量顺序和作用域分配其余变量槽。
局部变量表中的索引是槽的索引,比如实例方法定义两个变量,double a和int b,this引用占用0号槽,a是double占用两个槽(1和2),所以变量b的索引就是3。
package com.menglaoshi.test;
/**
* @author 专治八阿哥的孟老师
*/
public class TestClass02 {
public void test(int a, String s) {
int b = 1;
}
}
局部变量表的变量槽是可以复用的,以便节省栈帧消耗的内存空间。方法体中定义的变量,其作用域不一定覆盖整个方法,程序计数器的数值超出变量作用域后,这个变量占用的空间就可以被其他变量复用。
public static void main(String[] args) {
{
byte[] arr = new byte[64 * 1024 * 1024];
}
int a = 1;
}
上面的代码共有三个局部变量,String[] args、byte[]arr和int a。但局部变量表的槽位只有两个,因为是静态方法没有this,所以0号槽位存放的是方法入参args;arr引用数值先被存入了第2个变量(槽1),由于arr的作用域只在大括号内,所以当超出其作用域后,定义变量a时,a复用了arr的变量槽,也存入了槽1。
局部变量表的设计某些情况也会影响垃圾回收。在虚拟机运行参数中添加"-verbose:gc",查看垃圾回收过程:
public static void main(String[] args) {
{
byte[] arr = new byte[64 * 1024 * 1024];
}
System.gc();
}
从上面的代码可以看到, 执行System.gc()的时候,arr占用的64MB空间并没有被回收,这是因为执行System.gc()的时候,虽然已经离开了变量作用域,但局部变量表没有发生重写操作,局部变量表中还有arr的引用(槽1),所以没有被回收。在System.gc()前面添加一行代码"int a=1;",可以看到arr占用的空间被回收了。通过前面的分析我们可以知道,局部变量表此时重写,变量a复用了arr的槽位(槽1)。
与成员变量不同的是,局部变量如果只定义未赋值(如int a;),并不会在连接的准备阶段赋"零值",局部变量不赋值就没有默认值,所以局部变量不赋值就不能使用。
操作数栈(Operand Stacks)是一个后入先出(Last In First Out,LIFO)的栈结构,操作数栈的最大深度在编译的时候被写入到Code属性的max_stacks中,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,操作数栈的深度不会超过在max_stacks中设定的最大值。
当方法开始执行时,操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是出栈和入栈操作。以加法为例:
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
入栈出栈过程如下,BIPUSH将整数常量压入栈顶;ISTORE将栈顶元素出栈,并存入局部变量表对应索引位置;ILOAD加载局部变量表指定索引位置元素压入栈顶;IADD将栈顶两个元素出栈,计算相加结果之后将结果压入栈顶。
操作数栈中的元素数据类型必须与字节码指令的序列严格匹配,编译器在编译过程中必须严格保证这一点,并且类加载过程中的校验阶段也验证这一点。比如IADD命令要求栈顶的两个元素必须是int,不可以是其他类型。
调用其他方法时也是通过操作数栈传递参数的。如果该方法不是本地方法,则从操作数堆栈中弹出参数列表对应的参数值(nargs),在Java虚拟机栈上为所调用的方法创建一个新栈帧。nargs参数值将连续设置为新帧的局部变量的值,实例方法this是局部变量0,arg1是局部变量1,而静态方法arg1位于局部变量0,依此类推。并且程序计数器被设置为要调用的方法的第一条指令的操作码,继续执行方法的第一条指令。
在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递。
每个栈帧都包含对当前方法类型的运行时常量池的引用,以支持方法代码的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,动态连接将这些符号引用转换为具体的方法引用。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析;另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。具体内容会在后续文章中讲解。
方法执行后,有两种可能的退出方式,一种是正常返回退出,一种是出现异常退出。
正常调用完成(Normal Method Invocation Completion)是指方法运行过程中没有出现异常(包括虚拟机内部异常和手动抛出的异常),方法正常返回。如果当前方法有返回值,正常调用完成后会向调用者返回一个值。
异常调用完成(Abrupt Method Invocation Completion)是指方法执行过程中遇到异常,并没有在方法体内得到妥善处理的情况。异常调用完成的方法永远不会给调用者返回任何值。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的程序计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。