一、概述
执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,区别是:
物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的。
虚拟机的执行引擎是由自己实现的,因此可以自行定制指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中指定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。
在不同的虚拟实现里面,执行引擎在执行Java代码的时候可能会有以下方式:
- 解释执行(通过解释器执行)
- 编译执行(通过即时编译器产生本地代码执行)
- 以上两者兼备
- 可能会包含几个不同级别的编译器执行引擎
但从外观上看起来,所以的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
二、运行时栈帧结构
栈帧(Stack Frame) 是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。(“详解Java虚拟机内存各个区域”一文中也有介绍)。
栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。每个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行前变量数据的影响,而仅仅取决于具体的虚拟机实现。
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
在概念模型上,典型的栈帧结构如下图:
2.1 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部便来表达额容量以变量槽(Varibale Slot,下称Slot)为最小单位, 虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,着8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个Slot占用32位长度的内存空间”是有一些差别的,它允许Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。这几种数据类型,前面6中是基本数据类型,就不多做介绍了,对于reference类型,虚拟机实现至少都应当能通过这个引用做到两点:
- 从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引
- 此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slolt,如果是64位数据类型的变量,则说明同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,一旦遇到这个操作,虚拟机规范明确要求虚拟机应该在类加载的校验阶段抛出异常。
2.2 操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(LIFO:Last In First Out)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种自恶骂指令往操作数栈中写入和提取内容,也就是出栈/入栈操作(原来入栈、出栈是指入操作数栈、出操作数栈)。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让栈帧的部分操作栈与部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。如下图:
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
Class文件的常量池洪存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
- 执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion).
- 在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。
一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
三、方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用最普遍、最频繁的操作,Class文件的额编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用个,而不是方法在实际运行时内存布局中的入口地址(即:直接引用)。
3.1 解析
方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,这类方法的调用就称为解析。
在Java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。在Java虚拟机里面提供了5条方法调用的字节码指令与之对应,分别是:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器<init>方法、私有方法和父类方法
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在裕兴时再确定一个实现此接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
前面4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
主要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的通用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法。相反,其他方法称为虚方法。
但是有一个特例:被final修饰的方法,虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所有野无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的,所以Java语言规范中明确说明了final方法也是非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态也可以是动态的。
3.2 分派
3.2.1 静态分派
在开始讲解静态分派前,先分析一段代码:
package Dispatch;
/**
* 方法静态分派演示
* @author cb
*
*/
public class TestStaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Women extends Human {
}
void sayHello(Human human) {
System.out.println("Human ...........");
}
void sayHello(Man man) {
System.out.println("Man ...........");
}
void sayHello(Women women) {
System.out.println("Women ...........");
}
public static void main(String[] args) {
Human man = new Man();
Human women = new Women();
TestStaticDispatch tsd = new TestStaticDispatch();
tsd.sayHello(man);
tsd.sayHello(women);
}
}
相信对Java编程稍有经验的程序员就能得到正确的结果:
Human ...........
Human ...........
但为什么会选择执行参数类型为Human的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的概念。
Human man = new Man();
我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)。两者区别在于:
静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知道的;而实际类型变化的结果是在运行期才可确定的,编译期在编译程序的时候并不知道一个对象的实际类型是什么。
虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判断依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。
3.3.2 动态分派
与静态分派一样,我们先来看段代码:
package Dispatch;
/**
* 方法动态分派演示
* @author cb
*
*/
public class TestDynamicDispatch {
static abstract class Human {
abstract void sayHello();
}
static class Man extends Human {
@Override
void sayHello() {
System.out.println("Man........");
}
}
static class Women extends Human {
@Override
void sayHello() {
System.out.println("Women........");
}
}
public static void main(String[] args) {
Human man = new Man();
Human women = new Women();
man.sayHello();
women.sayHello();
man = new Women();
man.sayHello();
}
}
运行结果:
Man........
Women........
Women........
相信结果不会出人意料。
显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个边路man和woman在调用sayHello()方式时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。那么Java虚拟机是如何根据县级类型来分派烦恼方法执行版本呢?
我们分析一下使用javap -c xxx.class命令输出这段代码的字节码。如下图:
注:图上一些命令解释:
new:创建一个对象,并将其引用值压入栈顶
dup:复制栈顶数值并将复制值压入栈顶
invokespecial:调用实例构造器<init>方法、私有方法、父类方法
astore_1:将栈顶引用型数值存入第二个本地变量
astore_2:将栈顶引用型数值存入第二个本地变量
aload_1:将第二个引用类型本地变量推送至栈顶
aload_2:将第三个引用类型本地变量推送至栈顶
invokevirtual:调用所有的虚方法
return:从当前方法返回void
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:
Human man = new Man();
Human women = new Women();
接下来的16~21是关键部分,16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接受者(Receiver);17~21句是方法调用指令,这两条指令单从字节码角度来看,无论是指令还是参数都是完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从Invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
- 找到操作栈顶的第一个元素所指向的对象的实际类型,记做C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到不同的直接引用上,这个过程就是Java语言中方法重写的本质。
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。动态分派的典型应用是方法重写。