虚拟机字节码执行引擎
所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
运行时栈帧结构
栈帧用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
一个栈帧需要分配大多的内存,在程序编译的时候就已经确定并写入到方法表的Code属性中了,不会受到程序运行期间数据的影响。
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
变量槽(Variable Slot)局部变量表中的最小单位。每个Slot都应该可以存放一个boolean、byte、char、short、int、float、reference(一个对象实例的引用)或returnAddress类型的数据。(这些数据类型都可以使用32位或更小的物理内存来存放,虚拟机并没有指定一个Slot的大小是多少)
对于64位的数据类型(Java语言中明确的64位数据类型只有long和double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。
方法执行的时候,虚拟机是使用局部变量表完成参数值到参数变量列表传递过程的。如果执行的是实例方法(非static的方法),那局部变量的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以用this来访问到这个隐藏参数。
为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。
上述代码中,placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。test2()中代码虽然已经离开了placeholder,但之后没有对局部变量表的读写操作,placeholder原本所占用的Slot还没有被复用,索引GC Roots一部分的局部变量表仍保持着对它的关联。
操作数栈
操作数栈(Operand Stack) 也常称为操作栈,它是一个后入先出栈。
当一个方法执行开始时,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是 出栈/入栈操作。
在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的。有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析;另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当一个方法被执行后,退出方法的两种方式:
- 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令。
- 异常完成出口在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
为了程序能正确的执行,方法退出之后,必须回到方法被调用的位置。方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值;方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
附加信息
虚拟机规范允许虚拟机实现向栈帧中添加一些自定义的附加信息。
一般把动态连接、方法返回地址和其他附件信息全部归为一类,成为栈帧信息。
方法调用
在程序运行时,进行方法调用是最普遍、最频繁的操作。目的:确定被调用哪一个方法,不涉及方法内部的具体运行过程,
解析
方法在程序真正运行之前就有一个可确定的版本(编译期可知),并且这个版本在运行期不可变,(即静态方法和私有方法),在类加载的解析阶段,会将其符号引用转化为直接引用(入口地址)。这类方法的调用称为“解析(Resolution)”。
解析调用是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
在Java虚拟机中提供了5条方法调用字节码指令:
- invokestatic : 调用静态方法
- invokespecial:调用实例构造器方法、私有方法、父类方法
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象
- invokedynamic:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
分派
分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟中是如何实现的。
静态分派
依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。最典型的应用就是方法重载。
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("Human guy");
}
public void sayhello(Man guy) {
System.out.println("Man guy");
}
public void sayhello(Woman guy) {
System.out.println("Woman guy");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayhello(man);// Human guy
staticDispatch.sayhello(woman);// Human guy
}
}
结果如下:
在Human man = new Man();
中,"Human"成为变量的静态类型(Static Type),后面的"Man"称为变量的实际类型(Actual Type)。静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型在编译器可知,而实际类型到运行期才确定下来。
重载时通过参数的静态类型作为判定依据,而不是实际类型。因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayhello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
动态分派
动态分派:运行期根据实际类型确定方法执行版本的分派过程。它的典型应用是多态性的重写。
public class DynamicDisptch {
static abstract class Human {
abstract void sayhello();
}
static class Man extends Human {
@Override
void sayhello() {
System.out.println("man");
}
}
static class Woman extends Human {
@Override
void sayhello() {
System.out.println("woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayhello();
woman.sayhello();
man = new Woman();
man.sayhello();
}
}
这里是依据变量的实际类型来分派方法的执行版本,所以结果如上图所示。在下图的javap命令中
16~21句,分别将两个对象的引用压栈,这两个对象将要执行的sayHello()方法的所有者,称为接收者(Receiver)。从17和21句来看,这两天的指令和参数完全一致,但是最终执行的方法却不相同。因为invokevirtual执行执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中重写的本质。
invokevirtual指令的运行时解析过程大致如下:
找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C;
如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过,返回java.lang.IllegalAccessError异常;
否则,按照继承关系从上往下对C的各个父类进行第二步搜索和验证过程;
如果始终没有找到合适的方法,抛出java.lang.AbstractMethodError异常。
单分派与多分派
方法的接收者、方法的参数都可以称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。
Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
基于栈的字节码解释执行引擎
解释执行
解释执行将源语言直接作为源程序输入,解释执行解释一句后就提交计算机执行一句。并不形成目标程序。不依赖于平台,因为编译器会根据不同的平台进行解析。
Java语言经常被人们定位为 “解释执行”语言,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念。只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。
Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程,因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机内部,所以Java程序的编译就是半独立的实现。
基于栈的指令集和基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集, 依赖寄存器进行工作。
基于栈的指令集的过程:
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后将结果放回栈顶,最后istore_0把栈顶的值放到局部变量表中的第0个Slot中。
iconst_1
iaddistore_0
基于寄存器的过程:
mov eax, 1
add eax, 1
mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,将结果就保存在EAX寄存器里面。
栈架构指令集的优点:可移植、代码相对更加紧凑、编译器实现更加简单等。
栈架构指令集的缺点:执行速度相对来说会稍微慢一些。