JavaJVM_虚拟机字节码执行引擎
区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机执行引擎是由自己实现的,因此可以
自行制定指令集与执行引擎的结构体系
,并且能够执行那些不被硬件直接支持的指令集格式
。
1. 运行时栈结构
栈帧(Stack Frame)是用于
支持虚拟机进行方法调用和方法执行的数据结构
,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量
、操作数栈
、动态链接
和方法返回地址
等信息。每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此
一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响
,而仅仅取决于具体的虚拟机实现
。一个线程中的方法调用链可能会很长,很多方法都处于执行状态。对于执行引擎来说,
在活动线程中,只有位于栈顶的栈帧才是有效的
,称为当前栈帧
(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令对当前栈帧进行操作,在概念模型上
.1. 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于
存放方法参数和方法内部定义的局部变量
。在 Java 程序中编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。
.2. 操作数栈
操作数栈(Operand Stack)是一个
后进先出栈
。同局部变量表一样,操作数栈的最大深度也在编译阶段写入到 Code 属性的 max_stacks 数据项中
。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值
。一个方法刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。
.3. 动态链接
每个栈帧都
包含一个指向运行时常量池中该栈帧所属方法的引用
,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)
。Class 文件的常量池中存在大量的符号引用
,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数
,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
.4. 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
- 一种是执行引擎遇到
任意一个方法返回的字节码指令
,这时候可能会有返回值传递给上层方法的调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。 - 另一种退出方式是,在
方法执行过程中遇到了异常
,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出
。这种称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。 - 同学习
汇编时候的中断处理;
无论采用何种退出方式,在方法退出后
都需要返回到方法被调用的位置
,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上次方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
.5. 附加信息
虚拟机规范允许具体的虚拟机实现
增加一些规范里没有描述的信息到栈帧中
,例如与调试相关的信息
,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。
2. 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是
确定被调用方法的版本(即调用哪一个方法)
,暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最为普遍、频繁的操作。前面说过Class 文件的编译过程是不包含传统编译中的连接步骤的
,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
.1. 解析
所有方法调用中的目标方法在 Class 文件里都是一个常量池中的符号引用
,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用
,这种解析能成立的前提是方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。话句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来
。这类方法的调用称为解析(Resolution)。
Java 语言中符合「编译器可知,运行期不可变」这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问
,这两种方法各自的特点决定了它们都不可能通过继承或者别的方式重写其它版本,因此它们都适合在类加载阶段解析。
与之相应的是,在 Java 虚拟机里提供了 5 条方法调用字节码指令,分别是:
invokestatic:调用静态方法;
invokespecial:调用实例构造器 方法、私有方法和父类方法;
invokevirtual:调用所有虚方法;
invokeinterface:调用接口方法
,会在运行时再确定一个实现此接口的对象;invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在加载的时候就会把符号引用解析为直接引用。这些方法可以称为非虚方法,与之相反,其它方法称为虚方法(final 方法除外)。
Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无需对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法
。
解析调用一定是个静态过程,在编译期间就能完全确定
,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成
。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况.
.2. 分派
面向对象有三个基本特征,封装、继承和多态。这里要说的分派将会揭示多态特征的一些最基本的体现,如「重载」和「重写」在 Java 虚拟机中是如何实现的?虚拟机是如何确定正确目标方法的?
.1. 静态分派
依赖静态类型(又称外观类型)来定位方法执行版本的分派动作
,称为静态分派。静态分派的典型应用是方法重载
。
静态类型是编译期可知的
。静态分派发生在编译阶段,因此确定静态分派的动作不是由虚拟机来执行的。
.2. 动态分派
在运行期根据实际类型
确定方法执行版本的分派过程,称为动态分派。动态分派的典型应用是方法重写。
实际类型是在运行期才可确定。动态分派是非常频繁的动作,而且运行时需要在类的方法元数据中搜索合适的目标方法。基于性能的考虑,大部分的虚拟机实现都不会真正地进行如此频繁的搜索。最常用的“稳定优化”手段是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能
。虚方法表中存放着各个方法的实际入口地址。
.3. 单分派与多分派
根据分派基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
方法的接收者与方法的参数统称为方法的宗量
。
-
静态分派是根据方法
接收者的静态类型和方法参数来选择目标方法的
,因此静态分派属于多分派类型。 -
动态分派
只根据方法接收者的实例类型来选择目标方法
,因此动态分派属于单分派类型。
.3. 基于栈的字节码解释执行引擎
.1. 解释执行
无论是解释执行还是编译执行,无论是物理机还是虚拟机,对于应用程序,机器都不可能像人一样阅读、理解,然后获得执行能力。大部分的程序代码到物理机的目标代码或者虚拟机执行的指令之前,都需要经过下图中的各个步骤。下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支,则是解释执行的过程。
如今,基于物理机、Java 虚拟机或者非 Java 的其它高级语言虚拟机的语言,大多都会遵循这种基于现代编译原理的思路,在执行前先对程序源代码进行词法分析和语法分析处理,把源代码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++。也可以为一个半独立的编译器,这类代表是 Java。又或者把这些步骤和执行全部封装在一个封闭的黑匣子中,如大多数的 JavaScript 执行器。
Java 语言中,
Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树、再遍历语法树生成字节码指令流的过程
。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。而对于最新的 Android 版本的执行模式则是 AOT + JIT + 解释执行,关于这方面我们后面有机会再聊。
.2. 基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构
,指令流中的指令大部分是零地址指令
,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二地址指令集,这些指令依赖寄存器进行工作。
- 基于
栈的指令集
:可移植,但执行速度相对较慢
。 - 基于
寄存器的指令集
:执行速度快
,但由于寄存器由硬件直接提供,程序不可避免要受到硬件的约束。