“虚拟机”是⼀个相对于“物理机”的概念,这两种机器都有代码执⾏能⼒,其区别是物理机 的执⾏引擎是直接建⽴在处理器、缓存、指令集和操作系统层⾯上的,⽽虚拟机的执⾏引擎则是由软件⾃⾏实现的,因此可以不受物理条件制约地定制指令集与执⾏引擎的结构体系,能够执⾏那些不被硬件直接⽀持的指令集格式。
运行时帧栈结构
Java虚拟机以⽅法作为最基本的执⾏单元,“栈帧”(Stack Frame)则是⽤于⽀持虚拟机进 ⾏⽅法调⽤和⽅法执⾏背后的数据结构,它也是虚拟机运⾏时数据区中的虚拟机栈 (Virtual Machine Stack)的栈元素。栈帧存储了⽅法的局部变量表、操作数栈、动态连接和⽅法返回地址等信息。每⼀个⽅法从调⽤开始⾄执⾏结束的过程,都对应着⼀个栈帧在虚拟机栈⾥⾯从⼊栈到出栈的过程。
⼀个栈帧需要分配多少内存,并不会受到程序运⾏期变量数据的影响,⽽仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式(编译时已决定,并写⼊到⽅法表的Code属性 中)。
⼀个线程中的⽅法调⽤链可能会很⻓,以Java程序的⻆度来看,同⼀时刻、同⼀条线程 ⾥⾯,在调⽤堆栈的所有⽅法都同时处于执⾏状态。⽽对于执⾏引擎来讲,在活动线程 中,只有位于栈顶的⽅法才是在运⾏的,只有位于栈顶的栈帧才是⽣效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的⽅法被称为**“当前⽅法”(Current Method)**。 典型的栈帧结构如下⻚图所示。
局部变量表
局部变量表(Local Variables Table)是⼀组变量值的存储空间,⽤于存放⽅法参数和⽅法内部定义的局部变量。在Java程序被编译为Class⽂件时,就在⽅法的Code属性的max_locals数据项中确定了该⽅法所需分配的局部变量表的最⼤容量。
局部变量表的容量以**变量槽(Variable Slot)**为最⼩单位。《Java虚拟机规范》中并没有明确指出 一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存储,但这种描述与明确指出“每个变量槽应占用32位长度的内存空间”是有本质差别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段 让变量槽在外观上看起来与32位虚拟机中的一致。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索 引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐 含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是⼀个后⼊先出(Last In First Out, LIFO)栈 。
在概念模型中,两个不同栈帧作为不同⽅法的虚拟机栈的元素,是完全相互独⽴的。但是 在⼤多虚拟机的实现⾥都会进⾏⼀些优化处理,令两个栈帧出现⼀部分重叠。让下⾯栈帧 的部分操作数栈与上⾯栈帧的部分局部变量表重叠在⼀起,这样做不仅节约了⼀些空间, 更重要的是在进⾏⽅法调 ⽤时就可以直接共⽤⼀部分数据,⽆须进⾏额外的参数复制传递了。
动态连接
每个栈帧都包含⼀个指向运⾏时常量池中该栈帧所属⽅法的引⽤,持有这个引⽤是为了⽀持⽅法调⽤过程中的动态连接(Dynamic Linking)。通过第6章的讲解,我们知道Class⽂件的常量池中存有⼤量的符号引⽤,字节码中的⽅法调⽤指令就以常量池⾥指向⽅法的符号引⽤作为参数。这些符号引⽤⼀部分会在类加载阶段或者第⼀次使⽤的时候就被转化为直接引⽤,这种转化被称为静态解析。 另外⼀部分将在每⼀次运⾏期间都转化为直接引⽤,这部分就称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。
正常调用完成
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为**“正常调用完成”(Normal Method Invocation Completion)**。
异常调用完成
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方 法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为**“异常调用完成(Abrupt Method Invocation Completion)”**。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。 一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存 这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值 以指向方法调用指令后面的一条指令等。笔者这里写的“可能”是由于这是基于概念模型的讨论,只有具体到某一款Java虚拟机实现,会执行哪些操作才能确定下来。
方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一。
解析
所有⽅法调⽤的⽬标⽅法在Class⽂件⾥⾯都是⼀个常量池中的符号引⽤。⽅法在程序真正运⾏之前就有⼀个可确定的调⽤版本,并且这个⽅法的调⽤版本在运⾏期是不可改变的。换句话说,调⽤⽬标在程序代码写好、编译器进⾏编译那⼀刻就已经确定下来。这类⽅法的调⽤被称为解析(Resolution)。
在Java语⾔中符合“编译期可知,运⾏期不可变”这个要求的⽅法,主要有静态⽅法和私有 ⽅法两⼤类,前者与类型直接关联,后者在外部不可被访问,这两种⽅法各⾃的特点决定了它们都不可能通过继承或别的⽅式重写出其他版本,因此它们都适合在类加载阶段进⾏解析。
调⽤不同类型的⽅法,字节码指令集⾥设计了不同的指令。在Java虚拟机⽀持以下5条⽅法调⽤字节码指令,分别是
- invokestatic。⽤于调⽤静态⽅法
- invokespecial。⽤于调⽤实例构造器()⽅法、私有⽅法和⽗类中的⽅法。
- invokevirtual。⽤于调⽤所有的虚⽅法。
- invokeinterface。⽤于调⽤接⼝⽅法,会在运⾏时再确定⼀个实现该接⼝的对象。
- invokedynamic。先在运⾏时动态解析出调⽤点限定符所引⽤的⽅法,然后再执⾏该⽅ 法。前⾯4条调⽤指令,分派逻辑都固化在Java虚拟机内部,⽽invokedynamic指令的分 派逻辑是由⽤户设定的引导⽅法来决定的。
只要能被invokestatic和invokespecial指令调⽤的⽅法,都可以在解析阶段中确定唯⼀的调⽤版本, Java语⾔⾥符合这个条件的⽅法共有静态⽅法、私有⽅法、实例构造器、⽗类⽅法4种,再加上被final 修饰的⽅法(尽管它使⽤invokevirtual指令调⽤),这5种⽅法调⽤会在类加载的时候就可以把符号引⽤解析为该⽅法的直接引⽤。这些⽅法统称为**“⾮虚⽅法”(Non-Virtual Method),与之相反,其他⽅法就被称为“虚⽅法”(Virtual Method)**。
Java中的⾮虚⽅法除了使⽤invokestatic、invokespecial调⽤的⽅法之外还有⼀种,就是被final修饰的实例⽅法。
解析调⽤⼀定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引⽤全部转变为明确的直接引⽤,不必延迟到运⾏期再去完成。
分派
另⼀种主要的⽅法调⽤形式: **分派(Dispatch)**调⽤则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。 这两类分派⽅式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。
静态分派
**所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。**静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行 的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。
解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如前面说过静态方法会在编译期确定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。
动态分派
它与Java语言多态性的另外 一个重要体现——**重写(Override)**有着很密切的关联。当两个变量的实际类型不同,java虚拟机会根据实际类型来分派方法执行版本的。我们把这种在运行期根据实 际类型确定方法执行版本的分派过程称为动态分派。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》 一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对 目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的 方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不 会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法 区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也 会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以 提高性能[8]。我们先看看代码清单8-11所对应的虚方法表结构示例,如图8-3所示。
为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序 号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。