Hexo

点滴积累 豁达处之

0%

4 虚拟机字节码执行引擎

代码编译的结果 从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

1 概述

执行引擎是Java虚拟机最核心的组成部分之一,虚拟机可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

执行引擎在执行代码时,可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至包含几个不同级别的编译器执行引擎。但从外观来看,所有Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码的等效过程,输出的是执行结果。

2 运行时栈帧结构

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

在编译期,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

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

![图8-1 栈帧的概念结构](/images/jvm/图8-1 栈帧的概念结构.png)

2.1 局部变量表

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

对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的64位类型只有long和double两种,把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法有些类似,可以与Java内存模型做一个对比。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

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

Slot是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过,这种复用也会影响到垃圾收集的行为。
局部变量没有类变量的准备阶段,所以必须为局部变量赋初始值。

2.2 操作数栈

操作数栈也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

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

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。

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

![图8-2 两个栈帧之间的数据共享](/images/jvm/图8-2 两个栈帧之间的数据共享.png)

2.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存放有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

2.4 方法返回地址

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

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法来返回指令来决定,这种退出方式称为正常完成出口;
  • 另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

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

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

2.5 附加信息

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

3 方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经说过,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

3.1 解析

继续前面关于调用的话题,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可能改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过集成或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

        1,invokestatic:调用静态方法;

        2,invokespecial:调用实例构造器方法、私有方法和父类方法;

        3,invokevirtual:调用所有的虚方法;

        4,invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;

        5,invokedynamic:先在运行时动态解析出调用掉限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。下面代码演示了一个最常见的解析调用的例子,在此样例中,静态方法sayHello()只可能属于类型StaticResolution,没有任何手段可以覆盖或隐藏这个方法。

1
2
3
4
5
6
7
8
9
public class StaticResolution {

public static void sayHello(){
System.out.println("hello world");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}

使用javap 命令查看这段程序的字节码,会发现的确是通过invokestatic命令开调用sayHello()方法的。

虚拟机字节码引擎2

Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分配(Dispatch)调用则可能是静态的也可能是动态的,根据分配依据的宗量数可分为单分派和多分派。这两类分配方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分配组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。

3.2 分派

Java是一门面向对象的程序语言,因为其具备面向对象的3个基本特征:继承、封装和多态。这一小节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

3.2.1 静态分配

在开始说静态分配之前,JVM作者准备了一段经常出现在面试题中的程序代码,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 方法静态分派演示
* @author Peter
*
*/
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, man!");
}
public void sayHello(Woman gay){
System.out.println("hello, woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}

运行结果:

1
2
hello,guy!
hello,guy!

上面的代码为什么会选择执行参数类型为Human的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的概念。

1
Human man = new Man();

我们把上面的代码中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么。例如下面代码:

1
2
3
4
5
6
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
sd.sayHello((Man)man);
sd.sayHello((Woman)man);

解释了这两个概念,再回到代码清单的样例代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sd”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译期会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。这种模糊的结论在由0和1构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。下面代码演示了何为“更加合适的”版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.io.Serializable;
/**
* 重载方法匹配优先级
* @author Peter
*
*/
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');
}
}
1
输出结果:**hello char**

因为‘a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,

1
如果注释掉sayHello(char arg)方法,那输出会变为: **hello int **

