虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
接下来以这段代码为例
Java
public class MethodDemo { public static void main(String[] args) { study(); }public static void study(){eat();sleep();} public static void eat(){ System.out.println("吃饭"); } public static void sleep(){ System.out.println("睡觉"); }}
main方法执行时,会创建main方法的栈帧:
接下来执行study方法,会创建study方法的栈帧
进入eat方法,创建eat方法的栈帧
eat方法执行完之后,会弹出它的栈帧:
然后调用sleep方法,创建sleep方法栈帧
最后study方法结束之后弹出栈帧,main方法结束之后弹出main的栈帧
在IDEA中也可以看到对应的栈帧:
public class FrameDemo {public static void main(String[] args) {A();}public static void A() {System.out.println("A执行了...");B();}public static void B() {System.out.println("B执行了...");C();}public static void C() {System.out.println("C执行了...");throw new RuntimeException("测试");}
}
打上断点debug之后会出现栈帧内容,由于上面的代码是方法调方法,我们也可以直观得看到,如果是递归调用太多次,就会造成很多栈帧存储到栈中,最终就会造成栈溢出
如果在C方法中发生了异常,虚拟机栈就会将此时的栈帧依次弹出栈,满足栈的先进后出,这也有一个Debug小技巧,出现异常时我们要定位问题,只需要找到最上面的方法即可。
Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。如下就有两个线程的虚拟机栈,main线程和线程A。
Java虚拟机栈的栈帧中主要包含三方面的内容
- 局部变量表,局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈,操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据,帧数据主要包含动态链接、方法出口、异常表的引用
局部变量表
局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。
字节码文件局部变量表
我们先来看下字节码文件中的局部变量表:编译成字节码文件时就可以确定局部变量表的内容。
public static void test1(){int i = 0;long j = 1;
}
test1方法的局部变量表如下:
局部变量表中保存了字节码指令生效的偏移量:
比如i这个变量,它的起始PC是2,代表从lconst_1这句指令开始才能使用i,长度为3,也就是2-4这三句指令都可以使用i。为什么从2才能使用,因为0和1这两句字节码指令还在处理int i = 0这句赋值语句。j这个变量只有等3指令执行完之后也就是long j = 1代码执行完之后才能使用,所以起始PC为4,只能在4这行字节码指令中使用。
栈帧局部变量表
栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。i占用数组下标为0的位置,j占用数组下标1-2的位置。
刚才看到的是静态方法,实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。
方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。 如下test3方法中包含两个参数k,m,这两个参数也会被加入到局部变量表中。
槽数问题
public void test4(int k,int m){{int a = 1;int b = 2;}{int c = 1;}int i = 0;long j = 1;
}
为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
1、方法执行时,实例对象this、k、m 会被放入局部变量表中,占用3个槽
2、将1的值放入局部变量表下标为3的位置上,相当于给a进行赋值。
3、将2放入局部变量表下标为4的位置,给b赋值为2。
4、ab已经脱离了生效范围,所以下标为3和4的这两个位置可以复用。此时c的值1就可以放入下标为3的位置。
5、最后放入j,j是一个long类型,占用两个槽。但是可以复用b所在的位置,所以占用4和5这两个位置
所以,局部变量表数值的长度为6。总而言之,JVM会在编译阶段就根据变量如果后续没有使用到的情况,就会将它所占用的槽释放(让其它变量能占用这个槽),从而减少数组占用的内存,这一点在编译期间就可以确定了,运行过程中只需要在栈帧中创建长度为6的数组即可。
操作数栈
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。
比如之前的相加案例中,操作数栈最大的深入会出现在这个时刻,由于两数相加,需要将两个数入到操作数栈进行相应的操作,栈的最大深入就是2
帧数据
帧数据主要包含动态链接、方法出口、异常表的引用。
动态链接
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
方法出口
方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
异常表
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
如下案例:i=1这行源代码编译成字节码指令之后,会包含偏移量2-4这三行指令。其中2-3是对i进行赋值1的操作,4的没有异常就跳转到10方法结束。如果出现异常的情况下,继续执行到7这行指令,7会将异常对象放入操作数栈中,这样在catch代码块中就可以使用异常对象了。接下来执行8-9,对i进行赋值为2的操作。所以异常表中,异常捕获的起始偏移量就是2,结束偏移量是4,在2-4执行过程中抛出了java.lang.Exception对象或者子类对象,就会将其捕获,然后跳转到偏移量为7的指令。
栈内存溢出
Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。Java虚拟机栈内存溢出时会出现StackOverflowError的错误。
如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。
要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss 。
- 语法:-Xss栈大小
- 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
例如
Java
-Xss1048576
-Xss1024K
-Xss1m
-Xss1g
操作步骤如下,不同IDEA版本的设置方式会略有不同:
1、点击修改配置Modify options
2、点击Add VM options
3、添加参数