概述

执行引擎是Java虚拟机核心的组成部分之一。虚拟机执行引擎是由软件自行实现的,可以执行那些不被硬件直接支持的指令集格式。

运行时栈帧结构

Java虚拟机以方法为最基本的执行单元,栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。

一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

局部变量表

局部变量表是一组变量值的存储空间,存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位。一个Slot可以存放一个32位以内(boolean、byte、char、short、int、float、reference和returnAddress)的数据类型,reference类型表示一个对象实例的引用,returnAddress已经很少见了,可以忽略。

image-20200514224901767

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

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

当方法调用时,Java虚拟机使用局部变量表完成参数值到参数列表的传递过程,即实参到形参的传递。执行的是实例方法的话,局部变量表第0位索引变量槽默认是传递这个方法所属对象实例的引用,可以通过this访问。

为了节省栈帧空间,局部变量表的Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:在某些情况下,Slot的复用会直接影响到系统的收集行为。

局部变量并不像类变量那样会有一个“准备阶段”。也就是说局部变量不会被默认赋零值,这也就是为什么局部变量需要显式赋值后才能被使用的原因。

操作数栈

操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是在编译时期就写入到方法表的 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是可以是任意 Java 数据类型,包括 long 和 double,32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。

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

在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。

image-20200525145128509.png

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接

字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接

方法返回地址

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

  • 第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)
  • 另外一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)
    注意:这种退出方式不会给上层调用者产生任何返回值。

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

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

方法调用

方法调用阶段的目的:确定被调用方法的版本(哪一个方法),不涉及方法内部的具体运行过程,在程序运行时,进行方法调用是最普遍、最频繁的操作。

一切方法调用在Class文件里存储的都只是符号引用,这是需要在类加载期间或者是运行期间,才能确定为方法在实际 运行时内存布局中的入口地址(相当于之前说的直接引用)

解析

“编译期可知,运行期不可变”的方法(静态方法和私有方法),在类加载的解析阶段,会将其符号引用转化为直接引用(入口地址)。这类方法的调用称为“解析(Resolution)”。

在Java虚拟机中提供了5条方法调用字节码指令:

  • invokestatic : 调用静态方法
  • invokespecial:调用实例构造器方法、私有方法、父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象
  • invokedynamic:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

分派

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

静态分派

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。最典型应用表现是方法重载。

静态分派最典型的应用就是方法重载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class StaticResolution {
public static void main(String[] args) {
Human human = new Man();
Human woman = new Woman();
StaticResolution sr = new StaticResolution();
sr.sayHello(human);
sr.sayHello(woman);
}
public void sayHello(Man g) {
System.out.println("hello, gentleman!");
}
public void sayHello(Human h) {
System.out.println("hello, guy!");
}
public void sayHello(Woman h) {
System.out.println("hello, lady!");
}
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human {}
}

运行结果:

hello, guy!
hello, guy!

1
Human man = new Man();

其中的Human称为变量的静态类型(Static Type),Man称为变量的实际类型(Actual Type)
两者的区别是:静态类型在编译器可知,而实际类型到运行期才确定下来。
在重载时通过参数的静态类型而不是实际类型作为判定依据,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayhello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条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
public class DynamicDispatch {
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
static abstract class Human {
protected void sayHello() {
System.out.println("human say hello");
}
}
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");
}
}
}

运行结果:

man say hello
woman say hello
woman say hello

使用 javap 命令得到上面 main() 方法的字节码如下所示:

image-20200525220014127.png

17和21行分别通过invokevirtual指令进行方法调用,注释显示参数和指令都一样,但结果不同,这就要从指令本身入手。

invokevirtual指令运行时的解析过程大致分为以下步骤:

(1)找到操作数栈顶的第一个元素所指向对象的实际类型C。

(2)然后在C中查找与常量池中描述符和简单名称都相符的方法,如果找到,则进行权限验证,如果通过验证则返回这个方法的直接引用,否则抛出java.lang.AbstractMethodError异常。

(3)否则,按照继承关系自下而上对C的父类进行第二步的查找和验证。

