part3 虚拟机执行子系统
本部分讲虚拟机的执行过程所涉及到的一些问题。这部分详细地说明了Java是如何实现平台无关的:JVM和字节码存储格式。通过设计一个统一的Class文件标准去存储字节码(JVM指令集,符号表及其他辅助信息),并制定规范进行语法和结构化约束,使用JVM的执行引擎去进行解释执行,最终实现平台无关。
虚拟机执行整个流程:首先,由编译器将java文件编译成Class文件,然后通过一整个类加载过程,将Class文件加载到内存的方法区,最终由执行引擎对字节码指令进行解释执行。
ch8 虚拟机字节码执行引擎
本章的内容是虚拟机字节码执行引擎。对象是字节码,工具是虚拟机执行引擎,执行单位为栈帧。讲述的是概念模型,详细说明在完成类加载过程后,采用解析和分派的方式确定方法调用的目标方法;并从主类的main()方法开始,执行程序的概念模型和各种细节。
虚拟机执行引擎是以方法为基本执行单位的,每个方法在执行时都会生成栈帧,方法的执行过程亦即方法在虚拟机栈中进栈和出栈的过程。所以本章的内容重点分为运行时栈帧结构、方法调用和解释执行引擎。
物理机 vs 虚拟机
物理机和虚拟机都具有代码执行能力,区别在于:物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的;而虚拟机的执行引擎则是自己实现的,因此可以自行定义指令集和执行引擎的结构体系,从而执行那些不被硬件直接支持的指令集格式。
现代JVM可能是解释执行,也可能是编译执行,但它们的外观是一致的:
一 运行时栈帧结构
栈帧作用:栈帧是用以支持JVM进行方法调用和执行的数据结构,为VM运行时数据区中的虚拟机栈的栈元素。
栈帧结构:栈帧包括局部变量表、操作数栈、动态连接、方法返回地址和一些附加信息。
栈帧大小:在编译时,栈帧的局部变量表大小和操作数栈深度已确定,并写入于方法表的Code属性中,也就是说,此时,栈帧大小已确定。
当前栈帧:一个线程的方法调用链可能会很长,导致多个方法同时在执行状态。此时,只有位于活动线程的栈顶栈帧才是有效的,被称为当前栈帧,与之关联的方法为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
1.1 局部变量表
作用:为一组变量值存储空间,用以存放方法参数和方法内部定义的局部变量。
存储单位:局部变量表的容量以变量槽Variable Slot为最小单位;Slot的大小被规定为能存放至少一个int、float等类型数据。也就是Slot可为32bits或63bits,当为64bits时,需要进行补齐操作。若以32bits实现一个Slot,则long和double类型数据将以高位对其的方式分配在连续两个Slot空间。
JVM基本数据类型:与Java基本类型基本一致,多一个returnAddress,以前用以实现异常处理,现被异常表代替
使用方式:虚拟机通过索引定位的方式使用局部变量表,索引从0开始。若方法为非static方法,则第零个索引代表对所属对象实例的引用,可用this来访问。注意,考虑到有些变量的生命周期小于方法的生命周期,局部变量表中的Slot是可复用的。
与类变量区别:上一章提到,在类加载的准备阶段,类变量会被初始化赋以零值;而局部方法表中的变量则没有这一过程。所以局部变量必须经过初始化后才能使用。
1.2 操作数栈
先说明一下,Java虚拟机的解释执行引擎被称为基于栈的执行引擎,这里的栈,就是指操作数栈。
作用:是虚拟机的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈,用以代替物理机中的寄存器。少数情况下,数据来自操作码后面或者常量池。
单位和存储,访问方式:单位和存储方式同局部变量表一样,访问不是通过索引,二是通过压栈和出栈实现的,先入后出。
类型匹配:操作数栈中的数据类型必须与字节码指令的序列严格匹配,编译和验证阶段会分别进行验证。
栈帧间数据共享:严格来说,栈帧间是独立的,但VM的实现中会做一定优化,使得栈帧间存在重叠区域,实现数据共享。
1.3 栈帧信息
这里的栈帧信息包括三部分内容:动态连接、方法返回地址和附加信息。
动态连接:在每一次运行期间将符号引用转化为对应的直接引用的过程称为动态连接。每个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用就是为了实现方法调用过程中的动态连接。
方法退出
方法退出操作:1. 恢复上层方法的局部变量表和操作数栈;2. 把返回值(若存在)压入调用者栈帧的操作数栈中;3. 调整PC计数器的值
方法退出方式:方法退出方式分为两种:正常完成出口和一场完成出口。前者是指执行引擎遇到方法的返回字节码指令,正常执行返回操作;后者则是在方法执行过程中遇到异常且该异常未能在方法体内得到处理的情况。此时在方法的异常表中没有搜到匹配的异常处理器,导致方法异常退出。
方法返回地址:在方法退出后(无论以何种退出方式),需要回到方法被调用的位置以使程序继续执行所保存在栈帧中的信息。
附加信息:调试信息等。
二 方法调用
Class文件的编译过程中没有传统编译器的连接步骤,因此一切方法调用在Class文件里存储的只是符号引用,而不是方法在实际运行时内存布局中的入口地址。方法调用阶段的任务为确定被调用方法的具体版本,通过解析或分派实现,方法的具体执行是下一节的内容。
JVM中提供了5条方法调用字节码指令:
1. invokestatic:调用静态方法
2. invokespecial:调用实例构造器<init>
方法、私有方法和父类方法
3. invokevirtual:调用虚方法和final方法
4. invokeinterface:调用接口方法,在运行时确认一个实现此接口的对象
5. invokedynamic:在运行时动态解析处调用点限定符所引用的方法,再执行该方法
这里需要注意的是,前4条指令的分派逻辑是固化在JVM内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
2.1 解析调用
解析调用是指在类加载的解析阶段确定方法调用,将目标方法的符号引用转化为直接引用。解析调用的前提是该方法满足编译器可知、运行期不可变这个条件。
有4种方法是实行解析调用的:静态方法、实例构造器、私有方法和父类方法,也就是对应字节码指令invokestatic和invokespecial的方法。这里需要说明的是,final方法满足编译器可知、运行期不可变这个条件,但final方法是由invokevirtual指令执行的,但其为非虚方法。
2.2 分派调用
解析调用一定是静态的,在编译器可以完全确定。而分派调用则分为静态分派和动态分派。此外,根据分派的宗量数可以分为单分派和多分派。分派调用过程体现了多态性特征,核心在于JVM如何确定正确的目标方法。
2.2.1 静态分派(方法重载解析)
Super s = new Sub()
静态类型和实际类型:如上行代码所示;变量s的静态类型是Super,实际类型是Sub,也称为动态类型。其中,静态类型是编译器确定的,动态类型则在运行期才能确定。
方法重载解析判定:虚拟机(编译器)是根据参数的静态类型而不是动态类型来作为方法重载的判定依据的。
静态分派:所有以来静态类型来定位方法执行版本的分派动作称为静态分派。编译期间选择静态分派目标的过程,是Java语言实现方法重载的本质。
2.2.2 动态分派
动态分派与方法重写密切相关,在字节码层面是由invokevirtual实现的。动态分派的过程也即invokevirtual指令的运行时解析过程,见P254。
2.2.3 单分派和多分派
宗量:方法的接受者和方法的参数统称为方法的宗量。
单分派:根据一个宗量对目标方法进行选择
多分派:根据多个宗量对目标方法进行选择
Java语言是静态多分派,动态单分派。
2.2.4 虚拟机动态分派的实现
由于动态分派非常频繁,实际实现时会采用优化措施去代替元数据搜索。最常用的稳定优化是采用虚方法表Virtual Method Table(对应invokeinterface是接口方法表)。
虚方法表:表中存放了各个方法的实际入口地址;对于子类重写的情况,会进行方法地址替换;相同签名的方法,在子类和父类中有同样的索引序号
虚方法表创建时间:在类加载的连接阶段进行初始化
激进优化:内联缓存和基于类型继承关系分析技术的守护内联
2.3 动态类型语言支持
invokedynamic指令是在JDK1.7引入的,也是字节码指令集的第一个扩充指令,用以实现动态语言支持,为lambda表达式做技术准备。
2.3.1 动态类型语言
特征:类型检查的主体过程是在运行期而不是编译期
举例:Python、JavaScript、PHP、Ruby等
三 基于栈的字节码解释执行引擎
首先,需要明确的是,只有确定Java实现版本和执行引擎运行模式,才能知道字节码是被解释执行还是编译执行的。本节讲解释执行引擎。
指令集:物理机的指令集是基于寄存器的指令集架构;而字节码指令集是基本上是基于栈的指令集架构,也就是其指令大部分是零地址指令,以来操作数栈进行工作,也有部分指令自带参数。基于栈的指令集优点是可移植,缺点是速度慢。
解释执行模型:
do {
自动计算PC寄存器的值+1;
根据PC寄存器的指示位置,从字节码流中取出操作码;
if(字节码存在操作数)
从字节码流中取出操作数;
执行操作码所定义的操作;
} while (字节码流长度 > 0)