深入理解Java虚拟机-第八章 虚拟机字节码执行引擎

第八章 虚拟机字节码执行引擎

8.1 概述

执行引擎是 Java 虚拟机核心组件之一。虚拟机 是一个相对于 物理机 的概念,这两种机器都能执行代码,区别在于物理机是基于 CPU、寄存器、指令集和操作系统层面上的,而虚拟机的执行引擎是由自己实现的,因此可以自行制定指令集和执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集合。

8.2 运行时栈帧结构

运行时栈帧结构,实际上指的是虚拟机运行时数据区中的 虚拟机栈 的栈元素,栈帧存储了局部变量表、操作数栈、动态链接、方法出口等信息,一个方法的执行过程都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
每一个栈帧需要分配的局部变量表的内存大小和操作数栈深度都在编译的时候就确定了,并且放入了方法表中 Code 属性中(max_stack / max_locals)。因此一个栈帧需要分配多少内存不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中只有位于栈顶的栈帧才是有效的,被称为当前栈帧,与这个栈帧相关联的方法被称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。概念模型大致如下:
虚拟机栈概念模型

8.2.1 局部变量表

局部变量表是一组变量值存储的空间,用以存储方法参数和方法内部定义的变量。局部变量表以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范并没有指定 slot 的大小,只是说每个 slot 可以存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,这 8 种数据类型都可以使用 32 位或更小的物理内存来存放。**但是这种描述跟明确指出“每个 Slot 占用 32 位长度的内存空间”是有一定差别的。**它允许 Slot 的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。
对于 reference 类型表示对一个对象实例的引用,虚拟机实现至少都应到能通过这个引用做到两点:

  1. 从此引用中直接或简洁的查找到对象在 Java 堆中的数据存放的起始地址索引。
  2. 此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。

对于 64 位的数据类型,虚拟会以高位对齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的 64 位数据只用 long 和 double 两种(reference 类型可能是 32 位也可能是 64 位)。因为局部变量表是建立在线程的堆栈上的,是线程私有的数据,所以无论读写两个连续的 Slot 是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始到局部变量表最大的 slot 数量。如果访问的是 32 位数据类型的变量,索引 n 就代表着使用了第 n 个 slot ,而如果访问的是 64 位类型的数据,则是第 n 和 n+1 两个 slot。对于两个相邻的共同存放一个 64 位数据的两个 slot,不允许采用任何方式单独访问其中一个。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非 static 的方法),那局部变量表中第 0 位索引的 Slot 默认适用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 访问。其余的参数按照参数表顺序排列,参数表分配完毕后再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。
为了节省栈帧空间,局部变量表中的 Slot 是可以复用的,但是**一个 slot 是否被回收的根本原因是:局部变量表中的 slot 是否还存有关于前对象的引用。**什么意思呢,看如下一段代码:

public static void main(String[] args) {

        {
            byte[] placeholder = new byte[64 * 1024 * 1024];
            // placeholder = null;
        }
        System.gc();
    }

运行结果:
运行结果1
这里的 placeholder = null 这行被注释掉,即没有释放引用,虽然在 gc 的时候变量已经不可用了,但这里的内存仍然不会被回收。因为他还在局部变量表中即变量仍然与 GC Roots 保持关联。运行结果如下:

但是我们放开这行注释,让他把引用释放掉之后会怎样,代码如下:

public static void main(String[] args) {

        {
            byte[] placeholder = new byte[64 * 1024 * 1024];
            placeholder = null;
        }
        System.gc();
    }

运行结果:
在这里插入图片描述
结果显而易见,这里的内存被回收了。所以在某些特定情境下(例如一个方法,后面的代码有一些 耗时很长的操作,而前面又有实际上已经不会使用的但占用了大量内存的变量,这里将引用设置为 null 有利于提前回收垃圾)将不用的变量设置为 null 变不见得是一个绝对无意义的操作。
这里还需要说明的一点是,局部变量不像类变量一样有一个初始化的阶段,即先赋值为 0 然后再赋值的操作。如果一个局部变量定义了但没有赋初始值是不能使用的(编译器会给予提示,这里仅说明下原理~)。

8.2.2 操作数栈(Operand Stack)

