一般一部赛车的引擎是赛车的心脏,决定着赛车的性能和稳定性,赛车的速度、操纵感这些直接与车手相关的指标都是创建在引擎的基础上的。一样的,JVM的执行引擎是JAVA虚拟机最核心的组成部分之一。那么什么是JVM的执行引擎?咱们在学习计算机组成原理等课程的时候,知道物理机的执行引擎是直接创建在处理、硬件、指令集和操做系统层面上的。而相对于物理机,JAVA虚拟机一样具备代码执行的能力,虚拟机的执行引擎是由本身实现的,所以能够自行定制指令集和执行引擎的结构体系。java
在JAVA虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,尽管如今JVM的实现各不相同,有编译执行(如BEA JRockit)也有解释执行(如Sun Classic VM),可是从概念模型的角度来看,全部JAVA虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。web
1、运行时栈帧(Stack Frame)结构数组
咱们知道程序、指令在运行的时候少不了计算机存储、组织的方式,这种存储和组织方式咱们也称为数据结构。栈帧就是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。每个栈帧都包括了局部变量表、操做数栈、动态链接、方法返回地址以及一些额外的附加信息。数据结构
一个线程中的方法调用链可能会很长,不少方法都处于执行状态。可是对于执行引擎来讲,活动线程中只有栈顶的栈帧是有效的,称为当前栈帧,对应的方法为当前方法。执行引擎的全部字节码指令都是只针对当前栈帧进行操做的。架构
图 1. 栈帧的概念结构jvm
栈帧须要多大的局部变量表、多深的操做数,在代码编译的时候就已经彻底肯定下来了,写在了方法表的Code属性之中,例如:ide
1 public int add(int i,int j){ 2 int result; 3 result = i + j; 4 return result; 5 }
将上术方法所在java文件编译以后生成的class文件,经过命令 javap -verbose反编译以后add方法所对应的字节码以下:布局
1 public int add(int, int); 2 Code: 3 Stack=2, Locals=4, Args_size=3 4 0: iload_1 5 1: iload_2 6 2: iadd 7 3: istore_3 8 4: iload_3 9 5: ireturn 10 LineNumberTable: 11 line 7: 0 12 line 8: 4 13 14 LocalVariableTable: 15 Start Length Slot Name Signature 16 0 6 0 this Ljvm/executionengine/stackframe/StackFrame; 17 0 6 1 i I 18 0 6 2 j I 19 4 2 3 result I
在上面的字节码中能够看到,方法的Code属性中会有这三个值Stack=2, Locals=4, Args_size=3,其中Stack和Locals就是操做数栈的最大深度以及局部变量表的大小,最后一个参数的个数。细心的朋友会发现局部变量明明只有3个(i,j,result)占用3个Slot(后面会讲到Slot),参数个数明明也只有2个(i,j),为何都会多一个呢?这里面咱们不要忘记了非静态方法都会有个隐藏的变量(参数),那就是“this”。性能
(1) 局部变量表学习
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中肯定了该方法所须要分配的最大局部变量表的容量。
局部变量表的容量以变量槽(Slot)为最小单位,JAVA虚拟机规范中并无指明一个Slot应占用的内存空间大小,只是说明一个Slot都应该能存放一个32位之内的数据类型,有boolean、byte、char、short、int、float、reference(长度与实际使用的是32位仍是64位虚拟机有关)和returnAddress类型的数据。 returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。long和double是java语言中明确规定的64位数据类型,虚拟机会为其分配两个连续的Slot空间。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,若是是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中经过this访问。其他参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕以后,再根据方法体内部定义的变量顺序和做用域分配其他的Slot。
下面经过一个简单的例子来讲明上述的Slot的描述:
1 public int slot(int i,int j){ 2 double d = 5.23; 3 int result; 4 result = i + j; 5 return result; 6 }
1 public int slot(int, int); 2 Code: 3 Stack=2, Locals=6, Args_size=3 4 0: ldc2_w #16; //double 5.23d 5 3: dstore_3 6 4: iload_1 7 5: iload_2 8 6: iadd 9 7: istore 5 10 9: iload 5 11 11: ireturn 12 LineNumberTable: 13 line 6: 0 14 line 8: 4 15 line 9: 9 16 17 LocalVariableTable: 18 Start Length Slot Name Signature 19 0 12 0 this Ljvm/executionengine/stackframe/StackFrame; //this占用slot0 20 0 12 1 i I //int型 i 占用slot1 21 0 12 2 j I //int型 j 占用slot2 22 4 8 3 d D // double型 d 占用slot3 slot4 23 9 3 5 result I //int型 result 占用slot5
Slot是能够重用的,当Slot中的变量超出了做用域,那么下一次分配Slot的时候,将会覆盖原来的数据。Slot对对象的引用会影响GC(要是被引用,将不会被回收)。系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。为了说明Slot的可复用,下面举了一个在实际中基本不会出现的代码:
1 public void slotReuse(){ 2 { 3 int m = 100; 4 } 5 int n = 200; 6 }
1 public void slotReuse(); 2 Code: 3 Stack=1, Locals=2, Args_size=1 //变量个数3个(this,m,n),可是局部变量表大小为2 4 0: bipush 100 5 2: istore_1 //把m的值100放入下标1的slot中 6 3: sipush 200 7 6: istore_1 //把n的值200放入下标1的slot中,超出了m做用域,复用了 8 7: return 9 LineNumberTable: 10 line 7: 0 11 line 9: 3 12 line 10: 7 13 14 LocalVariableTable: 15 Start Length Slot Name Signature 16 0 8 0 this Ljvm/executionengine/stackframe/StackFrame; 17 7 1 1 n I
Slot的复用在某些状况下会直接影响到系统的垃圾回收行为,咱们来经过下面的3块代码来讲明这个问题:
1 /** 2 * 三段代码加上虚拟机的运行参数“-verbose:gc”来观察垃圾收集的过程 3 */ 4 /*代码一:gc的时候,变量placeHolder还处于做用域,没有回收掉*/ 5 public static void main(String[] args) { 6 byte[] placeHolder = new byte[64*1024*1024]; 7 System.gc(); 8 } 9 /*gc过程: 10 [GC 66167K->65824K(120576K), 0.0012810 secs] 11 [Full GC 65824K->65690K(120576K), 0.0060200 secs] 12 */ 13 14 /*代码二:gc的时候,变量placeHolder还处于做用域以外了,可是仍是没有回收掉*/ 15 public static void main(String[] args) { 16 { 17 byte[] placeHolder = new byte[64*1024*1024]; 18 } 19 System.gc(); 20 } 21 /*gc过程: 22 [GC 66167K->65760K(120576K), 0.0013770 secs] 23 [Full GC 65760K->65690K(120576K), 0.0055290 secs] 24 */ 25 26 /*代码三:gc的时候,变量placeHolder还处于做用域以外了,经过一个新的变量i赋值,复用掉placeHolder原先的slot,垃圾回收了*/ 27 public static void main(String[] args) { 28 { 29 byte[] placeHolder = new byte[64*1024*1024]; 30 } 31 int i = 0; 32 System.gc(); 33 } 34 /*gc过程: 35 [GC 66167K->65760K(120576K), 0.0017850 secs] 36 [Full GC 65760K->154K(120576K), 0.0064340 secs] 37 */
上述代码中, placeHolder可否被回收的根本缘由就是:局部变量表中的Slot是否还存在关于placeHolder数组对象的引用。代码二中虽然已经离开了placeHolder的做用域,可是此后没有局部变量表的读写操做,placeHolder本来占用的Slot尚未被其余变量复用,因此在GC的时候仍然保留有对数组对象的引用。代码三中,认为的定义的一个局部变量i,而且赋值以达到复用刚才placeHolder的Slot,消除了对数组对象的引用,而后GC就能够回收掉了。
(2) 操做数栈
Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操做数栈。操做数栈也常被称为操做栈,就是一个栈结构(后入先出)。同局部变量表同样,操做数的最大深度在编译的时候就肯定了,写在了方法的Code属性的max_stacks数据项中。当一个方法刚刚开始执行的时候,这个方法的操做数栈是空的,在方法执行的执行过程当中,会有各类字节码指令向操做数栈中写入和提取数据(入栈、出栈操做)。
举个例子:整数加法的字节码指令iadd在运行以前要求操做数栈中最接近栈顶的两个元素已经存有两个int型数值,当指定iadd时候,会将这两个int值出栈相加,而后将结果入栈。
1 /*java文件中add方法*/ 2 public int add(int i,int j) { 3 return i+j; 4 } 5 6 /*class文件反编译以后的add方法字节码*/ 7 public int add(int, int); 8 Code: 9 Stack=2, Locals=3, Args_size=3 10 0: iload_1 //将slot1的数值入栈 变量i的值 11 1: iload_2 //将slot2的数值入栈 变量j的值 12 2: iadd //将栈中的两个值相加,并入栈 13 3: ireturn 14 LineNumberTable: 15 line 17: 0 16 17 LocalVariableTable: 18 Start Length Slot Name Signature 19 0 4 0 this Ljvm/learn/workspace/SlotGC; 20 0 4 1 i I 21 0 4 2 j I
另外,在虚拟机概念模型中,两个栈帧做为虚拟机栈的元素,相互之间是彻底独立的。可是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操做数栈与上面栈帧的部分局部变量表重叠在一块儿,这样在方法调用的时候能够共用一部分数据,减小了额外的参数复制传递的开销。
(3) 动态链接
每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程当中的动态链接。咱们知道字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候化为直接引用,这话总转化叫作静态解析,还有一部分将在每一次运行期间转化为直接引用,这部分称为动态链接。
(4) 方法返回地址
当一个方法被执行后,有两种方式退出这个方法:1.执行引擎遇到任意方法返回的字节码指令,这时可能会返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令决定。这种退出方式为正常完成出口。2.遇到异常而且没有在方法体内获得处理,不管是Java虚拟机内部产生异常仍是使用athrow字节码指令产生异常,只要在本方法的异常表中没有搜到匹配的异常处理器,就会致使方法退出,这种退出方式是不会给它的上层调用者产生任何返回值的。这种退出方式为异常完成出口。
通常来讲,方法正常退出时,调用者的PC计数器的值就能够做为返回地址,栈帧中极可能会保存这个计数器值。而方法异常退出时,返回地址是要经过异常处理器表来肯定的,栈帧中通常不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,所以退出时可能执行的操做有:恢复上层方法的局部变量表盒操做数栈,把返回值(若是有的话)压入调用者栈帧的操做数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
(5) 附加信息
虚拟机规范容许具体的虚拟机实现增长一些规范里没有描述的信息到栈帧之中,例如调试相关信息。
2、方法调用
方法调用并不等同于方法执行,方法调用阶段惟一的任务就是肯定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程当中不包含传统编译中的链接步骤,一切方法调用在Class文件中都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。这个特色给Java带来了更强大的动态扩展能力,可是也带来了复杂,须要在类加载甚至运行期间才能肯定目标方法的直接引用。
在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,不会延迟到运行期去完成,这种方法的调用称为解析(Resolution)。解析能成立的前提是:方法在程序真正运行以前就有可肯定调用版本,而且在运行期间不可变。分派(Dispatch)调用则多是静态的也多是动态的,根据分派依据的宗量数可分为单分派和多分派,所以分派有四状况:静态单分派、静态多分派、动态单分派、动态多分派。
(1) 解析
Java虚拟机里面有四条主要的方法调用字节码指令:
1.invokestatic:调用静态方法
2.invokespecial:调用实例构造器<init>方法、私有方法和父类方法
3.invokevirtual:调用全部虚方法
4.invokeinterface:弟阿勇接口方法,会在运行时再肯定一个实现此接口的对象
此外,JSR-292中引入了第5条新的字节码指令invokedynamic,在这里不作讨论。
只要能被invokestatic和invokespecial指令调用的方法,均可以在解析阶段肯定惟一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法。此外,final方法也是能够在解析阶段肯定惟调用版本。
1 /** 2 * 方法静态解析演示 3 */ 4 //Hello.java 5 public class Hello { 6 public void sayHello(){ 7 System.out.println("hello jvm"); 8 } 9 } 10 //StackFrame.java 11 public class StackFrame { 12 13 public static void staticInvoke(){ 14 System.out.println(); 15 } 16 public void showInvoke(){ 17 staticInvoke(); 18 Hello hello = new Hello(); 19 hello.sayHello(); 20 } 21 } 22 23 //class文件反编译后showInvoke方法的字节码 24 public void showInvoke(); 25 Code: 26 Stack=2, Locals=2, Args_size=1 27 0: invokestatic #27; //Method staticInvoke:()V 28 3: new #29; //class jvm/executionengine/stackframe/Hello 29 6: dup 30 7: invokespecial #31; //Method jvm/executionengine/stackframe/Hello."<init>":()V 31 10: astore_1 32 11: aload_1 33 12: invokevirtual #32; //Method jvm/executionengine/stackframe/Hello.sayHello:()V 34 15: return 35 LineNumberTable: 36 line 15: 0 37 line 16: 3 38 line 17: 11 39 line 18: 15 40 41 LocalVariableTable: 42 Start Length Slot Name Signature 43 0 16 0 this Ljvm/executionengine/stackframe/StackFrame; 44 11 5 1 hello Ljvm/executionengine/stackframe/Hello;
(2) 分派
1.静态分派
1 public class StaticDispatch { 2 public class Human{ 3 4 } 5 public class Man extends Human{ 6 7 } 8 public class Women extends Human{ 9 10 } 11 public void sayHello(Human human){ 12 System.out.println("hello,human"); 13 } 14 public void sayHello(Man man){ 15 System.out.println("hello,man"); 16 } 17 public void sayHello(Women women){ 18 System.out.println("hello,women"); 19 } 20 public static void main(String[] args) { 21 //man的静态类型是Human,实际类型是Man 22 Human man = new StaticDispatch().new Man(); 23 //man的静态类型是Human,实际类型是Women 24 Human women = new StaticDispatch().new Women(); 25 StaticDispatch sd = new StaticDispatch(); 26 27 sd.sayHello(man); //输出:hello,human 28 sd.sayHello(women); //输出:hello,human 29 sd.sayHello((Man)man); //输出:hello,man 30 sd.sayHello((Women)women); //输出:hello,women 31 }
全部依赖静态类型来定位方法执行版本的分派动做,都称为静态分派。静态分派最典型的应用就是方法重载。静态分派放生在编译阶段,所以肯定静态分派的动做实际上不是有虚拟机来执行的。编译器能肯定出方法的重载版本,而且这种重载版本不是惟一的,每每只能肯定一个“更加合适”的版本。
1 public class Overload { 2 3 public static void sayHello(int i){ 4 System.out.println("hello int"); 5 } 6 public static void sayHello(long i){ 7 System.out.println("hello long"); 8 } 9 public static void main(String[] args) { 10 //发生了一次自动转换,自动转int(相比于long更合适) 11 sayHello('a'); //输出:hello int 12 } 13 }
2.动态分派
1 public class DynamicDispatch { 2 3 static abstract class Human{ 4 protected abstract void sayHello(); 5 } 6 static class Man extends Human{ 7 protected void sayHello(){ 8 System.out.println("hello,man"); 9 } 10 } 11 static class Women extends Human{ 12 protected void sayHello(){ 13 System.out.println("hello,women"); 14 } 15 } 16 public static void main(String[] args) { 17 Human man = new Man(); 18 Human women = new Women(); 19 man.sayHello(); //输出:hello,man 20 women.sayHello();//输出:hello,women 21 } 22 23 }
显然也不是静态类型决定的,从上面代码能够看到,静态类型代码也都是Human,可是最后执行的结果倒是不一样的。致使的缘由就是这个两个变量的实际类型不一样,Java虚拟机是如何根据实际类型来分派呢?咱们使用javap来查看一下字节码。
1 public static void main(java.lang.String[]); 2 Code: 3 Stack=2, Locals=3, Args_size=1 4 0: new #16; //class jvm/executionengine/stackframe/DynamicDispatch$Man 5 3: dup 6 4: invokespecial #18; //Method jvm/executionengine/stackframe/DynamicDispatch$Man."<init>":()V 7 7: astore_1 8 8: new #19; //class jvm/executionengine/stackframe/DynamicDispatch$Women 9 11: dup 10 12: invokespecial #21; //Method jvm/executionengine/stackframe/DynamicDispatch$Women."<init>":()V 11 15: astore_2 12 16: aload_1 13 17: invokevirtual #22; //Method jvm/executionengine/stackframe/DynamicDispatch$Human.sayHello:()V 14 20: aload_2 15 21: invokevirtual #22; //Method jvm/executionengine/stackframe/DynamicDispatch$Human.sayHello:()V 16 24: return 17 LineNumberTable: 18 line 19: 0 19 line 20: 8 20 line 21: 16 21 line 22: 20 22 line 23: 24 23 24 LocalVariableTable: 25 Start Length Slot Name Signature 26 0 25 0 args [Ljava/lang/String; 27 8 17 1 man Ljvm/executionengine/stackframe/DynamicDispatch$Human; 28 16 9 2 women Ljvm/executionengine/stackframe/DynamicDispatch$Human;
字节码中0-15的字节码都是new了两个实例,并将实例放入第一个和第二个Slot中,接下来16-17是将Slot1中值压入栈(man实例的引用),而且调用方法,20-21是将Slot2中值压入栈(women实例的引用),而且调用方法。单从17和21两行来看,调用方法符号引用如出一辙,可是这两条指令最终执行的目标方法确实不一样。缘由就跟invokevirtual指令的多态查找过程有关了,invokevirtual指令的运行时解析过程大体分为如下步骤:
1) 找到操做数栈顶的第一个元素所指对象的实际类型,记做C。
2) 若是在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,经过就直接引用,结束;不经过就返回java.lang.IllegalAccessError异常。
3) 不然,按照继承关系从下往上对C的各个父类重复第2步的搜索和校验。
4) 若是始终没找到合适的方法,则抛java.lang.AbstractMethodError异常。
从上面invokevirtual指令的运行时解析过程不难看出,代码中man和woman会找到实际类型中的方法调用。这个过程反映了java语言中方法重写的本质。
3.单分派和多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,能够将分派分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
1 /** 2 * 单分派、多分派 3 */ 4 public class Dispatch { 5 static class QQ{}; 6 7 static class _360{}; 8 9 public static class Father{ 10 public void choice(QQ arg) { 11 System.out.println("father choose QQ"); 12 } 13 public void choice(_360 arg) { 14 System.out.println("father choose 360"); 15 } 16 } 17 public static class Son extends Father{ 18 public void choice(QQ arg) { 19 System.out.println("son choose QQ"); 20 } 21 public void choice(_360 arg) { 22 System.out.println("son choose 360"); 23 } 24 } 25 26 public static void main(String[] args) { 27 Father father = new Father(); 28 Father son = new Son(); 29 //动态类型Father 静态类型_360 根据方法接收者:Father 和 方法参数:_360 肯定一个目标方法 30 father.choice(new _360()); //输出:father choose 360 31 ////动态类型Son 静态类型QQ 根据方法接收者:Son 和 方法参数:QQ 肯定一个目标方法 32 son.choice(new QQ()); //输出:son choose QQ 33 } 34 }
4.虚拟机动态分派的实现
因为动态分派很是繁琐以及虚拟机实际实现中基于性能考虑,一般都会对动态分派的实现作优化。最一般的优化方法就是在类的方法区中建一个虚方法表(Virtual Method Talbe,vtable),于此对应,invokeinterface执行时也用到接口方法表(Interface Method Table,itable)。
图2 方法表结构
虚方法表中存放着各个方法的实际入口地址。若是某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口地址与父类相同方法的地址入口地址一致,都指向父类的实现入口。若是子类重写了这个方法,那么子类虚方法表中地址将会被替换成指向子类实现版本的入口地址。上图中Son重写了来自Father的所有方法,所以Son方法表中这些方法的实际入口地址都指向了Son类型数据的方法。Son和Father都没有重写Object中的方法,因此方法表中的实际入口地址都指向了Object数据类型。
为了程序实现上的方便,具备相同签名的方法,在父类、子类的虚方法表中都应当具备同样的索引序号,这样当类型变换时,仅须要变动查找的方法表,就能够从不一样的虚方法表中按索引转换出所需的入口地址。
方法表通常在类加载的链接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
3、基于栈的字节码解释执行引擎
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,它们依赖操做数栈进行工做。下面结合一个小例子看看虚拟机其实是如何执行的。
1 //java文件中的一个计算方法 2 public int showExample(){ 3 int a = 10; 4 int b = 20; 5 int c = 30; 6 return a*(b+c); 7 } 8 9 10 //class文件中 showExample方法的字节码 11 public int showExample(); 12 Code: 13 Stack=3, Locals=4, Args_size=1 14 0: bipush 10 15 2: istore_1 16 3: bipush 20 17 5: istore_2 18 6: bipush 30 19 8: istore_3 20 9: iload_1 21 10: iload_2 22 11: iload_3 23 12: iadd 24 13: imul 25 14: ireturn 26 LineNumberTable: 27 line 5: 0 28 line 6: 3 29 line 7: 6 30 line 8: 9 31 32 LocalVariableTable: 33 Start Length Slot Name Signature 34 0 15 0 this Ljvm/executionengine/stackframe/example; 35 3 12 1 a I 36 6 9 2 b I 37 9 6 3 c I