JVM字节码执行引擎

  • Post author:
  • Post category:其他

一.概述

        执行引擎是Java虚拟机最核心的组成部分之一,“虚拟机”是一个相对于“物理机”的概念,这两种机器都具有执行代码的能力。其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集和执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

        在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。从外观来看,所有的Java虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的过程,输出的是执行结果,下面将从概念模型的角度来讲解虚拟机的方法调用字节码执行


二.栈帧

        栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。这行信息会在编译时被写入到Class文件的方法表的Code属性中。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

        一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示

接下来将详细讲解栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

局部变量表

        局部变量表是一组变量值存储空间,用于存放方法参数方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说明每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。也就是说一个Slot应该能够存放一个32位以内的数据类型。reference类型表示对一个实例对象的应用,虚拟机规范没有说明它的长度,也没有明确指出这种引用应该具有的结构。一般来说,虚拟机实现至少能应当通过这个引用做到两点:一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是引用中直接或间接地查找到对象所属数据类型在方法区中的类型信息。 对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

        为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体内定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。

       有一点需要注意,局部变量不像类变量那样存在“准备阶段”。我们知道类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值 。但局部变量就不一样,如果一个局部变量被定义了却没有赋予初始值是不能使用的

操作数栈

        操作数栈称为操作栈,它是一个后进先出的栈,当一个方法开始执行时,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和读取内容,也就是入栈和出栈操作。举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。  

        操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。

动态连接

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

方法返回地址

        当一个方法开始执行后,只有两种方式可以退出这个方法。第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型由方法返回指令决定,这种退出方法的方式成为正常完成出口。

        另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,这种退出方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

        无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,一般来说,方法正常退出是,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来处确定的,栈帧中一般不会保存这部分信息。

附加信息

        虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,在实际开发中,一般会把动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。

三.方法调用

        方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。我们知道,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件中存储的都是符号引用,而不是方法在实际运行时内存布局中的人口地址(即直接引用)。这个特性给Java带来更强大的动态扩展能力,但也使得Java方法调用变得复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。方法调用可以分为解析调用和分派调用两种。

1.解析调用

        所有方法调用中的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接应用,这种解析能成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析调用

        在Java语言中符合“编译期可知,运行期不可变”这个要求的方法主要包括静态方法私有方法两大类,前者与类型直接关联,后者在外部不可被访问。这两种方法各各自的特点决定了它们不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

        Java虚拟机提供了5条方法调用字节码指令,分别如下:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
  • invokedynamic:动态解析要调用的方法

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一调用版本,符合这个条件的有静态方法、私有方法、构造器方法、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。

2.分派调用

       众所周知,Java作为一门面向对象的程序语言有一个基本特征:多态。方法分派调用的过程正是多态特征的一些基本体现。分派调用将会解释Java中的“重载” 与“重写”在虚拟机中是如何实现的。

1)静态分派

        首先来看一段代码

/*
方法静态分派演示
 */
public class StaticDispatch {
    static abstract class Human{
    }
    static class Man extends Human{
    }
    static class Women extends Human{
    }
    public  void sayHello(Human guy){
        System.out.println("hello,human");
    }
    public  void sayHello(Man guy){
        System.out.println("hello man");
    }
    public  void sayHello(Women guy){
        System.out.println("hello women");
    }
    public static void main(String[] args){
        Human man = new Man();
        Human women = new Women();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(women);
    }
}

运行结果

这段代码体现的是方法的重载,虚拟机为什么会选择参数类型为Human的方法?要解释这个问题,首先要明确Human man = new Man()这行代码中的两个重要概念。我们把这行代码中“Human”称为变量的静态类型,后面的Man称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:

解释了这两个概念,我们再回到上述代码中,main()里面的两次sayHello()方法调用,在方法接受者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意定了两个静态类型相同但实际类型不同的变量,但虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译期会根据参数的静态类型决定使用哪个重载版本,并将选出的方法的符号引用写到main()方法里的invokevirtual指令的参数中。

        所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,需要注意的是,编译器虽然能确定方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。产生这种模糊的原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规划去理解和推断。

2)动态分派

        了解了静态分派,我们接下来看一下动态分派的过程,它和多态性的另外一个重要体现——重写有着密切的联系。同样用一段代码来演示

/**
 * 方法动态分派演示
 */
public class DynamicDiapatch {
    static abstract class Human{
        protected abstract void sayHello();
    }
    static class Man extends Human{
        protected void sayHello(){
            System.out.println("man say hello");
        }
    }
    static class Woman extends Human{
        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();
    }
}

运行结果

虚拟机是如何知道要调用哪个方法的?显然这里是不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法的执行版本的呢?这就要从invokevirtual指令的多态查找开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

①  找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
②  如果在类型C中找到了与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引 用,查找结束;如果不通过,则返回java.lang.IllegalAccessError异常。
③  否则,按照继承关系从下往上依次对C的各个父类进行步骤2的搜索和验证过程。
④  如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

有invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3)单分派和多分派

        方法的接收者方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量进行选择,多分派是根据多于一个宗量对目标方法进行选择。这个概念听起来很拗口,我们通过一段代码来进行说明

/**
 * 单分派、多分派演示
 */
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还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(_360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

        再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoiece(new QQ())”这句代码时,由于编译期已经确定了目标方法的签名必须为hardChoice(QQ),此时虚拟机不会关心传递给方法的实际参数,它不会影响虚拟机的选择,唯一可以影响虚拟机选择的因素是此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以说Java语言的动态分派属于单分派类型

        根据上述论证的结果,可以总结一句话:截止到目前为止(以后可能会变),Java语言是一门“静态多分派,动态单分派”的语言。

4)动态分派的实现

        前面介绍的分派过程,回答了虚拟机在分派中“会做什么”的问题,但是虚拟机“具体是如何做到的”呢?下面我们来看动态分派的实现原理。

        由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,出于性能的考虑,大部分虚拟机的真正实现都不会进行如此频繁的搜索。作为优化,虚拟机会为类在方法区建立一个虚方法表,使用虚方法表索引来代替元数据查找来提高性能。我们先看看前面用到的代码的虚方法表结构示例,如图

        虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类方法表中地址将会指向子类实现版本的入口地址。图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

        为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更要查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

        方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化后,虚拟机会把该类的方法表也初始化完毕。

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

        通过上述的讲解,我们对虚拟机是如何调用方法的有了一个大概的认识接下来我们将探讨虚拟机是如何执行方法中的字节码指令的。许多Java虚拟机的执行引擎在执行Java代码时都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。我们先来探讨下解释执行时,虚拟机执行引擎是如何工作的。

1.解释执行

        Java语言经常被人们定位为“解释执行”的语言,在JDK1.0时代,这种定位还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底是会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图所示的步骤,中间那条分支就是解释执行的过程。而下面那条分支是传统编译原理中程序代码到目标机器代码的生成过程。

        如今,基于物理机和Java虚拟机的语言大多都遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析、语法分析处理,把源码转化为抽象语法树,对于一门具体的语言实现来说,词法分析、语法分析以至于后面的优化器和目标代码生成器都可以独立于执行引擎,形成一个完成意义的编译器去实现,这类代表是C/C++语言。也可以选择将一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行器。

        Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

2.基于栈的指令集和基于寄存器的指令集

        Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一种常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,通俗来讲,就是现在我们主流PC机中直接支持的指令集架构,这些指令集依赖寄存器进行工作。这两者有何不同呢?

        举个简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈,相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

        如果是基于寄存器,那程序可能会是这个样子:

mov指令把EAX寄存器的值设为1,然后add指令把这个值加1,结果就保存在EAX寄存器里面。

        了解了基于栈的指令集和基于寄存器的指令集的区别后,你可能会问这两套指令集谁更好一些呢?应该这样说,既然这两套指令集同时并存和发展,自然是各有优势。

        基于栈的指令集的主要优点是可移植,因为它不直接依赖于寄存器,所以不受硬件的约束。它的主要缺点是执行速度相对会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。之所以速度慢,原因有两点:一是基于栈的指令集需要更多的指令数量,因为出栈和入栈本身就产生了相当多的指令;二是因为执行指令时会有频繁的入栈和出栈操作,频繁的栈访问也就意味着频繁的内存访问,相对于处理器而言,内存始终是执行速度的瓶颈。

3.基于栈的解释器执行过程

        有了上面的理论介绍,接下来我们准备一段Java代码,看看在虚拟机中实际是如何执行的。

	public int calc(){
		int a = 100;
		int b = 200;
		int c = 300;
		return (a+b)*c;
	}

用javap -v 命令查看该方法的字节码指令如下

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,根据这些信息画出如下几张图,用它们描述该方法在执行过程中代码、操作数栈和局部变量表的变化情况。

     上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程可能和概念模型相差较大,不过,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程。

五.结语

        通过上面的讲述,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法内的字节码,以及执行代码时涉及的内存结构。至此,相信你对JVM虚拟机的执行引擎一定有了一个大概的认识。

        


版权声明:本文为IT_GJW原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。