1、概述
执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中指定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
2、运行时栈帧结构
栈帧(Stack Frame) 是用于虚拟机执行时方法调用和方法执行时的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法从调用开始到方法返回都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧在编译程序代码的时候所需要多大的局部变量表,多深的操作数栈都已经决定了,并且写入到方法表的 Code 属性之中(编译期),一次一个栈帧需要多少内存,不会受到程序运行期变量数据的影响,仅仅取决于具体的虚拟机实现。
一个线程中方法调用可能很长,很多方法都处于执行状态。对于执行引擎来说,只有处于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与之相关联的方法称为当前方法(Current Method)。
在概念模型上,典型的栈帧主要由 局部变量表(Local Stack Frame)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、返回地址(Return Address)组成,如下图所示:
1、局部变量表
局部标量表是一组变量值的存储空间,用于存放 方法参数 和 方法内局部变量。在Java程序编译为Class文件时,就在Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。
变量槽 (Variable Slot)是局部变量表的最小单位,没有强制规定大小为 32 位,虽然32位足够存放大部分类型的数据。一个 Slot 可以存放 boolean、byte、char、short、int、float、reference 和 returnAddress 8种类型。其中 reference 表示对一个对象实例的引用,通过它可以得到对象在Java 堆中存放的起始地址的索引和对象所属数据类型在方法区中的存储的类型信息。returnAddress 则指向了一条字节码指令的地址。 对于64位的 long 和 double 变量而言,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表。索引值的范围是从0开始至局部变量表最大是Slot数量。
- 如果访问的是32位数据类型的变量,索引n代表了第n个Slot;
- 如果访问的是64位数据类型的变量,索引n代表了第n个和n+1个Slot;
之前我们知道,局部变量表存放的是方法参数和局部变量。当调用方法是非static方法时,局部变量表中第0位索引的 Slot 默认是用于传递方法所属对象实例的引用,即 “this” 关键字指向的对象。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。当离开了某些变量的作用域之后,这些变量对应的 Slot 就可以交给其他变量使用。但这种机制有时候会影响垃圾回收行为。
public static void main(String[] args){
{
byte[] placeholder = new byte[64*1024*1024];
}
System.gc();
}
结果:
[GC 602K->378K(15872K), 0.0603803 secs]
[Full GC 378K->378K(15872K), 0.0323107 secs]
[Full GC 66093K->65914K(81476K), 0.0074124 secs] 未回收
public static void main(String[] args){
{
byte[] placeholder = new byte[64*1024*1024];
}
int a = 0;
System.gc();
}
结果:
[GC 602K->378K(15872K), 0.0018270 secs]
[Full GC 378K->378K(15872K), 0.0057871 secs]
[Full GC 66093K->378K(81476K), 0.0054067 secs] 回收成功
代码一和代码二内的 placeholder 变量在 System.gc() 执行后理应被回收了,可是结果却是只有代码二被回收了,这是为什么呢?
placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。
这是因为代码一中 placeholder 虽然离开了作用域,但之后没有任何局部变量对其进行读写,也就是说其占用的 Slot 没有被其他变量复用,所以作为GC Roots 一部分的局部变量表仍然保持着对它的关联,也就是说 placeholder 占用的内存仍然有引用指向它,因而它没有被回收。
而代码二中的变量a由于复用了 placeholder 的 Slot ,导致 placeholder 引用被删除,因此占用的内存空间被回收。
《Practical Java》一书中把”不使用的对象应手动赋值为 null “作为一条推荐的编码规则,这并不是一个完全没有意义的操作。但是不应该对 赋 null 值有过多的依赖,主要有两点原因:
- 从编码的角度来讲,用恰当的变量作用域来控制变量的回收才是最优雅的解决方法。
- 从执行角度讲,使用赋值 null 的操作优化内存回收是建立在对字节码执行引擎概念模型基础上的,但是概念模型与实际执行模型可能完全不同。在使用解释器执行时,通常离概念模型还比较接近,但是一旦经过JIT 编译为本地代码才是虚拟机执行代码的主要方式,赋 null 值在JIT编译优化之后会被完全消除,这时候赋 null 值是完全没有意义的。(其实,上面代码一在 JIT 编译为本地代码之后,gc() 之后内存也会被自动回收)
关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的。
public static void main(String[] args){
int a;
System.out.println(a);
}
2、操作数栈
操作数栈(Operand Stack)也常称为操作栈,是一个后入先出栈。在Class 文件的Code 属性的 max_stacks 指定了执行过程中最大的栈深度。Java 虚拟机的解释执行引擎称为”基于栈的执行引擎“,这里的栈就是指操作数栈。
操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
方法执行中进行算术运算或者是调用其他的方法进行参数传递的时候是通过操作数栈进行的。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。以iadd指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。
在概念模型中,两个栈帧是相互独立的。但是大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无需进行额外的参数复制传递。
3、动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
Class文件的常量池中存在大量符号引用,字节码的方法调用指令就以常量池中指向方法的符号作为参数。
这些符号引用一部分会在类加载阶段或第一次使用的时候就转化为直接引用,这种转化称为静态解析。
另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
4、方法返回地址
当一个方法开始执行以后,只有两种方法可以退出当前方法:
-
正常完成出口(Normal Method Invocation Completion):执行引擎遇到任意一个方法返回的字节码指令,这种退出方法称为正常完成出口。
-
异常完成出口(Abrupt Method Invocation Completion):在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用athrow 字节码指令产生的异常。只要在本方法的异常表中没有搜索到匹配的异常处理器,方法就会退出;这种方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值压入调用者栈帧的操作数栈,调整 PC 计数器的值以指向方法调用指令后面的一条指令
5、附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中。如与调试相关的信息,此部分信息完全取决于具体虚拟机实现。
在实际开发中,一般会把动态连接,方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
3、方法调用
方法调用不等同于方法执行,它唯一的任务就是确定被调用方法的版本,即具体调用哪一个方法,暂时不涉及方法内部的运行过程。
在程序运行时,进行方法调用是最普遍、最频繁的操作,在讲解Class文件编译过程时,已经表明此过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于“直接引用”)。
此特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至运行期间才能确定目标方法的直接调用。
1、解析
(1)定义
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用:在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。这种解析能成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析。
(2)编译期可知,运行期不可变
在java语言中符合“编译期可知,运行期不可变”这个要求的方法主要包括:
- 静态方法,与类直接相关
- 私有方法,在外部不可被访问
这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
(3)调用字节码指令
与之对应的是,在JVM 中提供了5条方法调用字节码指令,分别如下:
- invokestatic:调用静态方法。
- invokeespecial:调用实例构造器方法,私有方法和父类方法。
- invokevirtual:调用所有的虚方法。
- invokeinterface:调用接口方法,会在运行时再确定另一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部 的,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
(4)非虚方法与虚方法
非虚方法:只要能被invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有:
- 静态方法
- 私有方法
- 实例构造器
- 父类方法
这4类方法在类加载的时候就会把符号引用解析为该方法的直接引用。
虚方法:其他的方法称为虚方法(final方法是非虚方法,虽然final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无需对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了 final 方法是一种非虚方法。)。
(5)解析调用示例
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}
(6)解析调用 与 分派调用
- 解析调用一定是个静态的过程:在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为 可确定的直接引用,不会延迟到运行期再去完成。
-
分派(Dispatch)调用可能是静态的也可能是动态的:根据分配依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派4种分派组合情况。
2、分派
(1)静态分派
package org.fenixsoft.polymorphic;
/**
* 方法静态分派演示
* @author zzm
*/
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 woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
运行结果:
hello,guy!
hello,guy!
把上面代码中的Human 变量称为变量的静态类型(Static Type)或叫做外观类型(Apparent Type),后面的Man 则称为变量的实际类型(Actual Type)。
静态类型和实际类型在程序中都可以发生一些变化,区别:
-
静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译期是可知的。
-
实际类型变化的结果在运行期才可确定,编译器在编译程序时并不知道一个对象的实际类型。
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);
代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确而言是编译器)在重载时是通过参数的静态类型并非实际类型作为判定依据的。并且静态类型是编译器可知的,因此在编译期间,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirtual指令的参数之中。
小结:
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,其典型应用就是重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机而是编译器执行的。
编译器虽然能确定方法的重载版本,但很多情况下并不“唯一”,往往只能说是一个“更加适合”的版本。(产生这种模糊结论的主要原因是字母量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断)
(2)动态分派
package org.fenixsoft.polymorphic;
/**
* 方法动态分派演示
* @author zzm
*/
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();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
运行结果:
man say hello
woman say hello
woman say hello
从invokevirtual 指令的多态查找过程开始说起,运行时的解析过程大致如下:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C。
- 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验。如果通过,则返回这个方法的直接饮用,查找过程结束:如果不通过,则返回 java.lang.IllegalAccessError 异常。
- 否则,按照继承关系从下往上依次对C 的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
方法重写本质:
由于 invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 java语言中方法重写的本质。
动态分派定义:
这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
(3)单分派 与 多分派
方法的接收者与方法的参数统称为方法的宗量。
单分派和多分派:根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。
- 单分派是根据一个宗量对目标方法进行选择。
- 多分派是根据多于一个宗量对目标方法进行选择。
/**
* 单分派、多分派演示
* @author zzm
*/
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());
}
}
运行结果:
father choose 360
son choose qq
根据以上结果,来分析编译阶段编译器的选择过程,也就是静态分派过程。
-
选择目标方法的依据:一是静态类型是Father还是Son,二是方法参数是QQ 还是360。
-
这次选择结果的最终产物是产生两条invokevirtual指令:两条指令的参数分别是常量池中指向Father.hardChoice(360)以及Father.hardChoice(QQ) 方法的符号引用。因为是根据两个宗量进行选择,所以java语言的静态分派属于多分派类型。
-
动态分派过程:在执行“
son.hardChoice(new QQ())
” 这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须是hardChoice(QQ)
。虚拟机并不关心传递过来的参数,因为此时参数的静态类型、实际类型都对方法选择不会造成任何影响。唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以java语言的动态分派属于单分派类型。
小结:
Java语言是一门 静态多分派、动态单分派的语言。
- 编译期:调用方法的静态类型、方法的参数
- 运行时:调用方法的实际类型
按照目前java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求。
(4)虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。
面对这种情况,最常用的稳定优化手段就是为类在方法区中建立一个虚方法表(Virtual Method Table),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。
- 如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。
- 如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口。
如上图,Son 重写了来自于 Father 的全部方法,因此 Son 的方发表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自于 Object 的方法,所以他们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完毕。
4、基于栈的字节码解释执行引擎
1、解释执行
Java语言经常被定位为“解释执行”的语言,但当主流虚拟机中包含了即时编译后,Class文件中代码到底会被解释执行还是 编译执行,只有虚拟机才能准确判断。再后来,Java发展出可直接生成本地代码的编译器,C/C++语言也出现通过解释器执行的版本,此时再笼统说“解释执行”就毫无意义。
只有确定了谈论对象是否是某种具体的Java实现版本和执行引擎运行模式时,谈“解释执行”或“编译执行”才会比较明确。
不论是编译还是解释,物理机或虚拟机,大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,需要经过编译过程几个步骤:
在java语言中,javac 编译器完成了程序代码经过词法分析,语法分析到抽象语法树,在遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在JVM 之外进行的,而解释器是在虚拟机的内部,所以java程序的编译就是半独立的实现。
2、基于栈的指令集
优点:可移植、代码紧凑(字节码中每个字节就对应一条指令)、编译器更易实现(不需要考虑空间分配问题,直接在栈上操作)
缺点:执行速度相对稍慢一些、执行指令数量多(入栈出栈操作本身产生了相当多的指令数量)、频繁的空间访问(频繁的栈访问)