Java 虚拟机栈是 JVM 运行时数据区的一部分,属于线程私有。
虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候 JVM 都会同步创建一个栈帧用于存储:局部变量表、操作数占、动态链接、方法出口灯信息。每一个方法被调用直至执行完毕的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
我们知道,不同的 CPU 根据架构的不同,寄存器的设计都不太一样,因此,为了跨平台,Java 指令都是根据栈来设计的。缺点也很明显:性能下降,实现同样的功能需要更多的指令。但是随着时代的发展,现在的 JVM 的性能已经可以和基于寄存器的编程语言不相上下。
简单地说,虚拟机栈就是保存方法的所有信息,一个方法对应一个栈帧,我们可以用 IDEA 来查看效果:
public class JVMStackTest {
public static void main(String[] args) {
method1();
}
private static void method1(){
method2();
}
private static void method2() {
}
}
可以看到,每跳转到一个方法时,就把该方法入栈,变为当前栈帧,执行结束后就抛出,继续执行栈顶方法。
JVM 规范运行 Java 栈的大小是动态的或者是固定不变的。
如果采用固定大小的 Java 虚拟机栈,那每一个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选择,如果线程请求分配的栈容量超过最大容量,就会抛出 StackOverflowError 的异常,这也是为什么递归不能无限执行的原因。
如果说 Java 虚拟机可以动态扩展栈的大小,但是这时候是我们计算机的内存不够,不足以让它扩展栈的最大容量,就会抛出 OutofMemoryError 异常。我们要区分这两种情况。
我们可以通过 -Xss 参数来设置栈的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。(当递归有结束条件,但还是 StackOverflowError 时,可以尝试手动扩大栈空间)。
不同线程中所包含的栈帧是不允许存在相互利用的,即不可能在一个栈帧之中引用另一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着 JVM 就会丢弃执行完毕的当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java 有两种返回函数的方式,一是正常返回,使用 return 指令(void 方法也有 return,只是省略不写,编译器编译的时候还是会加上。);另一种是抛出异常,不管哪种方式,都会导致栈帧被弹出。
我们现在知道了 Java 虚拟机栈是什么了,那这个虚拟机栈的内部又都存储了什么呢?
每个线程都有自己的栈,栈中的数据都是以栈帧的格式来存储。也就是说,每个方法都各自对应一个栈帧,栈帧是一个内存区块,是一个数据及,维系着方法执行过程中的各种数据信息。
每一个栈帧都存储着:局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)和一些附加信息。
下面再仔细讲解 Java 虚拟机栈里面的组成元素。
局部变量表也称为局部变量数组或本地遍历表。
其实说穿了就是一个数组,存储方法参数和定义在方法体内的局部变量。
我们知道虚拟机栈是线程私有的,那局部变量表是虚拟机栈内部的组成部分,所以局部变量表也是线程私有的,不存在数据安全的问题。
局部变量表所需的容量在编译期就确定下来了,并保存在 Code 属性的 maximum loval variables 数据项中,在方法运行期间是不会改变局部变量表的大小的。
方法嵌套调用的次数由栈的大小来决定。一般来说,虚拟机栈容量越大,方法嵌套调用的次数就越多。对一个方法而言,它的参数和局部变量越多,是的局部变量表膨胀,那么,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用的次数减少。
局部变量表中的变量只在当前方法调用中有效,在方法执行时,JVM 通过使用局部变量表来完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也随着销毁。
局部变量表存储的单元不是任何一个数据类型,而是 Slot (变量槽),存放 8 种基本数据类型、引用类型、returnAddress类型。
32 以内的类型只占一个 slot ,64 位的类型占用 2 个 slot。
需要注意的是:byte、short、char 在存储前被转成 int,所以占用一个 slot。而 long 和 double 则占用 2 个 slot。
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引(因为是数组嘛,很好理解),通过这个索引就可以成功访问到局部变量表的指定局部变量。
要注意的是:栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量锅了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量表的槽位,从而达到节省资源的目的。比如一段代码如下:
public void method2(){
int a = 0;
{
int b = 1;
}
int c = 2;
int d = 3;
}
它的局部变量表如下:
可以看到,原本局部变量 b 的位置应该在局部变量表的第 2 个位置,它过了作用域后,就给局部变量 c 复用了。
我们在类加载机制章节知道了:类变量有两次初始化的机会,第一次是在准备阶段,把类变量变为零值;第二次是在初始化阶段,赋予程序员在代码中定义的初始值。
和类变量不同,局部变量不存在系统初始化的过程,这意味着一旦定义了局部变量,就必须认为初始化,否则无法使用。
在栈帧中,与性能调优关系最为密切的部分就是局部变量表,在方法执行时,JVM 使用局部变量表来完成方法的传递。
局部变量表中的变量也是很重要的垃圾回收根结点,只要被局部变量表中直接或间接引用的对象都不会被回收。
通过上面一小节,我们知道了方法的所有局部变量都保存在局部变量表当中。
每一个独立的栈帧中除了局部变量表以外,还包含一个操作数栈(Operand Stack),或者称为表达式栈。它的作用是在方法的执行过程中,根据字节码指令,往操作数栈 push 数据或者 pop 数据。
比如执行复制、交换、求和等操作。
比如有一段代码如下:
public void method1() {
byte i = 1;
int j = 2;
int k = i + j;
}
对应的字节码指令信息为:
我们先看操作数栈的和局部变量表的容量:
操作数栈的容量为 2 ,局部变量表的容量为 4。
下面我们详细来讲解每一个指令的含义,同时局部变量表和操作数栈的状态。
0:iconst_1 表示将 int 型数字 1 推送至栈顶,执行结果如下:
1: istore_1 表示将栈顶 int 型数值存入第 1 个本地变量,执行结果如下:
2: iconst_2 表示将 int 型数字 2 推送至栈顶,执行结果如下:
3: istore_2 表示将栈顶 int 型数值存入到第2个本地变量,执行结果如下:
4: iload_1 表示将第 1 个 int 型本地变量推送至栈顶,执行结果如下:
5: iload_2 表示将第 2 个int型本地变量推送至栈顶,执行结果如下:
6: iadd 表示将栈顶两 int 型数值相加并将结果压入栈定,执行结果如下:
7: istore_3 表示将栈顶 int 型数值存入第 3 个本地变量,执行结果如下:
8: return 表示从当前方法返回 void。
至此,指令运行完毕。上面的操作数栈画的没体现出容量为 2 ,心里知道它容量为 2 即可。
从上面的一步步指令执行分析可知,操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈一开始是空的。
操作数栈会拥有一个明确的栈深度用于存储数值,栈的深度在编译的时候就定义好了,保存在方法的 Code 属性中。
栈中的任何一个元素都是可以任意的 Java 数据类型,32 位的类型占用一个栈单位深度,64 位的类型占两个栈单位深度。
操作数栈在底层其实是数组,但是 JVM 是不把他当数组,而是当成栈来操作,即只能通过标准的入栈和出栈操作来完成一次数据访问。
指令的最后一步为返回,如果方法有返回值,那么这个返回值会被压入操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,这由编译器在编译期间进行验证(前面章节说到的类加载机制的验证阶段有部分是放到编译器进行。),同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
我们一直说的 JVM 是基于栈的执行引擎,这个栈指的就是操作数栈。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。
静态链接和动态链接:
当一个 Class 字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称为静态链接。
如果被调用的方法在编译期无法被确定下来,也就是说,只能够程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此称为动态链接。
早期绑定和晚期绑定:
绑定:一个字段、方法或者类在符号引用被替换为直接引用的过程,仅发生依次。
被调用的目标方法在编译期可知,且运行期保持不变,即可将这个方法与所属的类型进行绑定。因此可以使用静态链接来将符号引用转换为直接引用。
被调用的目标方法在编译期无法确定,只能在运行期根据实际的类型绑定相关的方法,称为晚期绑定。
Java 语言中方法重写的本质:
1、找到操作数栈顶的第一个元素所执行的对象的实际类型,记为 C。
2、如果在类型 C 中找到与常量中描述符合的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,如果不通过则返回。
3、如果在类型 C 中找不到与常量中描述的方法,则对 C 的各个父类进行第 2 步的搜索和验证过程。
4、如果始终都没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
当一个方法开始执行后,只有两种方式退出。
第一种就是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法称为“正常调用完成”。
另一种退出方式是方法在执行过程中遇到了异常,并且这个异常没有在方法体里处理。方法体如果有异常处理(try…catch…),那么异常表就不会有匹配的异常处理器,方法遇到异常就会退出。这种退出方法称为“异常调用完成”。
一个方法如果是因为“异常调用完成”而退出的,是不会给上层调用者提供任何返回值的。
无论采用何种方式退出,都必须回到最初调用这个方法时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复上一层方法的执行状态。
如果方法是“正常调用完成”,主调方法的 PC 计数器的值就可以作为返回地址,栈帧中很可能保存这个计数器值。
如果方法是“异常调用完成”,返回地址是要通过异常处理器来确定的,栈帧中就一般不会保存这部分信息。
不同的 JVM 可以在这部分区域增加《Java虚拟机规范》没有描述的信息,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。
本章学习了 Java 虚拟机栈和它的主要组成部分:局部变量表、操作数栈、动态链接、方法返回地址和附加信息。下一章讲解运行时数据区的另一个组成部分:本地方法栈。