操作数栈又称操作栈,他是一个 LIFO(Last In First Out,后进先出)栈。操作数栈的每一个元素可以是任意的 Java 数据类型,32 位数据占 1 个单位的容量,64 位数据则占 2 个单位。当一个方法刚刚开始执行的时候,这个方法的操作数栈为空,方法过程中会有各种字节码指令往操作数栈中写入和提取内容,也就是 出栈 / 入栈 操作。举个例子,整数加法的字节码指令 iadd 就是讲操作数栈中靠栈顶的两个元素出栈并相加,然后再入栈。这里需要注意的是操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,类校验阶段的数据流分析中还要再次验证一次。例如 iadd 指令操作室,最接近栈顶的两个元素数据类型必须为 int ,不能出现一个 long 和一个 float 使用 iadd 相加的情况。
在概念模型中,两个栈帧作为虚拟机栈的元素是完全独立的。但是大多数实现中其实会令两个栈帧出现一部分重叠,让下面栈帧中的操作数栈和上面栈帧局部变量表部分重叠在一起。这样在进行方法调用的时候就可以共用一部分数据,避免进行额外的参数复制传递。
Java 虚拟机的解释执行引擎称为 “基于栈的执行引擎”,其中所指的“栈”就是说的操作数栈

8.2.3 动态连接

8.3 节中详细解释

8.2.4 方法出口(方法返回地址)

方法退出只有两种方式,一种是执行引擎遇到了任意一个方法返回的字节码指令,这种推出的方式称为正常完成出口( Normal Method Invacation Completion)。还有一种是碰到异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式被称为异常完成出口(Abrupt Method Invacation Completion)。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置程序才能继续进行。方法返回的时候就需要保存一些信息,一般来说方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,詹振中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:回复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者战阵的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

8.3 方法调用

方法调用不同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用那一个方法)。前文说过,一切方法调用在 Class 文件中存储的都只是符号引用,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。

8.3.1 解析

在类加载的解析阶段会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不能够改变的。这列方法的调用称为解析(Resolution)。
在 Java 语言中符合 “编译器可知,运行期不变” 这个要求的方法,主要包括静态方法和私有方法两大类。与之相对应的是,在 Java 虚拟机里面提供了 5 条方法调用字节码指令,如下:

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

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,他们被称作 非虚方法(在类加载的时候就会把符号引用解析为该方法的直接引用)。
虽然 final 方法是使用 invokevirtual 指令来调用的,但是它没有办法被覆盖所以也无需对方法接收者进行多态选择,在 Java 语言规范中明确说明了 final 方法是一种非虚方法。

8.3.2 分派

解释分派前,先说明两个关键性的名词:静态类型和实际类型。
在如下代码中,Human 被称为变量的静态类型,Man 被称为实际类型。

Human man = new Man();

区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,最终的静态类型是在编译期克制的。而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么,如下代码:

// 实际类型变化
Human man = new Man();
man = new Woman();

// 静态类型变化
System.out.println((Man)man);
System.out.println((Woman)man);
8.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 guy) {
        System.out.println("hello, gentleman!");
    }
    
    public void sayHello(Woman guy) {
        System.out.println("hello, lady!");
    }

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

        StaticDispatch sd = new StaticDispatch();
        sd.sayHello(man);
        sd.sayHello(woman);

    }

}

执行结果如下:
执行结果
结果还算显而易见,在方法接受者已经确定是对象 sd 时,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段 javac 编译器会根据参数的静态类型决定使用哪个重载版本。所有依赖静态类型来定位方法执行版本的分派动作被称为静态分派。
编译器虽然能确定出方法的重载版本呢,但很多情况下这个重载版本不是唯一的,往往只能确定一个更加合适的版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,他的静态类型只能通过语言上的规则去理解和推断。例如如下代码:

public class Overload {

    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    public static void main(String[] args) {
        Overload.sayHello('a');
    }

}

来看这段代码,你猜会输出啥。

hello char

这是一定的,毋庸置疑的对吧。但是我们要是把 sayHello(char arg) 这个方法注释掉,你猜会输出什么,Character 吗?NO NO NO! 输出的是:

hello int

哈?为啥,因为这时发生了一次自动类型转换,‘a’ 可以代表的除了一个字符之外,它还可以代表一个数字 97 (字符 a 的 Unicode 数值为十进制数字 97),所以这里打印 int。
如果再注释掉 sayHello(int arg) 呢?这回就是:

hello Character

这时发生了一次自动装箱,a 被包装为它的封箱类型 java.lang.Character。
就先举这些例子吧,这些例子演示了编译期间选择静态分派目标的过程,这个过程也是 Java 语言实现方法重载的本质。

8.3.2.2 动态分派

动态分配我们可以简单的认为就是重写(Override)的实现本质。二话不说先上代码:

public class DynamicDispatch {

    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        public void sayHello() {
            System.out.println("hello, gentleman!");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("hello, lady!");
        }
    }


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

}

