1 运行时栈帧结构
- 栈帧是虚拟机栈的栈元素,存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息
- 每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程;
- 栈帧需要分配多少内存在编译时就完全确定并写入到方法表的 Code 属性之中了,不会受到程序运行期变量数据的影响;
- 对于执行引擎来说,在活动线程中只有位于栈顶的栈帧才算有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
1.1 局部变量表
- 是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,Code 属性的 max_locals 确定了该方法所需要分配的局部变量表的最大容量;
- 其容量以变量槽(Variable Slot)为最小单位,虚拟机规范允许 Slot 的长度随处理器、操作系统或虚拟机的不同而发生变化;
- 一个 Slot 可以存放一个 32 位以内的数据类型,包括 boolean、byte、char。short、int、float、reference 和 returnAddress 这八种类型;对于 64 位的数据类型(long 和 double),虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间;
- Slot 可以被重用,有时候会影响到 GC
1.2 操作数栈
- 也常称为操作栈,它是一个后入先出栈;Code 属性的 max_stacks 确定了其最大深度;
- 比如整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会将这两个 int 值出栈并相加,然后将相加的结果入栈;
- 操作数栈中元素的类型必须与字节码指令的序列严格匹配;
- Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈;
1.3 动态连接
- 每个栈帧都包含一个执行运行时常量池中该栈帧所属方法引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking);
- Class 文件的常量池的符号引用,有一部分在类加载阶段或者第一次使用时就转换为直接引用,这种称为静态解析,而另外一部分在每一次运行期间转换为直接引用,这部分称为动态连接;
1.4 方法返回地址
- 退出方法的方式:正常完成出口和异常完成出口;
- 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能只需的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数中,调整PC计数器的值以只需方法调用指令后面的一套指令等;
1.5 附加信息
- 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分完成取决于具体的虚拟机实现;
2 方法调用
- 方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本即调用哪一个方法,暂时还不涉及方法内部的具体运行过程;
- Class 文件的编译过程中不报警传统编译的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂;
2.1 解析
解析调用是一个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用;而分派调用则可能是静态的也可能是动态的;
- 五条方法调用字节码指令:invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic;
- 非虚方法:静态方法、私有方法、实例构造器、父类方法、final 方法。类装载时静态解析。
2.2 分派
1)静态分派
语句中 Human 称为变量的静态类型,后面的 Man 称为变量的实际类型;
Human man = new Man();
- 静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;
- 而实际类型的变化在运行期才确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么;
所有根据静态类型来定位方法执行版本的分派动作称为静态分派,其典型应用是方法重载;
2)动态分派
invokevirtual 指令执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 Java 语言中方法重写的本质;
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派;
3)单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派分为
- 单分派(根据一个宗量对目标方法进行选择)
- 多分派(根据多于一个宗量对目标方法进行选择)两种;
今天的 Java 语言是一门静态多分派、动态单分派的语言;
4)虚拟机动态分派的实现
在方法区中建立一个虚方法表(Virtual Method Table),使用虚方法表索引来代替元数据查找以提高性能;
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化值后,虚拟机会把该类的方法表也初始化完毕;
2.3 动态类型语言支持
- JDK 1.7 发布增加的 invokedynamic 指令实现了“动态类型语言”支持,也是为 JDK 1.8 顺利实现 Lambda 表达式做技术准备;
- 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译器,比如 JavaScript、Python 等;
- Java 语言在编译期间就将方法完整的符号引用生成出来,作为方法调用指令的参数存储到 Class 文件中;这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息;而在 ECMAScript 等动态语言中,变量本身是没有类型的,变量的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型;变量无类型而变量值才有类型,这个特点也是动态类型语言的一个重要特征;
- JDK 1.7 实现了 JSR-292,新加入的 java.lang.invoke 包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法外,提供一种新的动态确定目标方法的机制,称为 MethodHandle;
- 从本质上讲,Reflection(反射)和 MethodHandle 机制都是在模拟方法调用,但 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用,前者是重量级,而后者是轻量级;另外前者只为 Java 语言服务,后者可服务于所有 Java 虚拟机之上的语言;
- 每一处含有 invokedynamic 指令的位置都称为“动态调用点(Dynamic Call Site)”,这条指令的第一个参数不再是代表符号引用的 CONSTANT_Methodref_info 常量,而是 CONSTANT_InvokeDynamic_info 常量(可以得到引导方法、方法类型和名称);
- invokedynamic 指令与其他 invoke 指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定的;
3 基于栈的字节码解释执行引擎
3.1 解释执行
- 只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才比较确切;
- Java 语言中,javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程;因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现;
3.2 基于栈的指令集与基于寄存器的指令集
- Java 编译器输出的指令集,基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,它们依赖操作数栈进行工作;
- 基于栈的指令集主要的优点是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束;主要缺点是执行速度相对来说会稍慢一点;
3.3 基于栈的解释器执行过程
一段简单的算法代码
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
上述代码的字节码表示
public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0:bipush 100
2:istore_1
3:sipush 200
6:istore_2
7:sipush 300
10:istore_3
11:iload_1
12:iload_2
13:iadd
14:iload_3
15:imul
16:ireturn
}
javap 提示这段代码需要深度为 2 的操作数栈和 4 个 Slot 的局部变量空间,作者根据这些信息画了示意图来说明执行过程中的变化情况:
执行偏移地址为 0 的指令
执行偏移地址为 2 的指令
执行偏移地址为 11 的指令
执行偏移地址为 12 的指令
执行偏移地址为 13 的指令
执行偏移地址为 14 的指令
执行偏移地址为 16 的指令
注:上面的执行过程仅仅是一种概念模型,虚拟机中解析器和即时编译器会对输入的字节码进行优化。