虚拟机字节码执行引擎(八)

时间:2022-12-24 14:37:59

代码编译的结果是从本地机器码转变为字节码,是存储格式发展的一小步,确实编程语言发展的一大步。

8.1 概述

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

在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。从外观来看,所有Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

8.2 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

每个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经被完全确定了。并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于一个执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

8.2.1 局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,知识很有“导向性”地说明每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,它允许Slot长度随着处理器、操作系统或虚拟机的不同发生变化。

局部变量表建立在线程堆栈上,是线程私有的数据。虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量。如果是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型变量,则说明使用第n和第n+1两个Slot。

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

8.2.2 操作数栈

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

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

举个例子,整数加法的字节码指令iadd在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序时,编译器要严格保证这一点。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

8.2.3 动态连接

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

8.2.4 方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invication Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内部得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的以藏处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

8.3 方法的调用

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

8.3.1 解析

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

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

与之相对应,在Java虚拟机里面提供了四条方法调用字节码指令,分别是:

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

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这条件的有静态方法、私有方法、实例构造器和父类方法四类,它们在类加载的时候会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法就称为虚方法(除去final方法)。

Java中的非虚方法除了使用incokestatic和invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方发接收者进行多台选择,又或者说多肽选择的结果肯定是唯一的。Java语言鬼法中明确说明了final方法是一种非虚方法。

解析调用一定是个静态的过程,在编译期就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派情况。

8.3.2 分派

Java具备面向对象的三个基本特征:继承、封装和多态。分派调用过程将会揭示多态性特征的一些最基本的体现(如“重载”和“重写”)。

1.静态分派

Human man = new Man();

我们把“Human”称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终静态类型是在编译器可知的;实际类型变化的结果在运行期才可确定,编译器在编译程序的时候不知道一个对象的实际类型是什么。虚拟机(准确的讲是编译器)在重载时时通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

2.动态分派

动态分派,它和多态性的另外一个重要体现—重写(Override)有着很密切的关联。

3.单分派与多分派

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

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

Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。

8.4.1 解释执行

虚拟机字节码执行引擎(八)
大部分程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都要经过上面图中的各个步骤,图中下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间那条分支自然就是解释执行的过程。

如今,基于物理机、Java虚拟机或者是非Java的其他高级语言虚拟机的语言,大多都遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(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

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

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

mov eax,1
add eax,1

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

  • 基于栈的指令集最主要有点是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免受到硬件的约束。
  • 栈架构指令集主要缺点是执行速度相对稍慢。
  • 栈架构指令集的代码虽然紧凑,但是完成相同功能所需指令数量一般会比寄存器架构多,因为出栈、入栈操作本身也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。

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

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

使用javap命令看看它的字节码指令:
虚拟机字节码执行引擎(八)

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间。

首先,执行偏移地址为0的指令,bipush指令的作用是将单字节的整型常量值(-128–127)推入操作数栈顶,后跟一个参数,指明推送的常量值,这里是100.

执行编译地址为1的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第一个局部变量Slot中。后续四条指令(直到偏移为11的指令为止)都是做同样的事。

虚拟机字节码执行引擎(八)

虚拟机字节码执行引擎(八)

执行偏移地址为11的指令,iload_1指令的作用是将局部变量表第一个Slot的整型复制到操作数栈顶。执行偏移地址为12的指令,iload2指令可iload1类似。
虚拟机字节码执行引擎(八)

执行偏移地址为13的指令,iadd指令作用是将操作数栈中前两个栈顶元素出栈,做加法,然后把结果重新入栈。在iadd执行完毕后,栈中原有的100和200出栈,他们的和300重新入栈。
虚拟机字节码执行引擎(八)

执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量Slot中的300入栈到操作数栈中。
虚拟机字节码执行引擎(八)

下一条指令imul是将操作数栈中前两个栈顶元素出栈,做整型惩罚,然后把结果重新入栈,这里与iadd完全类似。

偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。至此,这段方法执行结束。
虚拟机字节码执行引擎(八)