运行结果如下:
运行结果
这个结果也是毋庸置疑的,导致这个现象的原因很明显,是这两个变量的实际类型不同。这里会执行一个 invokevirtual 指令,invokevirtual 指令的运行时解析过程大致分为以下几个步骤:

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

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

8.3.2.3 单分派与多分派

只需记住一点:直至 JDK 1.8,Java语言都是一个静态多分派、动态单分派的语言。

8.3.2.4 虚拟机动态分派的实现

由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最长用的“稳定优化手段就是”为类在方法区中建立一个虚方法表(Vritual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表–Interface Method Table,简称 itable ),使用虚方法表索引来代替元数据查找以提高性能。我们回过头来看上面那段代码,它所对应的虚方法表结构示例如下:
方法表结构
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被 override ,那子类的 vtable 里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口,如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。如上图中 Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向Father类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以他们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的 vtable 中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表就可以从不同的 vtable 中按索引转换出所需的入口地址
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

8.3.3 动态类型语言支持

动态类型语言:动态类型语言的关键特征是他的类型检查的主体过程是在运行期而不是编译器,相对的,在编译器就进行类型检查过程的语言就是最常用的静态类型语言。
我们举两个简单的例子来讨论下什么是 编译期 / 运行期 进行和什么是类型检查,请看如下一段代码:

	public static void main(String[] args) {
        int[][][] array = new int[1][0][-1];
    }

这段代码看上去很无厘头,但是其实是可以编译通过的,当然他是没法运行的,这就是 编译期 检查通过,运行期 不通过。
再来看一段代码:

	obj.println("hello world")

这行代码明眼人看上去一眼就知道他要干什么,但是对计算机而言,这行代码没头没尾,是无法执行的。他需要一个具体的上下文才有讨论的意义。
现在假设这个代码是在 Java 中,并且变量 obj 的静态类型为 java.io.PrintStream,那么变量 obj 的实际类型就必须是 PrintStream 的子类才是合法的。否则,哪怕 obj 属于一个确实有用 println(String) 方法,但与 PrintStream 接口没有继承关系,代码依然不能运行——因为类型检查不合法。
但是相同代码在 ECMAScript (JavaScript)中情况则不一样,无论 obj 具体是何种类型,只要这种类型的定义中却是包含有 println(String) 方法,那方法调用便可成功。
这种差异的原因是 Java 语言在编译期间已将 println(String) 方法完整的符号引用生成出来,作为方法调用指令的参数存储到 Class 文件中。而动态语言大多在定义的时候不给变量赋以类型,即变量 obj 本身是没有类型的,obj 的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。变量无类型而变量值才有类型这个特点也是动态类型语言的一个重要特征。

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

8.4.1 解释执行

在开始之前,先介绍一下解释执行和编译执行的含义:

  • 解释执行:代码由生成字节码指令之后,由解释器解释执行
  • 编译执行:通过即时编译器(JIT,Just In Time)生成本地代码执行
    基本编译流程如下,中间这条流程是 解释执行,最下面的流程则是编译执行。
    编译过程
    如今,基于物理机、Java虚拟机,或者非Java的其他高级语言虚拟机(HLLVM)的语言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST) 。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C / C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行器。
    Java 语言中,Javac 编译器完成了程序代码经过 词法分析、语法分析 到 抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。
8.4.2 基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
举个最简单的例子,分别使用这两种指令集计算“1+1"的结果,基于栈的指令集会是这样子的:

// 基于栈的指令集
iconst_1
iconst_1
iadd
istore_0

// 基于寄存器
mov eax, 1
add eax, 1

那么究竟哪个更好呢,其实两套各有优势。基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接以来这些硬件寄存器的话免不了首映键约束。栈架构的指令集还有一些其他的优点,如代码相对更紧凑(字节码中每个字节就对应一条指令,多地址指令集中还需要存放参数)、编译期实现更加简单(不必考虑空间分配的问题,所需空间都在栈上操作)。
但是栈架构指令集的主要缺点是执行速度相对来说会稍微慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

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

上一段代码:

public class Demo {
    public static void foo() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 5;
    }
}

然后引用 @LittleCoding 同学的一篇文章 《深入理解Java虚拟机:虚拟机字节码执行引擎》中的一张动图:
在这里插入图片描述
相信已经说的很明白了。但是这种执行过程只是一种概念模型,虚拟机最终归会对这个过程做一些优化来提高性能。更准确地说,实际执行情况会和上面的图差距非常大。不过我们从这段程序的执行中也能看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。

读书越多越发现自己的无知,Keep Fighting!

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正。

欢迎友善交流,不喜勿喷~
Hope can help~

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值