什么是Java虚拟机栈
虚拟机栈(Virtual Machine Stack),随着每个线程的创建而创建,主要用于存储栈帧(Stack Frame),而栈帧对应着Java应用程序中一个个被调用的方法。
虚拟机栈的作用
基本等价于栈帧的作用,用于存储方法的局部变量表、操作数栈、动态连接、方法返回地址等信息,主管Java程序中的方法执行。
虚拟机栈的生命周期
生命周期与所属线程相同,虚拟机栈是线程私有的内存区域。
异常抛出情况
- 当线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
- 在JVM栈容量可动态扩展的条件下,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
测试代码如下
StackOverflowError
// -Xss128K 设置栈容量
public class Test01 {
private int stackLenth = 1;
public void stackLeak() {
stackLenth++;
stackLeak();
}
public static void main(String[] args){
Test01 test01 = new Test01();
try {
test01.stackLeak();
} catch (Throwable e) {
System.out.println("栈深度:" + test01.stackLenth);
throw e;
}
}
}
//输出结果如下
/*
栈深度:19724
Exception in thread "main" java.lang.StackOverflowError
at com.fakemybatis.fakemybatis.mybatis.test.Test01.stackLeak(Test01.java:11)
at com.fakemybatis.fakemybatis.mybatis.test.Test01.stackLeak(Test01.java:11)
...
*/
OutOfMemoryError
// -Xss2M 设置运行时栈容量
// 不建议测试这段代码 无限制创建新线程会为操作系统带来很大压力
// 我自己测试的时候直接卡死了 所以没有测试结果 建议体验之前保存一切未保存的工作
public class Test01 {
@Test
public void test(){
Test01 test01 = new Test01();
test01.endless();
}
private void endless(){
while(true){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
while(true){}
}
});
t.start();
}
}
}
栈帧
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。编译Java程序源码的时候,栈帧中局部变表、操作数栈等的大小和深度就已经被写入方法表中的Code属性,并不会受程序运行期间变量数据的影响。
对于执行引擎来讲,在所有活动的线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,此栈帧被称为当前栈帧,与此栈帧关联的方法被称为当前方法。
栈帧结构如下图
局部变量表
- 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,注意此处特指局部变量,成员变量以及定义在方法外的引用存放在Java堆中。
- 局部变量表的容量大小在编译器就已经确定,不会受Java程序影响而改变。
- 局部变量表用于存放编译期可知的各种基本数据类型,对象引用类型和returnAddress类型数据,最小存储单位为变量槽。
- JVM通过索引定位的方式使用局部变量槽,索引范围是从0到最大变量槽数量,如果访问32位数据类型的变量,则索引N代表第N个变量槽,如果访问64位数据类型的变量,则会同时使用第N和N+1两个变量槽。
- 当一个方法被调用时,JVM会使用局部变量表完成实参到形参的传递。如果被执行的方法是实例方法(未被static修饰的方法),则局部变量表中第0位索引的变量槽默认用于存放该方法所属对象实例的引用,这是一个隐含的参数,编码过程中使用的this关键字访问的就是这个参数。其余参数按参数表顺序排列,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
变量槽
- 变量槽(Variables Slot)是局部变量量表的最小单位。
- 每个变量槽都可以存储32位长度的内存空间,Java中占用不超过32位存储空间的boolean、byte、char、short、int、float、reference、returnAddress八种类型由单一变量槽直接存储。其中reference表示一个对象实例的引用。returnAddress指向一条字节码指令的地址(为字节码指令jsr、jsr_w和ret服务),早期版本的JVM曾使用这些指令实现异常处理时的跳转。
- 而对于64位存储空间的long和double,虚拟机会以高位对齐的方式为其分配两个连续的变量槽用于存储,将一次64位读写分割成了两次32位的读写。对于两个相邻共同存放一个64位数据的两个变量槽,虚拟机不允许采取任何方式单一访问其中的某一个。
- 为了解释栈帧耗用的内存空间,变量槽是可以重用的。在方法体中定义的变量,其作用域不一定覆盖整个方法体,如果当前字节码的程序计数器的值已经超出某变量的作用域,那么这个变量对应的变量槽就可以交给其他变量重用。这样的设计再节省了栈帧空间以外,会有在某些极端条件下影响gc的副作用。
- 类中变量有两次赋值过程,即准备阶段赋值和初始化阶段赋值,而局部变量不然,局部变量没有准备阶段,所以只有初始化阶段可以赋值,因此未赋初始值的局部变量是不可用的。
变量槽复用对gc影响测试代码如下(先在虚拟机运行参数中加上 -verbose:gc 来输出垃圾收集过程
public static void main(String[] args){
byte[] b = new byte[64*1024*1024];
System.gc();
}
/*运行结果如下
[GC (System.gc()) 73431K->66656K(502784K), 0.0371037 secs]
[Full GC (System.gc()) 66656K->66426K(502784K), 0.0153884 secs]
*/
public static void main(String[] args){
{
byte[] b = new byte[64*1024*1024];
}
System.gc();
}
/*运行结果如下
[GC (System.gc()) 73431K->66624K(502784K), 0.0382044 secs]
[Full GC (System.gc()) 66624K->66426K(502784K), 0.0051607 secs]
*/
public static void main(String[] args){
{
byte[] b = new byte[64*1024*1024];
}
int a = 0;
System.gc();
}
/*运行结果如下
[GC (System.gc()) 73431K->1104K(502784K), 0.0009279 secs]
[Full GC (System.gc()) 1104K->890K(502784K), 0.0042176 secs]
*/
三次结果显示,只有第三次尝试成功的回收了b数组。
对于第一次尝试,由于执行System.gc()时,b数组仍在作用域中,那么虚拟机自然不会回收这部分内存。
对于第二次尝试虽然在执行System.gc()时,数组b已经不可能再被程序访问,逻辑上这部分内存应该被回收,但在数组b后,还没有发生过任何对局部变量表的读写操作,数组b原本占用的变量槽没有被其他变量复用,所以作为GC Roots一部分的局部变量表仍保持着对它的关联,导致可达性分析算法后仍认为这部分内存可达不需要回收。
第三次尝试中,变量a复用了数组b占用的变量槽,上述关联被打断,垃圾收集正常进行。
操作数栈
- 操作数栈也被称为操作栈,它是一个LIFO栈。同局部变量表,操作数栈的最大深度也在编译时就已确定,不受程序影响。
- 操作数栈的每一个元素都可以是Java数据类型,32位数据类型所占栈容量为1,64位数据类型所占栈容量为2。
- 方法刚开始执行时,操作数栈为空,执行过程中各字节码指令会向操作数栈中写入读出内容,即入栈出栈操作。算术运算和调用其他方法时的参数传递都依靠操作数栈。
- 操作数栈中元素的数据类型必须严格与字节码指令匹配,编译器必须严格保证这一点,类校验阶段的数据流分析还会再次验证这一点,不允许出现非int类型的两个元素使用iadd指令相加。
- 为了节约空间和减少参数赋值传递操作,大多数虚拟机实现都会对栈帧进行优化处理,令两个栈帧的一部分重叠,共用部分数据,以达到上述目的,示意图如下
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。符号引用在类加载阶段或第一次使用时被转化为直接引用的转化被称为静态解析,而另一部分将每一次运行期间都转化为直接引用,这部分称为动态连接,详见方法调用文章(待更)。
- 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,使用时可以无歧义地定位到目标即可。
- 直接引用是可以直接指向目标的指针、相对偏移量货是一个能间接定位到目标的句柄。
方法返回地址
方法执行完毕后,有两种退出(即在虚拟机栈中出栈当前栈帧)方式。
- 正常调用完成:执行引擎在执行过程中遇到任一方法返回的字节码指令,此时将返回值(也可以没有)传递给方法调用者,此种退出方式为正常调用完成。
- 异常调用完成:执行引擎在执行过程中遇到了异常,且未进行妥善处理而导致的方法退出称为异常调用完成。
无论采取何种退出方式都需要返回到方法调用的位置,程序才能正常执行,方法返回时可能需要在栈帧中保存一些信息来帮助恢复上层主调方法的执行状态。一般来说,正常调用完成的情况下,主调方法的程序计数器的值可以作为返回地址,栈帧中很可能保存这个计数器的值。异常调用完成时,返回地址通常由异常处理器确定,栈帧中一般不会保存这部分信息。
附加信息
《Java虚拟机规范》允许虚拟机实现增加一些规范中未描述的信息到栈帧之中,例如调试、性能收集相关的信息,具体取决于虚拟机实现。
参考书籍 《深入理解Java虚拟机》第三版 ——周志明
本篇内容主要用于作者自身学习总结记录,才疏学浅,如文中出现纰漏,还望指正