您的位置:首页 > 其它

JVM高级特性与实践(十):虚拟机字节码执行引擎(栈帧结构)

2017-12-26 19:48 501 查看
执行引擎是虚拟机最核心的组成部分之一。

在讲解执行引擎之前,再来思考一下“虚拟机”的概念,它是一个相对于“物理机”的概念,两者都有代码执行能力,区别是物理机的执行引擎时直接建立在处理器、硬件、指令集和操作系统上,而虚拟机则是由自己实现,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

本章内容将从主要概念模型的角度讲解虚拟机的方法调用和字节码执行,此篇博文涉及知识点如下:
栈帧概念及结构
局部变量表、操作数栈的定义、数据结构
动态链接、方法返回地址、附加信息的相关知识点
代码实践 Slot 副作用


一. 执行时栈帧结构

JVM规范中制定了虚拟机字节码执行引擎的概念模型,成为了各虚拟机执行引擎的统一外观(Facade)。不同虚拟机实现中,执行引擎在执行Java代码时可能会有解释执行(即通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还包括不同级别的编译器执行引擎。

但从外观看,所有JVM的执行引擎都是一致的:输入的是字节码文件,处理过程时字节码解析的等效过程,输出的执行结果。


1. 总结构

(1)栈帧(Stack Frame)

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Visual Machine Stack)的栈元素。每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表,操作数栈,动态连接,方法返回地址等信息。栈帧被分配到的内存空间,不受到程序运行期变量数据的影响,仅仅取决于具体的虚拟机实现。

(2)当前栈帧(Current Stack Frame)和当前方法(Current Method)

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

(3)栈帧结构

在概念模型上,栈帧结构如下图所示:



查看上图,稍作了解后,接下来详细讲解栈帧中的 局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用与数据结构。


2. 局部变量表(Local Variable Table)

(1)定义

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

(2)容量

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

(3)Slot 单位

一个Slot可以存放一个32位以内的数据类型:java中占用32位以内的数据类型有 boolean,byte,char,short,int,float,reference和returnAddress 这8种类型。

前面6种类型读者可按照Java语言中的概念去理解(仅作理解,Java语言与JVM 中的基本数据类型时存在本质差别的)。

reference类型:表示对一个对象实例的引用,虚拟机实现至少都应当能通过这个引用做到两点:
从此引用中直接或间接地查找到对象在java堆中的数据存放的起始地址索引。
此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现java 语言规范中定义的语法约束。

returnAddress 类型:它是为字节码指令 jsr, jsr_w 和 ret服务的,指向了一条字节码指令的地址。

对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续 的Slot 空间,且64位的数据类型只有long和double两种。

(4)索引值范围

虚拟机通过索引定位的方式使用局部变量表,索引值范围从0~最大的Slot数量。
如果访问的是32位数据类型的变量,索引n代表了第n个Slot;
如果访问的是64位数据类型的变量,索引n代表了第n个和n+1个Slot;

对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中一个。JVM规范中明确要求遇到这种要求的字节码操作序列,虚拟机应该在类加载的校验阶段抛出异常。

(5)关键字this访问隐含参数

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

(6)Slot 重用

方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot 就可以交给其他变量使用了。这样重用局部变量表中的 Slot,可减少栈帧空间。

不过,Slot的可重用性会带来一些副作用,如会直接影响到系统的垃圾收集行为,如下面3个示例所示:

a. 示例一
【布局变量表 Slot复用对垃圾收集的影响之一】

