深入理解Java虚拟机运行时栈帧结构

一、概述

在Java虚拟机规范中,虚拟机字节码执行引擎的概念模型成为了各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现中,执行引擎在执行Java代码时可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器生成本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观上看,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本文将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

二、运行时栈帧结构

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

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

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。在概念模型上,典型的栈帧结构如下图所示。

1. 局部变量表

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

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位。虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是导向性地提到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。前面6种数据类型可使用32位或更小的物理内存来存放,Slot的长度可以随处理器、操作系统或虚拟机的不同而变化。

reference类型表示对一个对象实例的引用。虚拟机规范没有说明它的长度或结构,但一般来说,虚拟机实现应至少能通过这个引用做到两点:一是查找到对象在Java堆中的数据存放的起始地址索引;二是查找到对象所属数据类型在方法区中的存储类型信息。

returnAddress类型为字节码指令jsr、jsr_w和ret服务,指向一条字节码指令的地址。尽管它在现代Java虚拟机中很少见,但仍然存在。

方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程。如果执行的是实例方法(非static方法),局部变量表中第0位索引的Slot默认用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”访问这个隐含参数。其余参数按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

局部变量表中的Slot是可以重用的。方法体中定义的变量,其作用域不一定会覆盖整个方法体。如果当前字节码PC计数器的值已经超出了某个变量的作用域,该变量对应的Slot可以交给其他变量使用。这样的设计节省了栈帧空间,但可能会直接影响系统的垃圾收集行为。

局部变量不像类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段赋予系统初始值,另一次在初始化阶段赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值,类变量仍然具有确定的初始值。但局部变量如果定义了但没有赋初始值,是不能使用的。

2. 操作数栈

操作数栈(Operand Stack),也常称为操作栈,是一个后入先出(Last In First Out, LIFO)栈。

当一个方法刚刚开始执行时,该方法的操作数栈是空的。在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,即出栈/入栈操作。举例来说,整数加法的字节码指令iadd在运行时,操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值。执行该指令时,会将这两个int值出栈并相加,然后将相加结果入栈。

在概念模型中,两个栈帧作为虚拟机栈的元素是完全独立的。但在大多数虚拟机的实现里会做一些优化处理,让两个栈帧出现一部分重叠。下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样在方法调用时可以共用一部分数据,无须进行额外的参数复制传递。重叠的过程如图所示。

3. 动态连接

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

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这部分称为动态连接。

4. 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。

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

另一种退出方式是在方法执行过程中遇到异常,并且该异常没有在方法体内得到处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。使用异常完成出口的方式退出时,不会给上层调用者产生任何返回值。

无论采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址通过异常处理器表确定,栈帧中一般不会保存这部分信息。

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

5. 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息。这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

原创文章,作者:guozi,如若转载,请注明出处:https://www.sudun.com/ask/79186.html

(0)
guozi's avatarguozi
上一篇 2024年5月30日 下午6:08
下一篇 2024年5月30日 下午6:10

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注