这时发生了一次自动类型转化,‘a’除了可以代表一个字符串,还可以代表数字97(字符‘a’的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。

1
继续注释掉sayHello(int arg)方法,那输出会变为: **hello long**

这时发生了两次自动类型转换,‘a’转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。

1
继续注释掉sayHello(long arg)方法,那输出会变为:**hello Character**

这时发生了一次自动装箱,‘a’被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载,

1
继续注释掉sayHello(Character arg)方法,那输出会变为: **hello Serializable **

这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现hello Serializable,是因为java.lang.Serializable是java.lang.Character类出现的一个接口,当自动装箱之后,发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.Comparable,如果同时出现两个参数分别为Serializable和Comparable的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显示地指令字面量的静态类型,如:sayHello((Comparable)’’a),才能编译通过。

1
下面继续注释掉sayHello(Serializable arg)方法,输出会变为:**hello Object **

这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。

1
把sayHello(Object arg)也注释掉,输出将会变为: **hello char... **

7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符‘a’被当做一个数组元素。JVM作者使用的是char类型的变长参数,读者在验证时还可以选择int类型、Character类型、Object类型等的变长参数重载来把上面的过程重新演示一遍。但要注意的是,有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的。

上面的代码演示了编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。演示所用的这段程序属于很极端的例子,除了用做面试题为难求职者以外,在实际工作中几乎不可能有实际用途。JVM笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,大部分情况下进行这样极端的重载都可算是真正的“关于茴香豆的茴有几种写法的研究”。无论对重载的认识有多么深刻,一个合格的程序员都不应该在实际应用中写出如此极端的重载代码。

另外还有一点读者可能比较容易混淆:笔者讲述的解析与分配这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

3.2.2 动态分配

了解了静态分派,我们接下来看一下动态分派的过程,它和多态性的另外一个重要体现——重写(Override)有着密切的关联。还是用前面的Man和Woman一起sayHello()的例子来讲解动态分派,接下来我们请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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();
man.sayHello();
Human woman = new Woman();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

执行结果:

1
2
3
man say hello
woman say hello
woman say hello

虚拟机是怎样去调用哪个方法的?显然这里是不能根据静态类型来决定的,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?使用javap命令输出这段代码的字节码,尝试从中寻找答案。

动态分配2

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:

1
2
Human man = new Man();
Human woman = new Woman();

接下来的16~21局是关键部分,16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver):17和21局是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束:如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和校验过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分配过程称为动态分派。

3.2.3 单分派与多分派

方法的接收者和方法的参数统称为方法的宗量(该说法最早源于《Java与模式》一书)。根据分派基于多少种宗量,可以将分派分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照着实例看就不难理解了。下面的代码列举了一个Father和Son一起来做出“一个艰难的决定”的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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());
}
}

执行结果:

1
2
father choose 360
son choose qq

在main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结构在程序输出中已经显示得很清楚了。

我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father。hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两次宗量进行选择,所以Java语言的静态分派属于多分派类型。

 再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“Son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因此只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分配属于单分派类型。

根据上述论证的结构,我们可以总结一句:今天的Java语言是一门静态多分派、动态单分派的语言。强调“今天的Java语言”是因为这个结论未必会恒久不变,C#在3.0及之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型后,就可以很方便地实现动态多分派。

3.2.4 虚拟机动态分派的实现

前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派中“会做什么”这个问题。但是虚拟机“具体是如何做到的”,可能各种虚拟机的实现都会有差别。

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。我们先来看看上面代码清单所对应的虚方法表结构示例,如下图:

![图8-3 方法表结构](/images/jvm/图8-3 方法表结构.png)

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法 表中的地址将会替换为指向子类实现版本的入口地址。上图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来自的方法都指向了Object的数据类型。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

上文中笔者说方法表还是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程。读者可以参考JVM作者第11章中的相关内容。

3.3 动态类型语言支持

Java虚拟机的字节码指令集的数量从Sun公司的第一款Java虚拟机问世至JDK 7来临之前的十余载时间里,一直没有发生任何变化。随着JDK 7的发布,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增的指令是JDK 7实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8可以顺利实现Lambda表达式做技术准备。在本节中,将详细讲解JDK 7这项新特性出现的前因后果和它的深远意义。

#### 3.3.1 动态类型语言

在介绍Java虚拟机的动态类型语言支持之前,我们先弄明白动态类型语言是什么?它与Java语言、Java虚拟机有什么关系?了解JDK 1.7提供动态类型语言支持的技术背景,对理解这个语言特性是很有必要的。

什么是动态类型语言?动态类型语言的关键特征是它的类型检查的主题过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相对的,在编译期就进行类型检查过程的语言(如C++和Java等)就是最常用的静态类型语言。

1
2
3
public static void main(String []args){
int[][][] array = new int[1][0][-1];
}

这段代码能够正常编译,但运行的时候会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点来说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。

3.3.2 JDK1.7与动态类型

JVM毫无疑问是Java语言的运行平台,但在1997年出版的《Java虚拟机规范》中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于JVM之上”。而目前确实已经有许多动态类型语言运行于JVM之上,如Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性。

但遗憾的是,Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要变现在方法调用方面:JDK1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符,在运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就称为了Java平台的发展趋势之一,这就是JDK1.7(JSR-292)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

3.3.3 java.lang.invoke包

3.3.4 invokedynamic指令

3.3.5 掌控方法分派规则

