【JVM系列读书笔记】五、JVM执行引擎

230 阅读7分钟

注:本文主要参考《深入理解Java虚拟机 第2版》与《揭秘Java虚拟机》

1、概述

执行引擎,就是一个运算器,能够识别所输入的指令,并根据输入的指令执行一套特定的逻辑,最终输出特定的结果。大致流程按照“取指->译码->执行->取下一条指令”循回往复的执行下去,而区别是物理CPU执行的和JVM执行的指令集不一致,且JVM最终也还是依赖于物理CPU执行。

2、物理执行引擎

物理CPU执行指令的流程是这样的(更详细可去读《计算机组成原理》):

  1. 取指,CPU的控制器从内存读取一条指令并放入指令寄存器,其中物理机器的指令一般由操作码和操作数组成(并不是左右操作码都有操作数),如 mov ax 1,其中mov ax就是操作码,而1就是操作数;
  2. 译码,指令寄存器中的指令经过译码,确定该指令应进行何总操作(由操作码决定),操作数在哪(由操作数决定);
  3. 执行,分两个阶段,“取操作数”和“进行运算”;
  4. 取下一条指令,修改指令计数器(亦称程序计数器),计算下一条指令的地址,并从新进入取值、译码和执行的循环。

3、JVM执行引擎

虚拟机执行引擎,在虚拟机规范中约定的是:输入的是字节码文件,处理过程是字节码解析的等效过程(可能是解释执行、编译执行或者两种结合),输出的是执行结果

3.1、虚拟机的方法调用

3.1.1、方法调用相关的数据结构信息

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

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

栈帧中的主要信息如下;

  1. 局部变量表(Local Variable Table),是一组变量值存储空间,用于存放放啊发参数和方法内定义的局部变量,其容量以变量槽(Variable Slot)为最小单位,槽的具体大小随着系统不同而不同,为了节省内存,可以复用;
  2. 操作数栈(Operand Stack),也常称为操作栈,它是一个后入先出的栈,其最大深度在编译的时候写入到Code属性的max_stacks数据项中,当一个方法刚开始执行的是为空,方法在执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,或者在调用其他方法的时候,是通过操作数栈进行参数传递;
  3. 动态链接,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,是为了支持方法调用过程中的动态链接;
  4. 方法返回地址,当一个方法执行时,只有两种方式可以退出这个方法,第一是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),另一种是方法执行过程中遇到了异常,这两种退出方式,都需要返回到方法被调用的位置帮助恢复上层方法的执行状态;
  5. 附加信息,虚拟机规范中允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,比如调试相关的信息等;

3.1.2、方法调用

方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

虚拟机执行过程中,具有方法调用这一步的原因是由于Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都是符合引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用),因此方法调用的重点在于如何找到方法的入口地址。

JVM提供的5条方法调用指令来调用不同的方法:

  1. invokestatic,调用静态方法,解析调用,由于静态方法,编译器可知,运行期确定,因此在类加载解析阶段,就可以知道方法的调用版本,所以直接将符号引用转成直接引用;
  2. invokespecial,调用实例构造器<init>方法、私有方法和父类方法,同静态方法调用一样,此几类方法都是在解析阶段可以确定调用版本,也直接将符号引用转换成直接引用;
  3. invokevirtual,调用所有的虚方法,其中final修饰的方法,虽然是由invokevirtual指令调用,但是由于版本无法被覆盖(多态选择的结果是肯定的),所以调用也固定,而由于Java语言的多态特性,对于分派是,分有静态单分派、静态多分派、动态单分派、动态多分派;
  4. invokeinterface,调用接口方法,会在运行期再确定一个实现此接口的对象;
  5. invokedynamic,现在运行时动态解析出一个调用点限定符所引用的方法,然后再执行该方法,在此之前的4条指令,分片逻辑是固化在Java虚拟机内部的,而invokedynamic命令的分派逻辑是由用户所设定的引导方法决定。

3.2、虚拟机的字节码执行

在确定调用具体方法后,就需要执行方法中的字节码指令。执行的方式有解释器执行

Java代码经过javac指令完成了代码的词法分析、语法分析、到抽象语法书,然后再遍历抽象语法书生成现象的字节码指令的过程,然后得到了Java编译器输出的指令流,Java的指令流基本上是一种基于栈的指令架构,其中的指令大部分是零地址指令,依赖于操作数栈进行工作。

Java基于栈的指令集与基于寄存器的x86指令集,在1+1的计算过程分别如下:

  1. 基于栈的指令执行过程:
# 两条指令iconst_1,指令连续把两个常量1压入栈后,
# iadd指令把栈顶的两个指令出栈、相加,
# 然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中
iconst_1
iconst_1
iadd
istore_0
  1. 基于x86的指令执行过程:
# mov指令把EAX寄存器的值设置为1,然后add指令再把这个值加1,结果旧报错在EAX寄存器里面
mov eax, 1
add eax, 1

两种指令集对比如下:

  1. 栈指令集可以移植,而寄存器指令集由硬件直接提供,这是Java语言Write Once Run EveryWhere的基础,可以对用屏蔽寄存器底层的使用,让用户更容易写出高效的代码;
  2. 栈指令集代码相对紧凑,字节码的每个字节就对应一条指令,所以遍历起来比较简单,不需要考虑空间分配的问题,以为空间都是基于栈上操作;
  3. 栈指令集的执行速度相对慢一些,虽然栈指令集架构代码紧凑,相对于寄存器指令集,完成相同功能,需要更多的指令,因为多出了一些出栈、入栈的操作等;

4、小结

了解代码底层的设计原理,更有利于我们在实际编写代码时,写出效率更高的代码。