LCODER之JVM系列:字节码执行引擎

180 阅读17分钟

本章大纲: image.png

一、概述

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

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

二、运行时栈帧结构

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

在程序编译时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code(max_locals、max_stacks)属性中。

一个线程的方法调用链可能很长,很多方法都是同时处于执行的状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎只关注当前栈帧,其所有字节码指令都只针对当前栈帧进行操作。

在概念模型上,典型的栈帧结构如图所示:

image.png

2.1 局部变量表

局部变量表,是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在程序编译时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Slot)为最小单位。 虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个Slot占用32位长度的内存空间”是有一些差别的,它允许Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。只要保证即使在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。

这里需要注意的是:

  1. 虚拟机的8种数据类型,前6种很好理解,而第7种:reference类型,表示对一个对象实例的引用,虚拟机规范中没有明确规定reference类型的长度,她的长度与实际使用32还是64位虚拟机有关,如果是64位虚拟机,还与是否开启某些对象指针压缩优化有关。它的长度可能是32位也可能是64位。
  2. 对于64位数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
  3. Java语言中明确的(refrence类型可能是32位也可能是64位)64位的数据类型只有long和double两种。
  4. 虚拟机通过索引的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个slot。对于两个相邻的共同存放64位数据的Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机明确要求了,如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
  5. 在非static方法中,局部变量表的第0位索引的slot默认是“this”关键字的引用。
  6. Slot可以复用。
  7. 局部变量不像类变量一样,在准备阶段和初始化阶段有两次赋初始值的过程,局部变量不存在准备阶段,如果一个局部变量不被赋初始值就不能使用。

2.2 操作数栈

操作数栈,是一个后入先出栈。

当一个方法刚刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈、入栈操作。

举个例子:整数加法的字节码指令iadd在运行时操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。(关于这部分的内容,可以参照LCODER之JVM系列:运行时数据区的第2.2.2.3节:方法执行时,栈帧是如何工作的?来理解)

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

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递,重叠的过程如图8-2所示。

2.3 动态连接

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

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

2.4 方法返回地址

当一个方法被执行后,有两种方式退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  2. 方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。注意:这种退出方式不会给上层调用者产生任何返回值。

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

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

三、方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定调用的是哪一个方法,暂时还不涉及到方法内部的具体运行过程。 在程序运行时,进行方法调用是最普遍、最频繁的操作,但Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址,这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

3.1 解析

“编译期可知,运行期不可变”的方法(静态方法和私有方法),在类加载的解析阶段,会将其符号引用转化为直接引用(入口地址)。这类方法的调用称为“解析(Resolution)”。 与之对应的,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下:

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

3.2 分派

解析调用一定是个静态的过程,在编译期就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成,而分派调用则可能是静态的也可能是动态的。
分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟中是如何实现的。

3.2.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("hello,guy!");
    }
    
    public void sayHello(Man man){
         System.out.println("hello,man!");
    }
    
    public void sayHello(Woman woman){
         System.out.println("hello,woman!");
    }
    
    public static void main(String[] args){
           Human man = new Man();
           Human woman = new Woman();
           StaticDispatch sr = new StaticDispatch();
           sr.sayHello(man);
           sr.sayHello(woman);
    }
}

运行结果:

hello,guy!
hello,guy!

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

3.2.2 动态分派

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

public class DynamicDisptch {

    static abstract class Human {
        abstract void sayhello();
    }

    static class Man extends Human {

        @Override
        void sayhello() {
            System.out.println("man");
        }

    }

    static class Woman extends Human {

        @Override
        void sayhello() {
            System.out.println("woman");
        }

    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayhello();
        woman.sayhello();
        man = new Woman();
        man.sayhello();
    }
}

运行结果:

man
woman
woman

使用Javap输出这段代码的字节码:

image.png 0~15行是准备工作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:

Human man = new Man();
Human woman = new Woman();

接下来的16~21句是关键部分,16、20句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者;17和21句是方法调用指令,这两条指令单从字节码角度来看,无论是指令(invokevirual)还是参数(#22:Human.sayHello()的符号引用)完全一样的,但是这两天指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

3.2.3 虚拟机动态分派的实现

动态分派在虚拟机中是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。

虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。虚方法表一般在类加载的连接阶段进行初始化。

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

这一节讲的是,虚拟机是如何执行方法中的字节码指令的。

4.1 解释执行

Java语言经常被人们定位为 “解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器如GCJ(GNU Compiler for the Java),而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。 不论是解释还是编译,不论是物理机还是虚拟机,对于应用程序,机器都不可能像人那样阅读、理解,然后就获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都要经过下图中的各个步骤,这些内容是编译原理中的内容,如果对编译原理感兴趣,可以去阅读《编译原理》,这里就不再详细描述。

image.png

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

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集, 依赖寄存器进行工作。
那么,基于栈的指令集和基于寄存器的指令集这两者有什么不同呢?
举个简单例子,分别使用这两种指令计算1+1的结果,基于栈的指令集会是这个样子:

iconst_1

iconst_1

iadd

istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后将结果放回栈顶,最后istore_0把栈顶的值放到局部变量表中的第0个Slot中。
如果基于寄存器的指令集,那程序可能会是这个样子:

mov eax, 1

add eax, 1

mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,将结果就保存在EAX寄存器里面。
基于栈的指令集主要的优点就是可移植,寄存器是由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

栈架构的指令集还有一些其他的优点,如代码相对更加紧凑,编译器实现更加简单等。 栈架构指令集的主要缺点是执行速度相对来说会稍微慢一些。

但是基于栈的指令集主要缺点是执行速度相对于基于寄存器的指令集来说会稍微慢一些。

4.3 基于栈的解释器执行过程

这一章所讲的内容,其实在前面已经讲过了,具体可以查看LCODER之JVM系列:运行时数据区的第2.2.2.3节:方法执行时,栈帧是如何工作的?来详细理解,这里就不再说了。