(4)如果没有找到则抛出java.lang.AbstractMethodError异常。

在上述 invokespecial 查找方法的过程中,最重要的就是第一步,根据对象的引用确定对象的实际类型,这个方法重写的本质。如上所述,在运行期内,根据对象的实际类型确定方法执行版本的分派过程叫做动态分派。

多态性的根源在于虚方法调用指令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
public class FieldHasNoPolymorphic {
public static void main(String[] args) {
Father gay = new Son();
System.out.println(gay.money);
}
static class Father {
public int money = 2;
public Father() {
money = 3;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I an Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 4;
public Son() {
money = 5;
showMeTheMoney();
}
@Override
public void showMeTheMoney() {
System.out.println("I an Son, i have $" + money);
}
}
}
输出
I an Son, i have $0
I an Son, i have $5
3

在Son类创建时,首先隐式调用Father的构造函数,而Father的构造函数中对showMeTheMoney的调用是一次虚方法调用,实际执行的是Son::showMeTheMoney()方法。这时候虽然父类的money初始化为3,但Son::showMeTheMoney()访问的是子类的money字段,所以结果是0。

单分派与多分派

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

Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

注:到JDK12时,Java语言还是静态多分派、动态单分派的语言,未来有可能支持动态多分派。

虚拟机动态分派的实现

虚拟机中的动态分派是十分频繁的动作,并且是在运行时在类方法元数据中进行搜索的,因此基于性能的考虑,虚拟机会采用各种优化手段优化动态分派的过程,最常见的”稳定优化”的手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据以提高性能。

image-20200526082142831.png

上图就是一个虚方法表,Father、Son、Object 三个类在方法区中都有一个自己的虚方法表,如果子类中实现了父类的方法,那么在子类的虚方法表中该方法就指向子类实现的该方法的入口地址,如果子类中没有重写父类中的方法,那么在子类的虚方法表中,该方法的索引就指向父类的虚方法表中的方法的入口地址。有两点需要注意:

  • 为了程序实现上的方便,一个具有相同签名的方法,在子类的方法表和父类的方法表中应该具有相同的索引,这样在类型变化的时候,只需要改变查找方法的虚方法表即可。
  • 虚方法表是在类加载的连接阶段实现的,类的变量初始化完成之后,就会初始化该类的虚方法表。

动态类型语言的支持

JDK新增加了invokedynamic指令来是实现“动态类型语言”。

静态语言和动态语言的区别:

  • 静态语言(强类型语言)
    静态语言是在编译时变量的数据类型即可确定的语言,多数静态类型语言要求在使用变量之前必须声明数据类型。
    例如:C++、Java、Delphi、C#等。
  • 动态语言(弱类型语言)
    动态语言是在运行时确定数据类型的语言。变量使用之前不需要类型声明,通常变量的类型是被赋值的那个值的类型。
    例如PHP/ASP/Ruby/Python/Perl/ABAP/SQL/JavaScript/Unix Shell等等。
  • 强类型定义语言
    强制数据类型定义的语言。也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。举个例子:如果你定义了一个整型变量a,那么程序根本不可能将a当作字符串类型处理。强类型定义语言是类型安全的语言。
  • 弱类型定义语言
    数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。强类型定义语言在速度上可能略逊色于弱类型定义语言,但是强类型定义语言带来的严谨性能够有效的避免许多错误。

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

解释执行

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

image-20200526092549199.png

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

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA)依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集依赖寄存器进行工作

那么,基于栈的指令集和基于寄存器的指令集这两者有什么不同呢?

举个简单例子,分别使用这两种指令计算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寄存器里面。

基于栈的指令的特点

  • 可移植:寄存器由硬件决定,限制较大,但是虚拟机可以在不同硬件条件的机器上执行
  • 代码相对更加紧凑:字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数
  • 编译器实现更加简单
  • 基于栈的指令缺点就是执行速度慢,因为虚拟机中操作数栈是在内存中实现的,频繁的栈访问也就意味着频繁的访问内存,内存的访问还是要比直接操作寄存器要慢的

总结

本节中,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法内的字节码,以及执行代码时涉及的内存结构。