虚拟机字节码执行引擎

来源:互联网 发布:linux shadowsock 编辑:程序博客网 时间:2024/06/06 01:50

概述

Java虚拟机字节码执行引擎是Java虚拟机最核心的组成部分之一。它负责Java程序的执行,执行的过程主要包括方法的调用方法代码的执行(对代码块代码的执行其实在虚拟机内部也是组装成方法执行的)。

所以对执行引擎的讲解主要就是讲解方法的调用和方法执行过程。下边先讲解用于支持虚拟机进行方法调用和方法执行的数据结构——栈帧


运行时栈帧结构


栈帧(stack frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包含了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译 程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现
对于执行引擎来讲,在活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有字节码指令都只是针对当前栈帧进行操作。
一个栈中能容纳的栈帧是受限,过深的方法调用可能会导致StackOverFlowError,当然,我们可以人为设置栈的大小。
其结构如下图:


局部变量表


局部变量表存放的一组变量的存储空间。存放方法参数和方法内部定义的局部变量表。在java编译成class的时候,就在方法的Code属性的max_locals数据项中确定了局部变量表所需分配的最大容量。
局部变量表的最小单位是Slot,一个Slot可以存放一个32位以内的数据类型,对于64位的数据类型(long,double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
局部变量表的读取方式是索引,从0开始。所以局部变量表可以简单理解为就是一个表。
在方法执行时,虚拟机是使用局部变量表完成参数值参数变量列表的传递过程,如果是实例方法(非static)那么局部变量表中第0位索引的slot默认是用于传递方法所属对象实例的引用,方法中可以通过this来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量slot,参数表分配完毕之后,再根据方法体内部定义的变量顺序和作用域分配其余的slot。
局部变量表示意图:


操作数栈


操作数栈(Operand Stack)也常称为操作栈,操作数栈的最大深度在编译的时候写入到Code属性的max_stacks数据项中。栈中的任何一个元素都是可以任意的Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法的执行过程中,会有各种字节码指令往操作数中写入和提取内容,也就是出栈/入栈操作。
java虚拟机的解释执行引擎称为基于栈的执行引擎,其中所指的栈就是操作数栈。

动态连接


每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。字节码中的方法调用指令就以常量池中指向方法的符号引用参数这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化为称为静态解析,另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接

方法返回地址


有两种方式可以退出这个方法:1、执行引擎遇到任意一个方法返回的字节码指令,称为正常完成出口。2、在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,称为异常完成出口
方法退出的过程实际上等同于把当前栈帧出栈,方法正常退出时调用者的pc计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值,然后执行引擎会恢复调用者的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令
方法异常退出时,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。

附加信息


虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中。一般把动态连接方法返回地址与其他附加信息全部归为一类,称为栈帧信息


下面开始具体介绍方法调用阶段和方法执行阶段的过程

方法调用


方法调用阶段就是要确认调用的是哪一个方法,还不涉及方法内部的具体运行过程。一切方法调用在class文件中存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址,这使得java有着更强大的动态扩展能力,但也使得java方法的调用过程变得相对复杂起来,需要在类的加载甚至运行期间才能确定目标方法的直接引用。

解析


这部分应该是对类加载过程的解析阶段虚拟机类加载机制)的更深入讲解。对于Class文件中的符号引用,一部分是在类加载过程中转化为直接引用的,一部分是在运行期转化为直接引用的。
在类加载过程中转化为直接引用的前提是:方法在程序真正运行之前就有一个可确定的调用版本(即可确定的目标方法),并且这个方法的调用版本在运行期是不可改变的。这类方法调用你称为解析(Resolution)。Java语言中符合这个条件的有静态方法私有方法实例构造器父类方法四类,它们在类加载的时候会把符号引用解析为该方法的直接引用

Java虚拟机提供5中方法调用命令

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器<init>方法,私有方法和父类方法
  • invokevirtual:调用虚方法
  • invokeinterface:调用接口方法
  • invokedynamic:现在运行时动态解析出该方法,然后执行

前4条调用指令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
invokestatic指令和invokespecial指令调用的方法都可在解析阶段确定唯一的调用版本,在类加载的时候会把符号引用解析为该方法的直接引用。这些方法称为非虚方法其他方法称为虚方法(除final方法)。被final修改时的方法也是非虚方法虽然final方法是使用invokevirtual指令调用的
解析调用一定是一个静态的过程,编译期间就完全确定,在类装载的解析阶段就会把涉及到的符号引用
全部转化为可确定的直接引用,不会延迟到运行期间再去完成。

分派

分派调用可能是静态的也可能是动态的,根据分派依据的宗量数又可分为单分派和多分派。两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派4种分派组合。

静态分派


静态分派就是方法重载解析(Method Overload Resolution),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("Hello, guy! " + guy);    }    public void sayHello(Man guy){        System.out.println("Hello, gentleman! " + guy);    }    public void sayHello(Woman guy){        System.out.println("Hello, lady! " + guy);    }    public static void main(String[] args) {        Human man = new Man();        Human woman = new Woman();        StaticDispatch sd = new StaticDispatch();        sd.sayHello(man);        sd.sayHello(woman);    }}
运行结果为:
Hello, guy! com.jd.jvm.exec.StaticDispatch$Man@1b6d3586
Hello, guy! com.jd.jvm.exec.StaticDispatch$Woman@4554617c