public static void main(String[] args)() {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
1
2
3
4
5
6

运行结果:



分析:

以上代码即向内存填充了64MB的数据,然后通知JVM 进行垃圾收集。虚拟机运行参数中加上 
-verbose:gc
“来看看垃圾收集过程,根据运行结果可知
System.gc()
运行后并没有回收这64M
的内存。

没有回收placeholder的原因可以理解:因为在执行
System.gc()
 时,变量placeholder 还处于作用域内,虚拟机自然不会回收其内存。

b. 实例二

根据以上分析,由于变量placeholder 还处于作用域内,所以GC未回收,将代码修改如下:
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
1
2
3
4
5
6

运行结果:



分析:

以上代码修改之后,placeholder 的作用域被限制在花括号之外。从代码逻辑上讲,在执行
System.gc()
 时,placeholder已经不可能再被访问了,但从运行结果来看,内存仍然未被回收。

在解释之前,我们再对代码进行修改,在调用
System.gc()
之前加上一行
int
a = 0;
,如下所示:

c. 示例三
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
1
2
3
4
5
6
7

运行结果:



分析:

虽然最后的修改有些莫名其妙,但是根据以上运行结果,内存真的被回收了。

placeholder能否被回收的根本原因是:局部变量表中的Slot 是否还存有关于 placeholder 数组对象的引用。

第一次修改(示例二):代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量仍然保持着对它的关联。

第二次修改(示例三):第一次修改后,那种关联没有被及时打断,在绝大部分情况下影响轻微,但如果遇到一个方法其后面的代码耗时,而前面又定义了占用大量内存又不会再用的变量, 手动将变量设置为null值(即 
int
a=0
,把变量对应的局部变量表Slot清空)是一个推荐的方法。

该操作可以作为一种极特殊情形(对象占用内存大,此方法的栈帧长时间不能被回收,方法调用次数达不到JIT的编译条件)下的奇技来使用。《practical java》中把“不使用对象应手动赋值为null”作为一条推荐的编码规则。

但笔者(本书作者)的观点是没有必要把置null 当做一个普遍的编码规则来推广。原因有两点:
从编码角度讲:以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法。
从执行角度讲:使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的;而赋null值的操作在经过 JIT 编译优化后就会被消除掉,这时候将变量设置为null就是没有意义的。

(7)局部变量不存在准备阶段

类变量有两次赋初始值的过程:
一次在准备阶段,赋予系统初始值。
另外一次在初始化阶段,赋予程序员定义的初始值。所以在初始化阶段没有为类变量赋值也没有关系,因为它有一个确定的初始值。

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

如以下代码,下面这段代码并不能运行,因为局部变量没有赋初始值。(即便编译器能通过手动生成字节码的方式制造出无误的效果,在字节码校验的时候也会被虚拟机发现而导致类加载失败):




3. 操作数栈(Operand Stack)

(1)定义

操作数栈:也称为操作栈,它是一个后入先出栈。操作数栈的最大深度在编译的时候写入到 Code属性的max_stacks 数据项中。操作数栈中每一个元素可是任意Java数据类型,32位数据类型所占栈容量为1,64位的则占用2。在方法执行的任意时候,操作数栈的深度都不会超过 max_stacks数据项设定的最大值。

(2)出栈和入栈操作

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

例如在做算术运算时是通过操作数栈来进行的,又或者在调用其他方法时是通过操作数栈进行参数传递的。

举个具体点的例子:整数加法的字节码指令 iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int整型,当执行指令时,会将这两个值出栈并相加,然后将结果入栈。

(3)栈帧之间的数据共享

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

重叠过程如下图所示:



Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”是操作数栈。后续会对基于栈的代码进行详解。


4. 动态连接(Dynamic Linking)

(1)作用

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

(2)静态解析

在前面几篇博文的讲解中学习了Class文件的常量池中存在大量符号引用,字节码的方法调用指令就以常量池中指向方法的符号作为参数。

这些符号引用一部分会在类加载阶段或第一次使用的时候就转化为直接引用,这种转化称为静态解析;

(3)动态连接

另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。


5. 方法返回地址

(1)退出正执行的方法

当一个方法执行后,有两种方式退出这个方法:

正常完成出口(Normal Method Invocation Completion):执行引擎遇到任意一个方法返回的字节码指令,这种退出方法称为正常完成出口。

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

(2)退出方法时可能执行的操作

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


6. 附加信息

(1)定义

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中。如与调试相关的信息,此部分信息完全取决于具体虚拟机实现。

(2)栈帧信息

一般会把动态连接,方法返回地址与其他附加信息全部归为一类,称为栈帧信息;


二. 小结

关于“虚拟机字节码执行引擎”这一大知识点,主要是从概念模型的角度来讲解虚拟机的方法调用和字节码执行,但是此篇博文尚未涉及到此点,整篇在讲述栈帧结构,它是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个方法从调用开始至执行完成的过程都对应着一个栈帧!

所以在了解JVM字节码执行引擎之前,很有必要先了解其底层实现数据结构——栈帧,可能此篇文章理论部分较多,实践较少,但主要是为了后续讲解打下良好基础,并且引起各位对数据结构的重视,一个功能所依赖的数据结构很大程度上决定了此功能的实现原理与机制,后续介绍的方法调用及字节码执行将涉及大量代码实践,并且基于此基础上进行讲解。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: