虚拟机的字节码执行引擎

    xiaoxiao2022-07-07  137

    一、什么是执行引擎

        在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行和编译执行,但从外观来看,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本节将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

    二、每当一个java方法被调用时都会在虚拟机中新创建一个栈帧,那什么是栈帧呢?

    1、栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。每一个方法从调用开始至调用完成的过程都对应着一个栈帧从虚拟机栈从入栈到出栈的过程。

    2、栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息,在编译期,栈帧中需要多大的局部变量表和多深的操作数栈都是可以确定的,并且写入到字节码文件中的方法表的Code属性之中,因此每一个栈帧需要多大的内存,只与具体的虚拟机实现有关,而不受运行时影响。

    3、一个线程中,可能有许多方法处于执行状态,但对于执行引擎来说,只有目前正在执行的那个方法的栈帧是活动的(位于栈顶)。这个栈帧就称为当前栈帧(current frame),这个栈帧对应的方法称之为当前方法(current method)。定义这个方法的类就称之为当前类(current class)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

    图片来源:https://www.cnblogs.com/snailclimb/p/9086337.html

                

    (1)局部变量表:局部变量表是一组变量存储空间,方法参数和局部变量都存储在局部变量表里面。在Code属性中的max_locals确定了该方法中局部变量表的最大容量。

           局部变量表的容量以变量槽(Variable Slot)为最小单位。 一个Slot可以存放一个32位以内(boolean、byte、char、short、int、float、reference和returnAddress)的数据类型,reference类型表示一个对象实例的引用,returnAddress已经很少见了,可以忽略。

          对于64位的数据类型(Java语言中明确的64位数据类型只有long和double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

        虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。

        在方法执行时,虚拟机使用局部变量表完成参数值到参数变量表的传递过程,如果执行的时实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过“this”来访问这个隐含的参数,其余参数按照参数表顺序排列,参数表分配完毕,在根据方法内部定义的变量顺序和作用域分配其他的Slot。

        为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:在某些情况下,Slot的复用会直接影响到系统的收集行为。

    (2)操作数栈:也常称为操作栈,它是一个后入先出栈,同局部变量表一样,在代码编译的时候,操作数栈的最大深度写入到了Code属性中max_stacks数据项中,操作数栈中每一个元素可以是任意的Java数据类型,包括long和double,32数据类型占的栈容量是1,64位数据的栈容量是2,在方法执行过程中操作数栈的深度都不会超过max_stacks数据项中规定的最大值。

         在方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中会有各种字节码指令往里面写入和 提取内容,也就是入栈和出栈的过程,方法中调用其他方法的时候是通过操作数栈来进行参数传递的。

         在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。        

                                                       

    (3)动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接;

            Class文件的常量池中存在大量的符号引用。字节码中的方法调用指令就以常量池指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候转化为直接引用,这种方式称为静态解析。另一部分会在每次运行期间转化为直接引用,这种称为动态连接。

    (4)方法返回地址:当一个方法开始执行后,只有两种方式退出该方法:

       1)第一种是执行引擎遇到任意一种方法返回的字节码指令,这种情况下可能会有方法返回值传递给方法调用者。这种方式称为正常完成出口;

       2)另外一种是执行过程中出现异常,但是方法体内又没有对异常进行处理,这种退出不会给上层调用者任何返回值。这种方式称为异常完成出口;

          不管哪种方式,在方法退出之后,都需要返回方法被调用的位置,程序才能继续执行,方法返回时需要在栈帧中保存一些信息,用来帮助回复它上层方法的执行状态。方法正常退出时,PC计数器的值可以作为返回地址,方法异常退出时,返回地址是通过异常处理器来确定的,栈帧中一般不会保存这个信息。

    三、我们知道方法调用会创建栈帧, 接下来再来了解下方法调用

         方法调用阶段的目的:确定被调用方法的版本(哪一个方法),不涉及方法内部的具体运行过程,在程序运行时,进行方法调用是最普遍、最频繁的操作。一切方法调用在Class文件里存储的都只是符号引用,当需要在类加载期间或者是运行期间,才能确定为方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

    1、从上面知道,调用方法的时期有两个:在类加载期间或者是运行期间,运行期间调用好理解,来说说在类加载期间的调用。

         类加载期间就调用的方法有个要求:就是在运行时候不能修改了,即代码写“死"了的方法,这类方法的调用称为解析调用

         主要包括两类:静态方法和私有方法(不能被继承或者重写)。Java虚拟机有5条字节码指令:

         - invokestatic : 调用静态方法       - invokespecial:调用实例构造器<init>方法、私有方法、父类方法       - invokevirtual:调用所有的虚方法 ,但final方法也由它调用。      - invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象       - invokedynamic:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

           只要能被invokestatic 和 invokespecial 调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有:静态方法、构造器方法、私有方法、父类方法4类,它们在类加载时就会把符号引用解析为该方法的直接引用。它们也称为非虚方法。其他方法称为虚方法(除final修饰的方法)。

    2、解析调用是一个静态的过程(符号引用转变为直接引用的过程不会延迟到运行时),分派调用可以是静态的,也可以是动态的,分4种:静态单分派,动态单分派,静态多分派,动态多分派。

        分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟中是如何实现的。

       (1) 静态分派

       所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段。静态分派最典型的应用就是方法重载。在编译阶段,虚拟机会根据参数的静态类型而不是实际类型来选择重载版本。举例如下:

    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 guy Human guy

        分析:Human man = new Man();其中的Human称为变量的静态类型(Static Type),Man称为变量的实际类型。    两者的区别是:静态类型在编译器可知,而实际类型到运行期才确定下来。      在重载时通过参数的静态类型而不是实际类型作为判定依据,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所以选择了sayhello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

    (2)动态分派:在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。最典型的应用就是方法重写。

    (3)单分派和多分派:

             方法的接收者、方法的参数都可以称为方法的宗量。根据分批基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。

             Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

             运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

    3、虚拟机动态分派的实现

          由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。

         其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。虚方法表一般在类加载的连接阶段进行初始化。

    三、动态类型语言的支持(以下的内存不具体)

     前面说到有5中的字节码指令中的最后一个invokedynamic指令,是JDK1.7新增加的,用来是实现“动态类型语言”。   

    1、动态类型语言: 是指在运行期间才去做数据类型检查的语言,在运行时代码可以根据某些条件改变自身结构。主要动态语言:Object-C、C#、JavaScript、PHP、Python、Erlang。

    2、静态类型语言:静态语言的数据类型是在编译期间(或运行之前)确定的,编写代码的时候要明确确定变量的数据类型。主要语言:C、C++、C#、Java、Object-C。

    3、jvm不只是跨平台的,还是跨语言的,当有人在jvm上试图开发动态类型语言的时候,问题就来了:

          jvm大多数指令都是类型无关的,但是在方法调用的时候,却不是这样,每个方法调用在编译阶段就必须指明方法参数和返回值类型,但是动态类型语言的方法参数,直到运行时刻才能知道类型啊,因此jdk就做了这样一个“补丁”:用invokedynamic调用方法的时候,会转到bootstrap方法,在这个方法里可以动态获取参数类型,然后根据参数类型分派合适的方法作为CallSite(动态调用点),最后真实调用的就是CallSize里的方法。如此便能在jvm上实现动态类型语言的方法调用了。

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

        虚拟机如何调用方法的内容已经讲解完毕,现在我们来探讨虚拟机是如何执行方法中的字节码指令。

        1、解释执行

         Java语言经常被人们定位为 “解释执行”语言,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

                    

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

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

          Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集, 依赖寄存器进行工作。

         基于栈的指令集主要的优点就是可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

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

     

    借鉴:《深入理解JAVA虚拟机》

    借鉴博客:https://www.cnblogs.com/snailclimb/p/9086337.html

    最新回复(0)