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

概述

执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行哪些不被硬件直接支持的指令集够格式。

Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能有解释执行(通过解释器执行)编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观上看起来,所有Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

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

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

一个线程中的方法调用链可能回很长,很多方法都同事处于执行状态。对于执行引擎来讲,活动线程中,只有栈顶是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

栈帧的概念结构
栈帧的概念结构

局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中没有明确指明一个Slot应占内存空间大小,只是说明每个Slot都应该能存放一个boolean、btye、char、short、int、float、reference或returnAddress类型的数据,它允许Slot的长度随着处理器、操作系统或虚拟机的不同而发生变化。不过无论如何,即使在64位虚拟机中使用了64位长度的内存空间里爱实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机的一致。

对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的Slot空间。Java语言中明确规定的64位的数据类型只有long和double两种。

这里把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把long和double数据类型读写分割为两次32位读写的做法类似。不过,由于局部变量表建立在线程的对战上,是线程私有的数据,无论读写两个连续的Slot是否是原子操作,都不会引起数据安全问题。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot变量。如果是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明要使用第n和第n+1两个Slot。

操作数栈

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

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

例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

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

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

两个栈帧之间数据共享
两个栈帧之间数据共享

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

动态连接

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

方法返回地址

当一个方法被执行后,有两种方式退出这个方法。

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

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

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令

附加信息

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

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

在程序运行时,进行方法调用时最普遍、最频繁的操作,但前面已经讲过,Class文件进行编译过程中不包含传统编译中的连续步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂起来,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。

解析

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

在Java语言中,符合“编译器可知,运行期不可变”这个要求的方法主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或别的方式充血出其他版本,因此它们都适合在类加载阶段进行解析。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能时静态的也可能时动态的,根据分派一句的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派情况。

分派

Java是一门面向对象的程序设计语言,因为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
34
35
package com.cayzlh.demo;

/**
* 方法静态分派演示
*/
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 women = new Woman();

StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(women);
}

}

运行结果:

1
2
Hello, guy!
Hello, guy!

相信对Java稍微有经验的程序员看完程序后都能得出正确的运行结果,但为什么会选择执行参数类型为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 Women();
// 静态类型变化
sd.sayHello((Man) man);
sd.sayHello((Women) women);

会到代码中,main()里面两次sayHello()方法调用,在方法接收者已经确定是对象sd的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中定义了两个静态类型相同、实际类型不同的变量,单虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加适合的”版本。

动态分派

动态分派和多态性的另外一个重要体现——重写(Override)有着很密切的关联。

代码清单:

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
package com.cayzlh.demo;

/**
* 方法动态分派演示
*/
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 Women extends Human {

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

public static void main(String[] args) {
Human man = new Man();
Human women = new Women();

man.sayHello();
women.sayHello();

man = new Women();
man.sayHello();
}

}

运行结果:

1
2
3
man say hello
women say hello
women say hello

显然这里是不可能根据静态类型来决定的,因为静态类型都是Human的两个变量manwomen在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。

单分派与多分派

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

代码清单(列举一个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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.cayzlh.demo;

/**
* 单分派与多分派
*/
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

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

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

虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真的进行如此频繁的搜索。

面对这种情况,最常用的“稳定优化”手段就是**在类的方法区中建立一个徐方法表,使用虚拟方法表索引来代替原数据查找以提高性能。

方法表结构
方法表结构

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的入口时一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会被替换为指向子嘞实现版本的入口地址。

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

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

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

解释执行

不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读和理解,然后几获得了理解能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过编译过程。如下图,中下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支自然就是解释执行的过程。

编译过程
编译过程

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

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

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构师基于寄存器的指令集,最典型的就是x86的二地址指令集。

基于栈的指令集最主要的优点就是可移植性,寄存器由硬件直接提供,程序直接以来这些硬件寄存器则不可避免地要受到约束。

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

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

小结

分析了虚拟机在执行代码时如何找到正确的方法,如何执行方法内的字节码,以及执行代码时涉及的内存结构。针对Java程序时如何存储的、如何载入(创建)的以及如何执行的问题相关知识讲解了一下。

参考

  • 周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社