文章目录
- [第8章 虚拟机字节码执行引擎](#第8章 虚拟机字节码执行引擎)
-
- [8.0 个人感悟](#8.0 个人感悟)
- [8.1 概述](#8.1 概述)
- [8.2 运行时栈帧结构](#8.2 运行时栈帧结构)
-
- [8.2.1 局部变量表](#8.2.1 局部变量表)
- [8.2.2 操作数栈](#8.2.2 操作数栈)
- [8.2.3 动态连接](#8.2.3 动态连接)
- [8.2.4 方法返回地址](#8.2.4 方法返回地址)
- [8.2.5 附加信息](#8.2.5 附加信息)
- [8.3 方法调用](#8.3 方法调用)
-
- [8.3.1 方法解析](#8.3.1 方法解析)
- [8.3.2 方法分派](#8.3.2 方法分派)
- [8.3.3 方法调用字节码指令](#8.3.3 方法调用字节码指令)
- [8.4 动态类型语言支持](#8.4 动态类型语言支持)
-
- [8.4.1 invokedynamic指令](#8.4.1 invokedynamic指令)
- [8.4.2 java.lang.invoke包与方法句柄](#8.4.2 java.lang.invoke包与方法句柄)
- [8.5 基于栈的字节码解释执行引擎](#8.5 基于栈的字节码解释执行引擎)
-
- [8.5.1 解释执行与编译执行](#8.5.1 解释执行与编译执行)
- [8.5.2 基于栈的指令集 vs 基于寄存器的指令集](#8.5.2 基于栈的指令集 vs 基于寄存器的指令集)
- [8.5.3 基于栈的解释器执行过程](#8.5.3 基于栈的解释器执行过程)
第8章 虚拟机字节码执行引擎
8.0 个人感悟
0. 知识、技术的目的是解决问题,而不是。。。 书中这句话非常有共鸣!"演示所用的这段程序无疑是属于很极端的例子,除了用作面试题为难求职者之外,在实际工作中几乎不可能存在任何有价值的用途。。"
1. 栈帧是理解方法执行的核心。 以前写方法调用时只觉得"调用就调用了",从没想过背后还一整套精密的数据结构在支撑。理解栈帧后,对递归深度限制、局部变量作用域、方法参数传递这些日常问题都有了更底层的认识。可以看看之前写的设计模式学习(22) 23-20 解释器模式的代码,对理解出入栈很有帮助。
2. 关于静态分派与动态分派的再理解。 重载在编译期依据静态类型确定版本(静态分派),重写则在运行期依据实际类型确定版本(动态分派)。上一篇博客已详细剖析过它们底层的执行原理,而这次阅读又催生了新的感悟:设计决定了实现。过去总习惯追问"是什么"和"怎么做到的",却很少思考"为什么这样设计"。这往往导致脑中只存留了八股式的结论,而非真正透彻的理解。相比一头扎进技术实现细节,或许多关注技术背后设计的目的与权衡,才是更深刻的成长路径。
3. 基于栈的指令集的意义。 寄存器架构虽然快,但和硬件强绑定;栈架构虽然指令数量多、执行速度稍慢,却换来了极致的可移植性。加上JIT编译器弥补解释执行的性能短板。
4. invokedynamic指令的作用。 最初JVM是为Java语言量身定做的,方法调用的分派逻辑被固化在虚拟机内部。invokedynamic将分派逻辑的控制权交给用户,这种设计既保持了核心稳定,又给了生态足够的扩展空间。和上一章提到的模块化一样,一个语言的发展需要不断迎接挑战、适应变化。人也一样。
8.1 概述
执行引擎是Java虚拟机最核心的组成部分之一。"虚拟机"是一个相对于"物理机"的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎直接建立在处理器、缓存、指令集和操作系统层面上,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发行商的Java虚拟机执行引擎的统一外观(Facade) 。
在不同的虚拟机实现中,执行引擎在执行字节码时,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至可能同时包含几个不同级别的即时编译器一起工作。
但从外观上看,所有Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
8.2 运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,栈帧(Stack Frame) 则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译Java程序源码时,栈帧中需要多大的局部变量表、需要多深的操作数栈就已经被分析计算出来,并写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧(Current Stack Frame) ,与这个栈帧所关联的方法被称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
8.2.1 局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数 和方法内部定义的局部变量 。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
变量槽(Variable Slot) 是局部变量表的最小单位。《Java虚拟机规范》中并没有明确规定一个变量槽应占用的内存空间大小,只要求每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据(32位及以下)。对于long和double这两种64位数据类型,则以高位对齐的方式占用两个连续的变量槽。
重要特性:
- 虚拟机通过索引定位的方式使用局部变量表,索引值从0开始。
- 对于实例方法 (非static),局部变量表中第0位索引的变量槽默认用于传递方法所属对象实例的引用(
this),其余参数按参数表顺序从索引1开始分配。 - 变量槽可重用:如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用,以节省栈空间。这种重用在某些情况下会影响垃圾收集行为------如果Slot中还持有对象引用但未被复用,该对象就无法被回收。
- 局部变量没有准备阶段,如果定义了但没有赋初始值,完全不能使用------编译器在编译期间就能检查出来。
8.2.2 操作数栈
操作数栈(Operand Stack)也称为操作栈,是一个后入先出(LIFO) 的栈。同局部变量表一样,操作数栈的最大深度在编译时被写入到Code属性的max_stacks数据项中。在方法执行的任何时候,操作数栈的深度都不会超过max_stacks中设定的最大值。
当一个方法刚刚开始执行时,这个方法的操作数栈是空的。在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期验证,并在类加载阶段的字节码验证阶段再次确认。
优化处理 :
在概念模型中,两个不同方法的栈帧是完全相互独立的。但在大多数虚拟机的实现里,都会进行优化处理,令两个栈帧出现一部分重叠------让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起。这样做不仅节约了空间,更重要的是在进行方法调用时可以直接共用一部分数据,无须进行额外的参数复制传递。
8.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用:
- 一部分会在类加载阶段或第一次使用时被转化为直接引用,这种转化称为静态解析;
- 另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
8.2.4 方法返回地址
当一个方法开始执行后,有两种方式退出这个方法:
正常调用完成(Normal Method Invocation Completion) :执行引擎遇到任意一个方法返回的字节码指令(如ireturn、lreturn、areturn、return等),此时可能会有返回值传递给上层调用者。调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。
异常调用完成(Abrupt Method Invocation Completion) :在方法执行过程中遇到了异常,且这个异常没有在方法体内得到处理。这种退出方式不会给上层调用者提供任何返回值,返回地址需要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
方法退出后需要完成以下操作:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
8.2.5 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能监控相关的信息。
8.3 方法调用
方法调用阶段唯一的目的就是确定被调用方法的版本(即调用哪一个方法),暂时不涉及方法内部的具体运行过程。
一切方法调用在Class文件中存储的都是符号引用,而不是方法在内存中的实际入口地址。这个特性给Java带来了强大的动态扩展能力。
8.3.1 方法解析
解析(Resolution) 调用是指在类加载的解析阶段,将一部分符号引用转化为直接引用。这仅适用于**"编译器可知,运行期不可变"** 的方法------即目标方法在编译期就能唯一确定,运行期间不会发生变化。
符合解析调用条件的方法称为非虚方法(Non-Virtual Method),主要包括:
- 静态方法(static)
- 私有方法(private)
- 实例构造器(
<init>) - 父类方法(通过super调用)
- 被final修饰的方法
这些方法在类加载阶段就会将符号引用解析为直接引用。
8.3.2 方法分派
分派(Dispatch) 调用是Java实现多态性的核心机制。分派分为静态分派和动态分派,同时还可以根据宗量数(影响方法选择的因素数量,如方法的接收者和参数)分为单分派和多分派。
(1)静态类型与实际类型
理解分派前,先区分两个概念:
java
Human man = new Man();
// Human 是静态类型(Static Type),编译期可知
// Man 是实际类型(Actual Type),运行期可知
静态类型在编译期就已确定,而实际类型要到运行期才能确定。
(2)静态分派(方法重载)
静态分派 是指依赖静态类型来确定方法执行版本的分派动作,典型应用是方法重载(Overload)。静态分派发生在编译阶段,由编译器根据参数的静态类型选择对应的重载版本。
代码示例:
java
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
StaticDispatch sr = new StaticDispatch();
Human man = new Man();
Human woman = new Woman();
sr.sayHello(man); // 输出: hello,guy!
sr.sayHello(woman); // 输出: hello,guy!
}
}
输出:
hello,guy!
hello,guy!
编译期只看到变量man和woman的静态类型是Human,因此两个调用都选择了参数为Human的版本。
(3)动态分派(方法重写)
动态分派 是指依赖实际类型来确定方法执行版本的分派动作,典型应用是方法重写(Override)。动态分派发生在运行期。
核心机制 :JVM通过invokevirtual指令实现动态分派。执行该指令时,JVM会找到操作数栈顶第一个元素所指向的对象的实际类型,在该类型的方法表中查找目标方法并执行。
(4)单分派与多分派
方法的接收者 和参数 统称为方法的宗量。根据分派时考察的宗量数量,可分为:
| 类型 | 考察的宗量 | 说明 |
|---|---|---|
| 静态多分派 | 接收者的静态类型 + 参数的静态类型 | 编译期确定重载方法时,同时考察调用者的静态类型和参数的静态类型(多个宗量) |
| 动态单分派 | 接收者的实际类型 | 运行期确定重写方法时,只考察接收者的实际类型(一个宗量) |
因此,Java语言的方法分派可以总结为:静态多分派,动态单分派。
(5)动态分派的优化实现------虚方法表
由于动态分派需要频繁在方法元数据中搜索合适的目标方法,为了提升性能,JVM采用了虚方法表(Virtual Method Table,vtable) 的优化手段。
虚方法表在类加载的连接阶段被初始化,存储了各个方法的实际入口地址。如果子类没有重写父类方法,则子类虚方法表中的对应条目指向父类方法的入口地址;如果子类重写了父类方法,则指向子类方法的入口地址。通过虚方法表,JVM可以快速定位到正确的目标方法。
8.3.3 方法调用字节码指令
JVM提供了五条方法调用字节码指令:
| 指令 | 调用目标 | 绑定方式 |
|---|---|---|
invokestatic |
静态方法 | 解析调用 |
invokespecial |
实例构造器、私有方法、父类方法 | 解析调用 |
invokevirtual |
虚方法 | 动态分派 |
invokeinterface |
接口方法 | 动态分派 |
invokedynamic |
动态语言调用 | 用户定义分派逻辑 |
8.4 动态类型语言支持
8.4.1 invokedynamic指令
invokedynamic是Java 7引入的一条新指令,用以支持动态类型语言的方法调用。它将调用点(CallSite) 抽象成一个Java类,并将原本由JVM控制的方法调用及链接逻辑暴露给应用程序。
在第一次执行invokedynamic指令时,JVM会调用该指令对应的启动方法(Bootstrap Method),生成调用点并绑定。之后的运行过程中,JVM直接调用绑定的调用点所链接的方法句柄。
invokedynamic与前四条指令的本质区别在于:前四条指令的分派逻辑固化在JVM内部,而invokedynamic的分派逻辑由用户通过启动方法指定。
该指令不仅为JVM上的动态语言(如JRuby、Groovy)提供了原生支持,也是Java 8中Lambda表达式实现的关键技术基础。
8.4.2 java.lang.invoke包与方法句柄
JDK 7新增的java.lang.invoke包提供了方法句柄(MethodHandle) 机制,它是实现invokedynamic的基础设施。
MethodHandle vs Reflection:
- Reflection是Java最早提供的动态方法调用机制,但模拟的是Java代码层面的方法调用,包含大量的包装和解包操作。
- MethodHandle模拟的是字节码层面的方法调用,更贴近JVM的底层行为,性能更好。
- MethodHandle在签名中明确区分了调用者(
MethodHandles.Lookup),访问权限检查在创建时完成,调用时不再检查,效率更高。
8.5 基于栈的字节码解释执行引擎
8.5.1 解释执行与编译执行
Java虚拟机的执行引擎在执行Java代码时,有解释执行 (通过解释器执行)和编译执行 (通过即时编译器产生本地代码执行)两种选择。现代主流Java虚拟机(如HotSpot)普遍采用解释器和编译器并存的混合执行模式。
解释执行的优势在于快速启动、低内存占用、实现简单 ;即时编译的优势在于执行效率高。混合模式兼顾了启动速度与运行效率。
8.5.2 基于栈的指令集 vs 基于寄存器的指令集
基于栈的指令集以可移植性为核心优势,但执行速度相对较慢;基于寄存器的指令集执行效率高,但硬件绑定性强。
8.5.3 基于栈的解释器执行过程
以简单的加法运算为例,说明基于栈的解释器执行过程:
java
int a = 2 + 3;
对应的字节码执行流程:
iconst_2:将常量2压入操作数栈iconst_3:将常量3压入操作数栈iadd:弹出栈顶两个整数,相加后将结果5压回栈顶istore_1:将栈顶结果5存入局部变量表索引1的位置
整个计算过程完全围绕操作数栈进行------操作数栈既是数据来源,也是结果存放地,中间结果天然保存在栈中,无需显式管理寄存器。