虚拟机字节码执行引擎
1.概述
在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的 Java 虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本章主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行
2.运行时栈帧结构
java虚拟机以方法作为最基本的执行单元。“栈帧”则是用于支持虚拟机进行方法调用和方法执行的数据结构。在编译java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈已经被分析计算出来,不会受到程序运行期变量数据的影响。
一个线程中方法的调用链可能很长,以java角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
2.1局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽为最小单位,一个变量槽可以存放一个32位以内的数据类型(boolean,byte,char,short,int,float,reference,returnAddress),其中reference表示对一个对象实例的引用。对于long和double,java虚拟机则会以高位对齐的方式为其分配两个连续的变量槽空间。不过由于局部变量表建立在线程的堆栈中,属于线程私有的数据,所以无论读写两个连续的变量槽是否为原子操作,都不会引起线程安全问题。变量槽的长度可以随着处理器,操作系统或虚拟机实现的不同而发生变化,如64位虚拟机中使用了64位的物理内存空间去实现一个变量槽。
如果遇到一个方法,其后面定义的代码有一些耗时很长的动作,而前面又定义了占用大量内存但是实际已不会再使用的变量,可以手动设置为null,把变量对应的局部变量槽清空,促进垃圾回收。
局部变量不像类变量存在准备阶段,不会赋予系统初始值。所以如果一个局部变量定义了但是没有赋初始值,就完全不能使用
2.2操作数栈
当一个方法开始执行的时候,这个方法的操作数栈是空的。在方法的执行过程中,会有各种字节码指令往操作数栈写入和提取内容,也就是出栈和入栈。举个例子,整数加法的字节码指令iadd,这条指令在运行时要求操作数栈中最接近栈顶的两个元素已经存入两个int类型的数值,当执行这个指令时,会把这两个int值出栈并想加,然后将相加结果重新入栈。
2.3动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
在 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
2.4方法返回地址
当一个方法被执行后,有两种方式退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
- 方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。注意:这种退出方式不会给上层调用者产生任何返回值。
无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
3.方法调用
方法调用不等于方法中的代码执行,唯一的任务就是确定被调用方法的版本,即调用哪一个方法。
3.1解析
所有方法调用的目标方法在class文件里面都是一个常量池中的符号引用,在类加载阶段,会将其中的一部分符号引用转换为直接引用,这些方法要求运行期是不可改变的。
在java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类。这两种方法各自的特点决定了他们不可能通过继承或别的方式重写出其他版本,因此适合在类加载阶段进行解析。另外被final修饰的实例方法,也可以在类加载阶段进行解析
3.2分派
分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟中是如何实现的。
3.2.1 静态分派
所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。静态分派最典型的应用就是方法重载。
1 public class StaticDispatch { 2 static abstract class Human { 3 } 4 5 static class Man extends Human { 6 } 7 8 static class Woman extends Human { 9 } 10 11 public void sayhello(Human guy) { 12 System.out.println("Human guy"); 13 } 14 15 public void sayhello(Man guy) { 16 System.out.println("Man guy"); 17 18 } 19 20 public void sayhello(Woman guy) { 21 System.out.println("Woman guy"); 22 } 23 24 public static void main(String[] args) { 25 Human man = new Man(); 26 Human woman = new Woman(); 27 StaticDispatch staticDispatch = new StaticDispatch(); 28 staticDispatch.sayhello(man);// Human guy 29 staticDispatch.sayhello(woman);// Human guy 30 } 31 } 32 33 /* 34 result: 35 Human guy 36 Human guy 37 */
Human man = new Man();其中的Human称为变量的静态类型(Static Type),Man称为变量的实际类型(Actual Type)。
两者的区别是:静态类型在编译器可知,而实际类型到运行期才确定下来。
在重载时通过参数的静态类型而不是实际类型作为判定依据,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayhello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
3.2.2 动态分派
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。最典型的应用就是方法重写。
1 public class DynamicDisptch { 2 static abstract class Human { 3 abstract void sayhello(); 4 } 5 6 static class Man extends Human { 7 @Override 8 void sayhello() { 9 System.out.println("man"); 10 } 11 } 12 13 static class Woman extends Human { 14 @Override 15 void sayhello() { 16 System.out.println("woman"); 17 } 18 } 19 20 public static void main(String[] args) { 21 Human man = new Man(); 22 man.sayhello(); 23 Human woman = new Woman(); 24 woman.sayhello(); 25 man = new Woman(); 26 man.sayhello(); 27 } 28 } 29 30 31 /* 32 * result: 33 * man 34 woman 35 woman 36 * 37 */
这里选择调用的方法版本不再依据静态类型来做决定,因为静态类型同样都是human的两个变量man和woman在调用sayHello方法时产生了不同的行为。那么java虚拟机是如何根据实际类型来分派方法的执行版本呢?
根据《java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按继承关系从下往上依次对C的各个父类将那些第二步的搜索和校验
4)如果始终没找到合适的方法,则抛出java.lang.AbstractMethodError.
4.基于栈的解释器执行过程
package com.ryj.hotspot.dispatch; public class Test { public static void main(String[] args) { System.out.println(calc()); } private static int calc() { int a = 100; int b = 200; int c = 300; return (a+b)*c; } }
0: bipush 100 :执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量 100 推入操作数栈顶
2: istore_0 :istore_0指令的作用是将操作数栈顶的整型值出栈并存放到第0个局部变量槽
11: iload_0 :iload_0指令的作用是将局部变量表第0个变量槽中的整型值复制到操作数栈顶
13: iadd :iadd指令的作用是将操作数栈中的头两个栈顶元素出栈,做整型加法,然后把结果入栈