第 8 章:虚拟机字节码执行引擎
本文最后更新于:1 年前
虚拟机字节码执行引擎:
- 虚拟机的执行引擎可以不受物理条件制约
因为是由软件自行实现的
地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的。
- 概念模型
在《Java 虚拟机规范》中制定
:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
运行时栈帧结构:
- Java 虚拟机以方法作为最基本的执行单元,栈帧
存储了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息
是用于支持虚拟机进行方法调用和方法执行背后的数据结构每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程
。 - 一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响
在编译 Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来了,并且写入到方法表的 Code 属性之中
。 - 执行引擎所运行的所有字节码指令都只针对当前栈帧
位于栈顶的栈帧
进行操作。
局部变量表:
- 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
- Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽
局部变量表的容量的最小单位
数量。- 如果访问的是 32 位数据类型的变量,索引 N 就代表了使用第 N 个变量槽。
- 如果访问的是 64 位数据类型的变量,会同时使用第 N 和第 N+1 两个变量槽。
- Java 虚拟机使用局部变量表完成参数值到参数变量列表的传递
实参到形参的传递
。- 如果执行的是实例方法,那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属的对象实例的引用
在方法中可以通过关键字 this 来访问到这个隐含的参数
。
- 如果执行的是实例方法,那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属的对象实例的引用
- 变量槽是可以重用的
所以方法体中定义的变量,其作用域并不一定会覆盖整个方法体
。- 优:节省栈帧耗用的内存空间。
- 【引入问题
影响不大
】在某些情况后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量
下会影响到系统的垃圾收集行为见后面例子
。
【原因】变量槽在被重用前,局部变量表作为 GC Roots 一部分
保持着对它的关联。
【解决问题除了做实验外几乎毫无用处,在经过即时编译优化后几乎一定会被当作无效操作消除掉
】打断关联关系比如重用变量槽,或把不使用的对象手动赋值 null
。
- 局部变量没有赋初始值时不能使用
没有类变量的在准备阶段被赋予系统初始值的过程
,编译器能在编译期间就检查到并提示。
例子:变量槽可被重用的设计,影响了垃圾收集行为。
添加运行参数:
应该能 gc 但是实际没有 gc:
1 |
|
修改之后能 gc:
1 |
|
操作数栈:
- Java 虚拟机的解释执行引擎被称为:基于栈
操作数栈
的执行引擎。 - 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容
也就是出栈和入栈的操作
。 - 在概念模型中,两个不同栈帧
作为不同方法的虚拟机栈的元素
是完全相互独立的。但是在大多数虚拟机的实现里都会进行一些优化处理令两个栈帧出现一部分重叠:一块内存同时作为一个栈帧的部分局部变量表和下一个栈帧的部分操作数栈
。- 优:节约一些空间;无须进行额外的参数复制传递。
动态连接:
每个栈帧都包含一个引用指向运行时常量池中该栈帧所属方法
,持有这个引用是为了支持方法调用过程中的动态连接。
方法返回地址:
- 当一个方法开始执行后,只有两种方式退出这个方法:
- 正常调用完成:遇到任意一个方法返回的字节码指令。
主调方法的 PC 计数器的值就可以作为返回地址栈帧中很可能会保存这个计数器值
。 - 异常调用完成:遇到了异常
并且这个异常没有在方法体内得到妥善处理
。
返回地址要通过异常处理器表来确定栈帧中一般不会保存这部分信息
。
- 正常调用完成:遇到任意一个方法返回的字节码指令。
- 方法退出
等同于把当前栈帧出栈
时可能执行的操作:- 恢复上层方法的局部变量表和操作数栈。
- 把返回值压入调用者栈帧的操作数栈。
- 调整 PC 计数器的值
以指向方法调用指令后面的一条指令
。
栈帧信息:
包括动态连接、方法返回地址、附加信息《Java 虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中
。
方法调用:
- 唯一的任务就是确定被调用方法的版本
未涉及方法内部的具体运行过程
。 - Class 文件的常量池中存有大量的符号引用,方法调用的字节码指令就以常量池中指向方法的符号
所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用
作为参数。
符号引用转化为直接引用有两种情况:- 【静态】解析:在类加载的解析阶段或者第一次使用时转化。
- 【在类加载的解析阶段转化】
- 前提:编译期可知
方法在程序真正运行之前就有一个可确定的调用版本
,运行期不可变方法的调用版本在运行期是不可改变的
。 - 符合前提要求的方法:主要有静态方法
与类型直接关联
和私有方法在外部不可被访问
,各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本。
- 前提:编译期可知
- 【在类加载的解析阶段转化】
- 【动态】连接:在每一次运行期间都转化。
- 【静态】解析:在类加载的解析阶段或者第一次使用时转化。
- 对应不同类型的方法调用的字节码指令:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器 <init>() 方法、私有方法和父类中的方法。
- invokevirtual:调用所有的虚方法
不能在类加载时把符号引用解析为直接引用的方法
。- 【普通方法】解析过程:
- 找到操作数栈顶的第一个元素所指向的对象
方法接收者
的实际类型记作 C
。方法重写的本质:在运行期确定方法接收者的实际类型。 - 如果在 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束。
- 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出异常。
- 找到操作数栈顶的第一个元素所指向的对象
- 【普通方法】解析过程:
- invokeinterface:调用接口方法
会在运行时再确定一个实现该接口的对象
。 - invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
例子:方法重载。
1 |
|
上面的是静态类型,下面的是实际类型:
1 |
|
可能的输出:
- Human 是变量的静态类型
在编译期可知,也叫外观类型
,Man 是变量的实际类型在运行期才可确定,也叫运行时类型
。 - 编译器在重载时是将参数的静态类型作为判定依据。
分派调用:
- 静态分派:在编译期编译器根据静态类型确定方法执行版本的分派过程
最典型应用表现就是方法重载
。- 字面量天生具有模糊性
没有显式的静态类型,静态类型只能通过语言、语法的规则去理解和推断
,会导致重载版本并不是唯一的,只能确定一个相对更合适的版本见后面例子
。
- 字面量天生具有模糊性
- 动态分派:在运行期虚拟机根据实际类型确定方法执行版本的分派过程
最典型应用表现就是方法重写
。- 【存在的问题】频繁搜索类型元数据导致执行性能低。
【解决问题】- 为类型在方法区中建立一个虚方法表
存放着各个方法的实际入口地址。与此对应的,在 invokeinterface 执行时也会用到接口方法表
,使用虚方法表索引为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应该具有一样的索引序号
来代替元数据查找。 - 其他优化技术:类型继承关系分析、守护内联、内联缓存等。
- 为类型在方法区中建立一个虚方法表
- 【存在的问题】频繁搜索类型元数据导致执行性能低。
Java 语言是一门静态多分派、动态单分派的语言见后面例子
。
- 单分派和多分派的划分是根据分派基于的宗量
方法的接收者与方法的参数
个数,对目标方法进行选择。
例子:字面量天生具有模糊性,会导致重载版本并不是唯一的,只能确定一个相对更合适的版本。
1 |
|
依次注释掉重载的方法,会依次打印 char、int、long、Character、Serializable、Object、char...
,变长参数的重载优先级是最低的。
例子:Java 语言是一门静态多分派、动态单分派的语言。
1 |
|
确定方法调用的版本:
- 静态多分派:
- 方法接收者的静态类型是
Father
还是Son
? - 方法参数的静态类型是
QQ
还是360
?
- 方法接收者的静态类型是
- 动态单分派:
- 方法接收者的实际类型是
Father
还是Son
? 方法参数的实际类型不重要,会自动确定一个相对更合适的版本。
- 方法接收者的实际类型是
运行时异常:只要代码不执行到这一行就不会出现问题。
连接时异常:即使导致连接时异常的代码被放在一条根本无法被执行到的路径分支上,类加载时连接过程在类加载阶段
也会抛出异常。
语言类型:
- 静态类型语言:能够在编译期确定变量类型
所以编译器可以提供全面严谨的类型检查
。- Java 语言在编译期就已经将方法完整的符号引用生成出来,并作为方法调用指令的参数存储到 Class 文件中。
- 动态类型语言:在运行期才确定变量类型
所以代码会清晰简洁
。- 特征:
- 类型检查的主体过程是在运行期而不是编译期进行的。
- 变量无类型而变量值才有类型。
- 特征:
【存在的问题】Java 虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方面:
JDK 7 以前的字节码指令集,4 条方法调用指令的第一个参数都是被调用的方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者
。
【解决问题】编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配。
- 【引入问题】
- 增加了内存开销。
- 动态类型方法调用时,方法内联无法有效进行
因为无法确定调用对象的静态类型
。
【解决问题】在 Java 虚拟机层面上提供动态类型的直接支持:JDK 7 加入 invokedynamic 指令和 java.lang.invoke 包。- java.lang.invoke 包
- 方法句柄
MethodHandle
:一种动态确定目标方法的机制,可以单独把一个函数作为参数进行传递。
- 方法句柄
- invokedynamic 指令:用字节码和 Class 中其他属性、常量实现的 MethodHandle 机制。
- 根据第一个参数
CONSTANT_InvokeDynamic_info
提供的信息,可以找到并且执行引导方法,最终调用到目标方法。
- 根据第一个参数
- java.lang.invoke 包
例子:访问祖类的方法。
1 |
|
从源码到解释执行:
- 程序源码转换成物理机的目标代码或虚拟机能执行的指令集的过程
见首图
。- 完整的编译器
比如 C/C++ 语言的
:包括词法分析、语法分析、优化器、目标代码生成器。 - 半独立的编译器
比如 Java 语言的
:包括词法分析、语法分析。- Javac 编译器完成了程序源码经过词法分析、语法分析到抽象语法树,再遍历语法树到生成线性的字节码指令流
基本上是基于栈的指令集架构
的过程。
- Javac 编译器完成了程序源码经过词法分析、语法分析到抽象语法树,再遍历语法树到生成线性的字节码指令流
- 黑匣子
比如大多数的 JavaScript 执行引擎
。
- 完整的编译器
- 指令集架构:
- 基于寄存器的指令集架构:指令依赖寄存器进行工作
每个指令都包含两个独立的输入参数,依赖于寄存器来访问和存储数据
。- 程序直接依赖硬件寄存器则不可避免地要受到硬件的约束。
- 基于栈的指令集架构:零地址指令,依赖操作数栈进行工作
使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中
。- 可移植:不直接依赖硬件寄存器
可以由虚拟机实现来自行决定把一些访问最频繁的数据——如程序计数器、栈顶缓存——放到寄存器,以获取尽量好的性能
。 - 代码相对紧凑
字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数
。 - 编译器实现更加简单
不需要考虑空间分配的问题,所需空间都在栈上操作
。 - 【存在的问题】执行速度
局限在解释执行状态下,经过即时编译器会输出成物理机上的汇编指令流;
稍慢。
在解释执行时,出栈、入栈操作会产生大量的指令,且栈实现在内存中相对于处理器,内存始终是执行速度的瓶颈
。
【优化措施】栈顶缓存:把最常用的操作映射到寄存器中,以避免直接内存访问。
- 可移植:不直接依赖硬件寄存器
- 基于寄存器的指令集架构:指令依赖寄存器进行工作
- Java 虚拟机解释执行字节码指令流的概念模型。
第 8 章:虚拟机字节码执行引擎
https://weichao.io/9cc280d67ba5/