3.4 栈的字节码解释器执行引擎

许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本章中,我们先来探讨一下在解释执行时,虚拟机执行引擎是如何工作的。

3.4.1 解释执行

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代 ,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来 ,Java也发展出了可以直接生成本地代码的编译器[如GCJ」(GNU Compiler for the Java )],而C/C++语言也出现了通过解释器执行的版本(如CINT) ,这时候再笼统地说“解释执行”,对于整个 Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解 ,然后就获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8-4中的各个步骤。如果读者对编译原理的相关课程还有印象的话,很容易就会发现图8-4中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。

如今,基于物理机、Java虚拟机,或者非Java的其他高级语言虚拟机(HLLVM )的语 言 ,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树( Abstract Syntax Tree,AST)。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java 语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行器。

![图8-4 编译过程](/images/jvm/图8-4 编译过程.png)

Java语言中 ,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的, 而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

3.4.2 基于栈与寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构 , 指令流中的指令大部分都是零地址指令,它们包赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集 ,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么 ,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

举个最简单的例子,分别使用这两种指令集计算“ 1+1”的结果,基于栈的指令集会是这样子的:

1
2
3
4
iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相 加 ,然后把结果放回栈顶 ,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

如果基于寄存器,那程序可能会是这个样子:

1
2
mov eax ,1 
add eax ,1

mov指令把EAX寄存器的值设为1 ,然后add指令再把这个值加1 ,结果就保存在EAX寄存器里面。

了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有进一步的疑问, 这两套指令集谁更好一些呢?

应该这么说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,就不会存在选择的问题了。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如 ,现在32位80x86体系的处理器中提供了8 个32位的寄存器,而ARM体系的CPU ( 在当前的手机、PDA中相当流行的一种处理器)则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器 ,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等) 放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一 些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作 ) 等。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是 ,栈实现在内存之中 ,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问 ,但这也只能是优化措施而不是解决本质问题的方法。 由于指令数量和内存访问的原因 ,所以导致了栈架构指令集的执行速度会相对较慢。

注:  部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也就是都不存在显式的参数。Java这样实现主要是考虑了代码的可校验性。

这里说的是物理机器上的寄存器,也有基于寄存器的虚拟机,如Google Android平台的 Dalvik VM。即使是基于寄存器的虚拟机,也希望把虚拟机寄存器尽量映射到物理寄存器上以获取尽可能高的性能。

1 基于栈的解释器执行过程

初步的理论知识已经讲解过了,本节准备了一段Java代码 ,看看在虚拟机中实际是如何执行的。前面曾经举过一个计算“ 1+1”的例子,这样的算术题目显然太过简单了,笔者准备了四则运算的例子,请看代码清单8-16。

![代码清单8-16 一段简单的算术代码](/images/jvm/代码清单8-16 一段简单的算术代码.png)

从Java语言的角度来看,这段代码没有任何解释的必要,可以直接使用javap命令看看它的字节码指令,如代码清单8-17所示。

![代码清单8-17 一段简单代码的字节码表示](/images/jvm/代码清单8-17 一段简单代码的字节码表示.png)

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,笔者根据这些信息画了图8-5〜图8-11共7张图,用它们来描述代码清单8-17执行过程中的代码、操作数栈和局部变量表的变化情况。

![图8-5 执行偏移地址为0的指令情况](/images/jvm/图8-5 执行偏移地址为0的指令情况.png)

![图8-6 执行偏移地址为1的指令情况](/images/jvm/图8-6 执行偏移地址为1的指令情况.png)

![图8-7 执行偏移地址为11的指令情况](/images/jvm/图8-7 执行偏移地址为11的指令情况.png)

![图8-8 执行偏移地址为12的指令情况](/images/jvm/图8-8 执行偏移地址为12的指令情况.png)

![图8-9 执行偏移地址为13的指令情况](/images/jvm/图8-9 执行偏移地址为13的指令情况.png)

![图8-10 执行偏移地址为14的指令情况](/images/jvm/图8-10 执行偏移地址为14的指令情况.png)

![图8-11 执行偏移地址为16的指令情况](/images/jvm/图8-11 执行偏移地址为16的指令情况.png)

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能 ,实际的运作过程不一定完全符合概念模型的描述……更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如 ,在HotSpot虚拟机中,有很多以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译的优化手段更加花样繁多。

不过 ,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。