CH08-字节码执行引擎
8.1 概述
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。每个方法调用从开始到执行完成的过程,都对应着一个栈帧在虚拟机站里面从入栈到出栈的过程。
每个栈帧中都包含了局部变量表、操作数栈、动态连接、方法返回地址等信息。栈帧中需要多大的局部变量表、多深的操作数栈会在 编译期确定,并且写入到方法表的 Code 属性中,因此一个栈帧需要分配多少内存。不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称作 当前栈帧,与该栈帧关联的方法称为 当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示。
8.2.1 局部变量表
局部变量表是一组 变量值存储空间,用于存放 方法参数 和方法内部定义的 局部变量。在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性中的 max_locals 数据项中确定了该方法需要分配的局部变量表的最大容量。
局部变量表以槽(slot)为最小单位,虚拟机规范中没有明确指定一个 Slot 应该占用多大的内存空间,但是很有导向性的说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference、returnAddress 类型的数据,这 8 种数据类型,都可以用 32 位或更小的物理内存来存放,允许 Slot 的长度随着处理器、操作系统或虚拟机的不同而发生变化。只要保证即使在 64 位虚拟机中使用 64 位的物理内存空间来实现一个 slot,虚拟机仍要使用对齐和补白的手段让 slot 在外观上看起来与 32 位的虚拟机中一致。
上面提到的前 6 种数据类型无需多说,而第 7 种 reference 类型表示对一个对象实例的引用,虚拟机规范没有明确说明其长度和结构。但一般来说,虚拟机实现至少都应当能通过该引用做当两点:从此引用中直接或间接的查找到对象在 Java 堆中的数据存放的起始地址索引;此引用中直接或间接的查找到对象所属数据类型在方法区中存储的类型信息,否则无法实现 Java 语言规范中定义语法约束。
第 8 种 returnAddress 类型目前已经很少见了,它为字节码指令 jsr、jsr_w、ret 服务,指向了一条字节码指令的地址,远古 JVM 曾使用这几条指令来实现处理,现在已经由异常表代替。
对于 64 位的数据类型,虚拟机会以高位补齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32/64 位) 64 位数据类型目前只有 long、double。值得一提的是,这里把 long、double 分割存储的方法与“非原子性协定”中对这两种数据类型分割为两次读写的方式类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有数据,无论读写两个连续的 slot 是否为原子操作,都不会引起数据安全性问题。
虚拟机通过索引定位的方式使用局部变量表,索引值从 0 开始只局部变量表的最大 slot 数量。如果访问的是 32 位数据类型的变量,则索引 N 就表示使用第 N 个 Slot;如果是 64 位的数据类型变量,则会同时使用 N 和 N+1 来表示两个 Slot。对于两个相邻的用于存放一个 64 位数据的 slot,不允许采用任何单独的方式来单独访问其中一个,否则将在类加载的校验阶段抛出异常。
在方法执行时,虚拟机使用局部变量表来完成参数值到参数变量列表的传递过程。如果执行的是实例方法,局部变量表中第 0 位索引的 slot 默认用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来方法该隐含参数。其余参数则按照参数列表顺序排列,占用从索引值 1 开始的局部变量 slot。参数列表分配完成之后,再根据方法体内部定义的变量顺序和作用域来分配其余的 slot。
为了尽可能的节省栈空间,局部变量表中的 slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么该变量对应的 slot 就可以交给其他变量使用。不过,这样的设计出了节省栈帧空间之外,还会伴随着一些额外的副作用,如在某些情况下,slot 的复用会直接影响到系统的垃圾收集行为。
slot 复用如何影响 GC?
“如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么该变量对应的 slot 就可以交给其他变量使用。” 但是,如果并没有其他变量复用该 slot,该 slot 中原有的值会继续保持,所以作为 GC-Roots 一部分的局部变量表仍然保持着对该 slot 中的变量值的关联。 这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为 null 值这会变得有意义。 但赋值为 null 的操作可能被 JIT 优化掉,因此不能依赖这种操作。
局部变量不像前面介绍的类变量那样存在“准备阶段”。通过第 7 章的讲解,我们已经知道类(静态)变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为 Java 中任何情况下都存在诸如整型变量默认为 0,布尔型变量默认为 false 等这样的默认值。编译器会报告这种错误。
8.2.2 操作数栈
即操作栈,是一个 LIFO 栈。与局部变量表一样,其最大深度也在编译期写入到 Code 属性的 max_stacks 数据项中。操作数栈的每个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的占容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时刻,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。
当一个方法刚刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈、出栈操作。比如,在做算术运算的时候是通过操作数栈完成的,又或者在调用其他方法的时候是通过操作数栈完成参数传递的。
举个例子,整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行 iadd 指令时,会将栈顶的两个 int 值出栈并相加,然后再将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验的数据流分析中还要再次验证这一点。
再以上面 iadd 指令为例,该指令用于整型加法,它在执行时,最接近栈顶的两个元素的数据类型必须都是 int 型,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
另外,在概念模型中,两个栈顶作为虚拟机栈的元素,是完全互相独立的。但在大多虚拟机的实现中会做一些优化处理,使两个栈帧出现一部分重叠。让下面栈帧的部分操作数与上面栈帧的局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递,重叠的过程如图 8-2 所示。
Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈。
8.2.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有该引用是为了支持方法调用过程中的动态连接。通过第六章的讲解,我们知道 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候被转换为直接引用,这时候的转换被称为静态解析。另外一部分将在每次运行期间转换为直接引用,这部分称为动态连接。
8.2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出该方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(即调用当前方法的方法),是否有返回值和返回值的类型将根据具体的方法返回指令来确定,这种退出方式称为正常完成出口。
另一种退出方式是在方法执行过程中遇到了异常,并且该异常没有在方法体内得到处理,无论是 JVM 内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。这种退出方式不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它上层的方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器值可以作为返回地址,栈帧中可能会保存该计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
8.2.5 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧中,如调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再描述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
8.3 方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即要调用哪个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。该特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的版本,并且该方法的调用版本在运行期是不可改变的。即,调用目标在程序代码编写完成、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在 Java 语言中符合“编译期可知、运行期不可变”这种要求的方法,主要包括静态方法和私有方法两大类,前者直接与类相关联,后者不可被外部访问,这两种方法各自的特点都决定了它们都不可能通过继承或别的方式被重写为其他版本,因此它们都适合在类加载阶段完成解析。
与之对应的是,在 JVM 中提供了 5 个方法调用字节码指令,分别是:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器
<init>
方法、私有方法、父类方法。 - invokevirtual:调用所有的虚方法。
- invikeinterface:调用接口方法,会在运行时确定一个实现该接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用指令,分派逻辑是固化在 JVM 内部的,而 invokedynamic 指令的分派逻辑是由用户指定的引导方法决定的。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合该条件的只有静态方法、私有方法、实例构造器、父类方法 4 种,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法则被称为虚方法(final 方法除外)。
Java 中的非虚方法除了使用 invokestatic 和 invokespecial 调用的方法之外还有一种,即被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖、不会存在其他版本,所以也无需对方法接收者进行多态选择,又或者说多态选择的结果一定是唯一的。在 Java 语言规范中明确说明了 fianl 方法是一种非虚方法。
解析调用移动是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转换为可确定的直接引用,不会延迟到运行期再进行。而分派(dispatch)调用则可能是静态或动态的,根据分派所依据的宗量数有但分派和多分派。这两类分派方式来那个量组合就构成了静态但分派、静态多分派、动态单分派、动态多分派这 4 种分派组合的情况。
8.3.2 分派
Java 是一种 OO 语言,因为它具备 OO 的 3 个基本特征:封装、继承、多态。这里讲解的分派调用过程将会揭示多态特性的一些最基本体现,如“重载”和“重写”在 JVM 中是如何实现的,即虚拟机如何确定正确的目标方法。
1. 静态分派
public class StaticDispatch{
static abstract class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human guy){
System.out.println("hello,guy!");
}
public void sayHello(Man guy){
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy){
System.out.println("hello,lady!");
}
public static void main(String[]args){
Human man=new Man();
Human woman=new Woman();
StaticDispatch sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
// 执行结果:
// hello,guy!
// hello,guy!
可以发现两次方法调用均选择了以 Human 的参数类型的重载方法。要理解该问题,我们先按如下定义来理解两个重要的概念。
Human man = new Man();
我们将上面代码中的 “Human” 称为变量的静态类型,或者叫做外观类型,后面的 “Man” 则称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
再回到最初的例子中。main 中的两次 sayHello 方法调用,在方法接收者已经确定是对象 sr 的前提下,使用哪个重载版本,就完全取决于传入参数的数量的类型。代码中刻意的定义了两个静态类型相同但实际类型不同的变量,但 虚拟机(更准确的说是编译器)在重载时是通过参数的静态类型而非实际类型作为判断依据的。并且静态类型是编译期可知的,因此在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本的方法,因此选择了 sayHello(Human)
作为调用目标,并把该方法的符号引用写入到了 main 方法里的两条 invokevirtual 指令的参数中。
所有依赖静态类型进行方法版本定位的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译期,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个“更加合适的”版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则来理解和推断。下面的例子演示了“更合适的版本”是什么。
package org.fenixsoft.polymorphic;
public class Overload{
public static void sayHello(Object arg){
System.out.println("hello Object");
}
public static void sayHello(int arg){
System.out.println("hello int");
}
public static void sayHello(long arg){
System.out.println("hello long");
}
public static void sayHello(Character arg){
System.out.println("hello Character");
}
public static void sayHello(char arg){
System.out.println("hello char");
}
public static void sayHello(char……arg){
System.out.println("hello char……");
}
public static void sayHello(Serializable arg){
System.out.println("hello Serializable");
}
public static void main(String[]args){
sayHello('a');
}
}
// 输出
// hello char
这很好理解,'a'
是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉对应 char 参数类型的方法 sayHello(char arg)
,则输出会不变为 hello int
。
这时发生了一次自动类型转换,'a'
除了可以代表一个字符串,还可以代表数字 97(a 的 Unicode 数值为十进制的 97)。因此参数类型为 int 的重载也是合适的。如果继续去掉参数类型为 int 的方法 sayHello(int arg)
,输出则变为 hello long
。
这时发生了两次自动类型转换,'a'
转型为整数 97 之后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载。例子中没有提供其他类型如 float、double 等重载方法,不过实际上自动转型还能继续发生多次,按照 char、int、long、float、double 的顺序依次转型进行匹配。但不会匹配到 byte 和 short 类型的重载,因为 char 到 byte 或 short 的转型是不安全的。继续注释掉参数类型为 long 的方法 sayHello(long arg)
,输出则变为 hello character
。
这时发生了一次自动装箱操作,'a'
被包装为它的封装类型 java.lang.Character
,所以匹配到了参数类型为 Character 的重载,继续注释掉参数类型为 Character 类型的方法 sayHello(Character arg)
,输出则变为 hello Seralizable
。
这个输出可能会让人感到难以理解,一个字符或数字与序列化有什么关系呢?出现这种结果的原因是 java.lang.Serializable
是 java.lang.Character
的一个接口,当它自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。char 可以转换为 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全的转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>
,如果同时还出现了两个参数类型分别为 Serializable
和 Comparable<Character>
的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,因此会提示模糊类型,拒绝编译。程序必须在调用时显式的指定字面量的静态类型,如:sayHello((Comparable<Character>) 'a')
,才能编译通过。如果继续将参数类型为 Comparable<Character>
的方法去掉,结果变为:hello Object
。
这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接靠上层的优先级越低。即使方法调用传入的参数值为 null,该规则仍然适用。当把参数类型为 Object 的方法去掉之后,输出将变为 hello char...
。
7 个重载方法已经被移除的只剩下一个类,可变变长参数的重载优先级是最低的,这时候 'a'
被当做一个数组元素。这里使用的是 char 类型的变长参数,读者在验证时还可以选择 int、Character、Object 类型等的变长参数重载来重新演示上面的例子。但要注意的是,有一些在单个参数中能成立的自动转型,如 char 到 int,在变长参数中是不成立的。
上面的例子演示了编译期间选择静态分派目标的过程,该过程也是 Java 语言实现方法重载的本质。比较容易混淆的是,解析与分派的关系并非二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。如前所述,静态方法会在类加载期间进行解析,而静态方法也可以拥有重载版本,选择重载版本的过程就是通过静态分派完成的。
2. 动态分派
动态分配与多态性的另外一个重要体现——重写(override)有着密切的联系。
public class DynamicDispatch{
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
@Override
protected void sayHello(){
System.out.println("man say hello");
}
}
static class Woman extends Human{
@Override
protected void sayHello(){
System.out.println("woman say hello");
}
}
public static void main(String[]args){
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}
// 结果
// man say hello
// woman say hello
// woman say hello
这里,虚拟机不再根据静态类型来选择要调用的方法,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,JVM 是如何根据实际类型来分派方法执行版本呢?这里使用 javap 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如下:
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0:new#16;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Man
3:dup
4:invokespecial#18;//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Man."<init>":()V
7:astore_1
8:new#19;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Woman
11:dup
12:invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispa
tch $Woman."<init>":()V
15:astore_2
16:aload_1
17:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Human.sayHello:()V
20:aload_2
21:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Human.sayHello:()V
24:new#19;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Woman
27:dup
28:invokespecial#21;//Method org/fenixsoft/polymorphic/Dynam
icDispatch $Woman."<init>":()V
31:astore_1
32:aload_1
33:invokevirtual#22;//Method org/fenixsoft/polymorphic/
DynamicDispatch $Human.sayHello:()V
36:return
0~15 行的代码是准备动作,作用是建立 man 和 women 的内存空间、调用 Man 和 Women 类型的实例构造器,将这两个实例的应用存放在 1、2 两个局部变量表的 slot 之中,该动作对应代码中的两行代码:
Human man=new Man();
Human woman=new Woman();
接下来 16~21 行是关键部分,16、20 两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的 sayHello 方法的持有者,称为接收者;17、21 是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 22 项的常量,注释还显示了该常量是 Human.sayHello 的符号引用)完全一样,但是这两行指令最终执行的目标方法是不同的。原因就需要从 invokevirtual 指令的多态查找过程说起,invokevirtual 指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C。
- 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果校验通过则返回该方法的直接引用,查找过程结束;如果不通过,则返回非法访问异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类机执行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出抽象方法异常。
由于 invokevirtual 指令指定的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,该过程就是 Java 中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3. 但分派与多分派
方法的接收者与方法的参数统称为方法的宗量,该定义最早来自《Java 与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多个宗量对目标方法进行选择。
public class Dispatch{
static class QQ{}
static class_360{}
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}
public void hardChoice(_360 arg){
System.out.println("father choose 360");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}
public void hardChoice(_360 arg){
System.out.println("son choose 360");
}
}
public static void main(String[]args){
Father father=new Father();
Father son=new Son();
father.hardChoice(new_360());
son.hardChoice(new QQ());
}
}
// 结果
// father choose 360
// son choose qq
在 main 函数中调用了两次 hardChoice 方法,这两次方法调用的选择结果在程序输出中已经显示的很清晰了。
在编译器编译期间的静态分派过程中,选择方法主要依据两点:一是静态类型是 Father 还是 Son,二是方法参数是 QQ 还是 360。这次选择的最终产物是产生了两条 invokevirtual 指令,两条指令的参数分别是常量池中指向 Father.hardChoice(360)
和 Father.hardChoice(QQ)
方法的符号引用。因为根据两个宗量进行选择,所以 Java 的静态分派属于多分派。
再看看运行时阶段虚拟机的选择,也就是动态分派过程。在执行 son.hardChoice(QQ)
这行代码对应的 invokevirtual 指令时,由于编译器已经决定目标方法的签名必须是 hardChoice(QQ)
,虚拟机此时不关心传递过来的参数是 QQ 的哪个子类实现,因为这时参数是静态类型、实际类型都对方法的选择不构成影响,唯一可以印象虚拟机选择的因素只有此方法的接收者的实际类型是 Father 还是 Son。意味只有一个宗量作为选择依据,所以 Java 的动态分派属于单分派。
根据上述论证的结果,我们可以总结出:现在的 Java(1.8之前) 是一种静态多分派、动态单分派的语言。
按照目前 Java 语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如 JavaScript)执行引擎的方式来满足动态性的需求。但是 JVM 层面上则不同,在 JDK 1.7 中实现的 JSR-292 里面就已经开始提供对动态语言的支持了,JDK 1.7 中新增的 invokedynamic 指令也成为了最复杂的一条方法调用字节码指令。
4. 虚拟机动态分派实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个续方法表(vtable,于此对应,invokeinterface 执行时也会用到接口方法表,即 itable),使用虚方法表索引来代替元数据查找以提高性能。
下图是虚方法表的数据结构示例:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了父类的方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。在上图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承而来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完成。
上文中说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存和基于“类型继承关系分析”技术的守护内联两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程,可参考本书第 11 章。
8.3.3 动态类型语言支持
从 Sun 公司的第一款 JVM 问世至 JDK7 来临之前的十余年时间里,JVM 的字节码指令集的数量一直都没有发生过变化。随着 JDK7 的发布,字节码指令集终于迎来了第一位新成员——invokedynamic 指令。这条新增加的指令是 JDK7 实现“动态类型语言”支持而进行的改进之一,也是为 JDK8 可以顺利实现 Lambda 表达式做出技术准备。
1. 动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而非编译期。相对的,在编译期进行类型检查的过程的语言就是常说的静态类型语言。
静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题在编码时就能发现,利于稳定性以及代码达到较大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,有动态类型语言来实现可能会更加清晰简洁,这也就意味着开发效率的提升。
2. JDK7 与动态类型
JVM 层面对动态类型语言的支持一直有所欠缺,主要变现在方法调用方面:JDK7 之前的字节码指令集中,4 条方法调用指令的第一个参数都是被调用方法的符号引用。前面已经说过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在 JVM 上实现的动态类型语言就不得不使用其他方式(如在编译期留一个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必会增加动态类型语言的实现难度,也可能会带来额外的性能或内存开销。尽管可以利用一些方法(如 Call site Caching)让这些开销尽可能变小,但这种底层问题终归是应当在虚拟机层次上来解决才最合适,因此在 JVM 层面上提供动态类型的直接支持就成为了 Java 平台的发展趋势之一,这就是 JDK7 中 JSR-292 invokedynamic 指令以及 java.lang.invoke 包出现的技术背景。
3. java.lang.invike 包
JDK7 实现了 JSR-292,新加入的 java.lang.invoke 包就是 JSR-292 的一个重要组成部分,该包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式之外,提供一种新的动态确定目标方法的机制,称为 MethodHandle。
这类似于 C/C++ 中的函数指针,或者 C# 中的 Delegate 类。比如我们要实现一个带谓词的排序函数,在 C/C++ 中的做法是把谓词定义为函数,用函数指针把谓词传递给排序方法:
void sort(int list[], const int size,int(*compare)(int,int))
Java 无法把方法作为一个参数进行传递。普遍的做法是设计一个带有 compare 方法的 Comparator 接口,以实现了这个接口的对象作为参数,如 Colleciton.sort 就是这样定义的:
void sort(List list,Comparator c)
在拥有 MethodHandle 之后,Java 也可以拥有类似函数指针或委托的方法别名工具了。但在看完 MethodHandle 的用法之后大家可能会有疑惑,相同的事情,反射不是已经早就实现了吗?确实,仅站在 Java 语言的角度来看,MethodHandle 的使用方法和效果与 Reflection 有着众多相似之处,不过,它们还是有以下区别:
从本质上讲,Reflection 和 MethodHandle 都是在模拟方法调用,但 Refection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。在 MethodHandle.lookup 中的 3 个方法——findStatic、findVirtual、findSpecial 正是为了对应于 invokestatic、invokevirtual/invokeinterface、invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 是不需要关心的。
Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象包含的信息多。前者是在 Java 一端的全面镜像,包含了方法签名、描述符以及方法属性表中各种属性的 Java 端表示方式,还包含执行权限等运行时信息。而后者仅仅包含与执行该方法相关的信息。即,Reflection 属于重量级 API,而 MethodHandle 属于轻量级。
由于 MethodHandle 是对字节码指令指令调用的模拟,所以理论上虚拟机在这方面做得各种优化(如方法内联),在 MethodHandle 上也应当可以采用类似的思路来支持(但目前还不完善)。而通过反射调用则行不通。
MethodHandle 与 Reflection 除了上面列举的区别外,最关键的一点还在于去掉前面讨论事假的前提“仅站在 Java 语言的角度来看”:Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成客服务于所有 JVM 之上的语言,其中也包含 Java。
4. invokedynamic 指令
在某种程度上,invokedynamic 指令与 MethodHandle 机制的作用一样,都是为了解决原有 4 条 invoke 指令方法分派规则规划在虚拟机中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体的用户代码中,让用户(或其他语言的设计者)拥有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层 Java 代码和 API 来实现;另一个用字节码和 Class 中的属性、常量来完成。
每一处含有 invokedynamic 指令的位置都称作“动态调用点”,该指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是变为 JKD7 新加入的 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到 3 项信息:引导方法(存放在新增的 BootstrapMethods 属性中)、方法类型、方法名称。引导方法有固定的参数,且返回值是 java.lang.invoke.CallSite 对象,该对象代表要真正执行的目标方法调用。根据 CONSTANT_InvokeDynamic_info 中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个 CallSite 对象,最终滴啊用要执行的目标方法。
前面讲过,由于 invokedynamic 指令面向的使用者并非只有 Java 语言,而是其他 JVM 上的动态语言,因此紧靠 Java 语言的编译器 javac 没有办法生成带有 invokedynamic 指定的字节码,所以需要使用 Java 语言来演示 invokedynamic 指令只能用一些变通办法。John Rose 编写了一个把程序的字节码转换为使用 invokedynamic 指令的简单工具 INDY 来完成这件事情,我们我们可以使用该工具来生成最终需要的字节码。
5. 掌控方法分派规则
invokedynamic 指令与前面 4 条 invoke 指令的最大差别就是它的分派逻辑不是由 JVM 决定的,而是有开发者决定。下面是一个简单的例子:
class GrandFather{
void thinking(){
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("i am father");
}
}
class Son extends Father{
void thinking(){
//请读者在这里填入适当的代码(不能修改其他地方的代码)
//实现调用祖父类的thinking()方法, 打印"i am grandfather"
}
}
在 Java 程序中,可以通过 super 关键字来滴啊用父类中的方法,但是如何调用始祖类的方法呢?
在 JDK7 之前,使用纯粹的 Java 语言很难处理这个问题(直接生产字节码很简单,如使用 ASM 字节码生产工具),原因是在 Son 类的 thinking 方法中无法获取一个实际类型是 GrandFather 的对象引用,而 invokevirtual 指令的分派逻辑就是按照方法接收者的实际类型来进行分派的,该逻辑是固化在 JVM 中的,开发者无法改变。在 JDK7 中,可以使用以下代码来解决该问题。
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
class Test{
class GrandFather{
void thinking(){
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("i am father");
}
}
class Son extends Father{
void thinking(){
try{
MethodType mt=MethodType.methodType(void.class);
MethodHandle mh=lookup().findSpecial(GrandFather.class, "thinking", mt,getClass());
mh.invoke(this);
}catch(Throwable e){
}
}
}
public static void main(String[]args){
(new Test().new Son()).thinking();
}
}
8.4 基于栈的字节码解释执行引擎
上面介绍了 JVM 是如何调用方法的,下面接着介绍 JVM 是如何执行方法中的字节码指令的。很多 JVM 的执行引擎在执行 Java 代码时都有解释执行(通过解释器解释字节码来执行)和编译执行(通过编译器将字节码便以为本地代码来执行)两种选择。
8.4.1 解释执行
Java 语言经常被人们定位为解释执行的语言,在 JDK1 时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器之后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断问题。再后来,Java 也发展出了可以直接生成本地代码的编译器,而 C/C++ 也出现了通过解释器执行的版本,这时候就再笼统的说解释执行,对于整个 Java 语言来说就成了无意义的概念,只有确定过了谈论对象是某种具体的 Java 实现版本和执行引擎的运行模式时,对解释执行和编译执行的讨论才是有意义的。
无论是解析还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
如今,基于物理机、JVM、或者非 Java 的其他高级语言虚拟机的语言,大多都会遵循这种现代编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器来实现,这类代表语言是 C/C++。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表就是 Java。又或者把这些步骤和执行引擎全部集中封装到一个封闭的黑匣子中,如大多数的 JavaScript 执行器。
Java 中,javac 编译器完成了程序代码经过词法分、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这部分动作是在 JVM 之外进行的,而解释器位于 JVM 内部,所以 Java 的编译就是半独立实现的。
8.4.2 基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 X86 的二地址指令集,通俗的说,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
举个简单的例子,分别使用两种指令集架构来实现 1+1
,基于栈的指令集会是这样:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈,iadd 指令把栈顶的两个值出栈、相加,然后把结果放入栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 slot 中。
如果是基于寄存器架构,程序会是这种形式:
mov eax, 1
add eax, 1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把该值加 1,结果就保存在 EAX 寄存器中。
基于栈的指令集主要优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件的约束。如,现在 32 位 80x86 体系的处理器中提供了 8 个 32 位的寄存器,而 ARM 体系的 CPU 则提供了 16 个 32 位的通用寄存器。如果使用基于栈的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中已获得尽量好的性能,这样实现起来也更加简单。栈架构的指令集还有一些其他优点,如代码相对更加紧凑(字节码中每个字节都对应一条指令,而多地址指令集中还需要存放参数)、编译器更加简单(不需要考虑空间分配问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会慢一点。所有主流物理机的指令集都是寄存器架构。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
8.4.3 基于栈的解释器执行过程
一个简单的算术示例:
public int calc(){
int a=100;
int b=200;
int c=300;
return(a+b)*c;
}
使用 javap 查看其字节码指令:
public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0:bipush 100
2:istore_1
3:sipush 200
6:istore_2
7:sipush 300
10:istore_3
11:iload_1
12:iload_2
13:iadd
14:iload_3
15:imul
16:ireturn
}
字节码指令中显示,需要深度为 2 的操作数栈和 4 个 slot 的局部变量空间,如下一系列的图中,展示了该段代码在执行过程中代码、操作数栈、局部变量表的变化情况。
上面的执行过程仅仅是一种概念模型,虚拟机会对执行过程做一些优化来提高运行性能,实际的运作过程不一定完全符合概念模型的描述。更加准确的说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器对输入的字节码进行优化,如,在 HotSpot 虚拟机中,有很多以 “fast_” 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。
不过,我们从这段程序的执行中也可以看出站结构指令集的一般运行过程,整个运算过程的中间变量都以操作数出入栈为信息交换途径,符合我们在前面分析的特点。
8.5 本章小结
本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法中的字节码,以及执行代码时涉及的数据结构。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.