虚拟机字节码执行引擎

虚拟机字节码执行引擎

执行引擎是Java虚拟机最核心的组成部分之一,物理机的执行引擎是直接建立在处理机,硬件,指令集和操作系统层面上的,而虚拟机的执行引擎是自己实现的,可以自行制定指令集与执行引擎的结构体系,所有Java虚拟机的执行引擎都是输入字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果

运行时栈帧结构

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

每一个栈帧都包括局部变量表,操作数栈,动态链接,方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定,并且写入方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现

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

局部变量表

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

操作数栈

操作数栈是一个后进先出栈。同局部变量表一样,操作数栈的最大深度也可以在编译阶段写入Code属性中,数据项为max_stacks。操作数栈的每一个元素可以是任意Java数据类型,32位数据类型所占栈容量为1,64位为2

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

动态链接

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

方法返回地址

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

一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能有返回值传递给上层方法的调用者,是否有返回值和返回值的类型将根据何种方法返回指令来决定,这种退出方法称为正常完成出口

另外一种退出方法是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表没有搜索到匹配的异常处理器,就会导致方法退出。这种称为异常完成出口,一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的

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

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

方法调用

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

解析

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

在Java语言中符合”编译期可知,运行期不可知”这个要求的方法,主要包括静态方法和私有方法两大类,这两种方法各自的特点决定了它们不可能通过继承或别的方法重写其他版本,因此它们都适合在类加载阶段进行解析

只要能被invokestatic和invokespecial(调用实例构造器<init>方法,私有方法和父类方法)指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法称为非虚方法,与之相反的方法称为虚方法(final除外)

对于final修饰的方法,虽然使用invokevirtual指令来调用,但是由于它无法被覆盖,所以也无须对方法接收者进行多态选择,final方法也是一种非虚方法

解析调用一定是静态的过程,在编译期就完全确认,在类装载的解析阶段就会把涉及的符号引用转变为可确定的直接引用,不会延迟到运行期再去完成

分派

分派调用过程会揭示多态性特征的一些最基本的体现,如”重载”和”重写”在Java虚拟机是如何实现的

静态分派

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
public class StaticDispatch {
private static abstract class Human { }

private static class Man extends Human { }

private static class Woman extends Human { }

private void sayHello(Human guy) {
System.out.println("Hello, guy!");
}

private void sayHello(Man man) {
System.out.println("Hello, man!");
}

private void sayHello(Woman woman) {
System.out.println("Hello, woman!");
}

public static void main(String[] args) {

Human man = new Man();
Human woman = new Woman();
StaticDispatch dispatch = new StaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(woman);
}
}

/* 输出结果:
Hello, guy!
Hello, guy!
*/

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

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

虚拟机(编译器)在重载时通过参数的静态类型而不是实际类型作为判定依据,编译阶段Javac编译器会根据参数的静态类型决定使用哪个重载版本,并将这个方法的符号引用写到invokevirtual指令参数中。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派典型应用是方法重载。静态分派发生在编译阶段

很多情况下重载版本不是唯一的,往往只能确定一个更加合适的版本,如下:

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
public class Overload {

static void sayHello(Object arg) {
System.out.println("Hello, Object!");
}

static void sayHello(int arg) {
System.out.println("Hello, int!");
}

static void sayHello(long arg) {
System.out.println("Hello, long!");
}

static void sayHello(Character arg) {
System.out.println("Hello, Character!");
}

static void sayHello(char arg) {
System.out.println("Hello, char!");
}

static void sayHello(char... arg) {
System.out.println("Hello, char...!");
}

static void sayHello(Serializable arg) {
System.out.println("Hello, Serializable!");
}

public static void main(String[] args) {
sayHello('a');
}
}

它会输出”Hello, char!”,注释掉sayHello(char arg),会输出”Hello int!”,这时发生了一次自动类型转换,它会按照char->int->long->float->double的顺序转型进行匹配(不会匹配到byte和short,因为是不安全的),继续注释到sayHello(long),会输出”Hello, Character!”,这次发生了自动装箱,继续注释则”Hello, Serializable!”,装箱类实现了序列化类的接口,所以可以自动转型,注意Character不会转型为Integer,它只能安全地转型为它实现的接口或父类,如果实现了多个接口,且优先级一样,则会提示类型模糊,拒绝编译,如果有多个父类,则在继承关系中从下往上搜索,越靠近上层优先级越低,变长参数的重载优先级是最低的

动态分派

动态分派和动态性的另一个重要体现—重写有很密切关系

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
public class DynamicDispatch {

static abstract class Human {

abstract void sayHello();
}

static class Man extends Human {

@Override
void sayHello() {
System.out.println("Man say hello!");
}
}

static class Woman extends Human {
@Override
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();
}
}

静态类型同样都是Human的两个变量man和woman在调用sayHello()方法的时候执行了不同的行为,关键是invokevirtual指令的多态查找过程,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

即重写使用的是动态分派,最终调用的方法一般上来说是实际类型的方法,如果没有则往上搜索其父类的方法

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派,单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择

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
41
42
43
44
public class Dispatch {

static class QQ { }

static class _360 { }

static class Father {

public void hardChoice(QQ qq) {
System.out.println("Father choose QQ!");
}

public void hardChoice(_360 qiHu360) {
System.out.println("Father choose 360!");
}
}

static class Son extends Father {

@Override
public void hardChoice(QQ qq) {
System.out.println("Son choose QQ!");
}

@Override
public void hardChoice(_360 _360) {
System.out.println("Son choose 360!");
}
}

public static void main(String[] args) {

Father father = new Father();
Father son = new Son();

father.hardChoice(new QQ());
son.hardChoice(new _360());
}
}

/* 输出结果
Father choice QQ!
Son choice 360!
*/

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

再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行”son.hardChoice(new QQ())”的时候,由于编译期已经决定目标方法的签名必须是hardChoice(QQ),唯一影响虚拟机选择的因素只有此方法的接受者的时机类型是Father还是Son,因为只有一个宗量可以选择,所以Java语言的动态分派属于单分派类型

综上,目前Java语言是一门静态多分派,动态单分派的语言

基于栈的字节码解释执行引擎

Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择

解释执行

Java 语言常被人们定义成「解释执行」的语言,但随着 JIT 以及可直接将 Java 代码编译成本地代码的编译器的出现,这种说法就不对了。只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

无论是解释执行还是编译执行,无论是物理机还是虚拟机,对于应用程序,机器都不可能像人一样阅读、理解,然后获得执行能力。大部分的程序代码到物理机的目标代码或者虚拟机执行的指令之前,都需要经过下图中的各个步骤。下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支,则是解释执行的过程。

如今,基于物理机、Java 虚拟机或者非 Java 的其它高级语言虚拟机的语言,大多都会遵循这种基于现代编译原理的思路,在执行前先对程序源代码进行词法分析和语法分析处理,把源代码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++。也可以为一个半独立的编译器,这类代表是 Java。又或者把这些步骤和执行全部封装在一个封闭的黑匣子中,如大多数的 JavaScript 执行器。Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树、再遍历语法树生成字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现

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

Java 编译器输出的指令流,基本上是一种基于栈的指令集架构。基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件约束。栈架构的指令集还有一些其他优点,比如相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译实现更加简单(不需要考虑空间分配的问题,所有空间都是在栈上操作)等

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

虽然栈架构指令集的代码非常紧凑,但是完成相同功能需要的指令集数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存中,频繁的栈访问也意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