《深入理解Java虚拟机》——栈帧结构

时间:2022-12-27 16:50:20

这部分的内容是虚拟机字节码执行引擎方面的,大致分为3块:栈帧结构、方法调用、字节码的执行,而本篇主要是针对栈帧结构的总结。

在开始栈帧结构之前我们还是先了解一下执行引擎相关的内容:

物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的。

虚拟机的执行引擎则是由自己实现的,因此可以自行指定指令集与执行引擎的结构体系并且能够执行那些不被硬件直接支持的指令集格式。

大家都知道Java虚拟机有很多种实现姿势,这些实现都在一定程度上受限于虚拟机字节码执行引擎的概念模型,这个模型成为各种虚拟机执行引擎的统一外观。虽外观受限,但内在是有一定的发挥空间的,在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两种兼备,甚至还可能会包含几个不同级别的编译器执行引擎。

在了解了执行引擎之后,我们来了解本篇文章的重点——栈帧结构。

栈帧在前面的文章中简单地提及过,这里做下详细的介绍。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图:

《深入理解Java虚拟机》——栈帧结构

这里需要思考的是虚拟机栈、栈帧、操作数栈,线程之间的栈帧可以共享吗?

虚拟机栈就是存放栈帧的,一个方法对应一个栈帧,操作数栈是栈帧结构一部分,栈帧是线程私有的,线程之间不能共享。


局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出"每个Slot占用32位长度的内存空间"是有一些差别的。


这里说一下reference类型:reference类型表示对一个对象实例的引用,虚拟机实现至少能根据这个引用做到两点:一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。


虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过Slot重用这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,在某些情况下会直接影响到系统垃圾收集行为。

我们先来一起看一下Slot的复用,测试代码如下:

package com.general.class_structure;
public class TestFSLocalVariable {
/**locals=3*/
public void testLocal(){
int a=1;
System.out.println(a);
int b=1;
}
/**locals=2*/
public void testLocal1(){
{
int a=1;
System.out.println(a);
}
int b=1;
}
/**locals=2*/
public void testLoad2(){
{
int a=1;
System.out.println(a);
}
{
int b=1;
System.out.println(b);
}
int c=1;
}
}


使用javap得到的相关方法的Code属性如下:

 public void testLocal();
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
................................................................
public void testLocal1();
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
..............................................................
public void testLoad2();
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1


在上面的测试代码中,我们可以看到,方法testLocal1和testLoad2中的局部变量表中的Slot槽均发生了复用。刚刚说到slot的复用会对垃圾收集有影响,我们继续用代码来说明,请看下图:

《深入理解Java虚拟机》——栈帧结构

《深入理解Java虚拟机》——栈帧结构


关于局部变量表还有一点需要注意的是:如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为Java中任何情况下都存在诸如整型变量默认为0,布尔型变量默认为false等这样的默认值。


这里再次强调查看一个方法的locals的值的操作姿势,javap命令或者直接把class文件直接拖到eclipse中。


局部变量表介绍完了,下面我们来看栈帧结构中的另一个东西:操作数栈。


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


当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。在前文中的类加载中当时就介绍了字节码验证,其中有一点就是:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。


这里还需要知道关于操作数栈的2点内容:(1)在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。(2)Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的"栈"就是操作数栈。


每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

前面文章类文件结构中我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。

来来,修改一下测试代码,举个例子:

public class TestFSLocalVariable {
/**locals=3*/
public void testLocal(){
int a=1;
System.out.println(a);
int b=1;
testLocal1();
}
/**locals=2*/
public void testLocal1(){
{
int a=1;
System.out.println(a);
}
int b=1;
testLoad2();
}
/**locals=2*/
public void testLoad2(){
{
int a=1;
System.out.println(a);
}
{
int b=1;
System.out.println(b);
}
int c=1;
}


来看下testLocal1方法的字节码版本:

public void testLocal1();
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: iconst_1
1: istore_1
2: getstatic #15 // Field java/lang/System.out:Ljava/io/PrintStream;
5: iload_1
6: invokevirtual #21 // Method java/io/PrintStream.println:(I)V
9: iconst_1
10: istore_1
11: aload_0
12: invokevirtual #33 // Method testLoad2:()V
15: return
LineNumberTable:
line 15: 0
line 16: 2
line 18: 9
line 19: 11
line 20: 15
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 this Lcom/general/class_structure/TestFSLocalVariable;
2 7 1 a I
11 5 1 b I


来看下invokevirtual #33,#33就是testLoad2的符号引用。invokevirtual是方法调用指令,#33是其参数。

有些方法的符号引用是在每一次运行期间转化为直接引用的,比如方法的重载,这部分称为动态连接,这部分内容将在方法分派的文章中展开。



方法返回地址


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


另一种退出方式是:在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。


一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。


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


栈帧信息:虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧之中,在实际开发中,一般会把动态连接、方法返回地址与其他附件信息全部归为一类,称为栈帧信息,也即开头代表栈帧结构的图,可以转换为下图:

《深入理解Java虚拟机》——栈帧结构


栈帧结构到此就介绍完了,转载请注明出处:http://blog.csdn.net/android_jiangjun/article/details/78436719