所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段。
有时候重载版本并不“唯一”,编译器会确定一个“更加合适的”,参数自动转型的顺序为:char->int->long->float->double->父类/父接口->封装类型->变长参数

动态分派


在运行期间根据实际类型来确定方法执行版本的分派调用过程称为动态分派。这跟多态性的另一个体现——重写(Override)有着很密切的关联。通过接口类型的变量调用方法对应的字节码指令是invokevirtual
invokevirtual指令有多态查找的机制,该指令的运行时解析过程步骤如下
  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做c
  2. 如果在类型c中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,不通过则返回java.lang.IllegalAccessError.
  3. 否则,按照继承关系从下往上依次对c的各个父类进行第二步的搜索和验证过程。
  4. 始终没找到合适的方法,抛出java.lang.AbstractMethodError异常。

单分派与多分派


方法的接受者与方法的参数统称为方法的宗量单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。Java语言是静态多分派,动态单分派的。

虚拟机动态分派的实现


动态分派在Java中被大量使用,使用频率及其高,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此JVM在类的方法区中建立虚方法表(virtual method table)来提高性能。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类该方法的地址入口一样。如果子类重写父类的方法,那么子类的虚方法表中该方法的实际入口将会被替换为指向子类实现版本的入口地址。 
方法表结构示意图:



方法调用总结

方法调用的过程就是把方法的符号引用解析转化为直接引用的过程。解析调用静态分派在编译期间就可以完全确定目标方法,在类装载的解析阶段就会把符号引用转化为直接引用;动态分派需要在运行时期根据实际类型解析出目标方法。解析调用主要用于静态方法、私有方法、实例构造器、父类方法;静态分派主要应用于方法重载(Overload);动态分派主要应用于方法重写(Override)。解析分派之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。Java语言中静态分派属于多分派类型,动态分派属于单分派类型。

当确定了目标方法后,之后就是执行方法中的代码(字节码),下面介绍基于栈的字节码解释执行引擎介绍一下方法执行的过程:

基于栈的字节码解释执行引擎


在jdk 1.0时代,Java虚拟机完全是解释执行的,随着技术的发展,现在主流的虚拟机中大都包含了即时编译器(JIT)。因此,虚拟机在执行代码过程中,到底是解释执行还是编译执行,只有它自己才能准确判断了,但是无论什么虚拟机,其原理基本符合现代经典的编译原理,如下图所示: 


Java语言中,javac编译器完成了词法分析、语法分析以及抽象语法树的过程,再遍历语法树生成线性字节码指令流的过程,此过程发生在虚拟机外部,而解释器在虚拟机的内部。

基于栈的指令集与基于寄存器的指令集


Java编译器输出的指令流基本上是一种基于的指令集架构,指令流中的指令大部分是零地址指令,它们依赖于操作栈进行工作。另外一种指令集架构则是基于寄存器的指令集架构,典型的应用是x86的二进制指令集,比如传统的PC。两者之间最直接的区别是,基于栈的指令集架构不需要硬件的支持,基于操作栈;而基于寄存器的指令集架构则完全依赖硬件(寄存器)。
基于寄存器的指令集架构执行效率更高,但可移植性差,而基于栈的指令集架构的移植性更高,但执行效率相对较慢,因为完成相同功能所需的指令数,基于栈的指令集往往需要更多的指令且需频繁的访问内存。

基于栈的字节码解释执行过程

下面列出《深入理解Java虚拟机》一书上的一个简单的执行过程的例子,便于理解执行引擎执行的过程:
public int calc()    {        int a = 100;        int b = 200;        int c = 300;        return (a+b)*c;    }
其字节码表示:


执行过程的概念模型:





从这段程序的执行中可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈,入栈为信息交换途径。