第8章 虚拟机字节码执行引擎
8.1 概述
执行引擎是Java虚拟机最核心的组成部分之一。"虚拟机"是一个相对于"物理机"的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
8.2 运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
8.2.1 局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型都可以使用32位或更小的物理内存来存放。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的64位的数据类型只有long和double两种。值得一提的是,这里把long和double数据类型分割存储的做法与"long和double的非原子性协定"中把一次long和double数据类型读写分割为两次32位读写的做法有些类似。
8.2.2 操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
8.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
8.2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
正常调用完成(Normal Method Invocation Completion):执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常调用完成。
异常调用完成(Abrupt Method Invocation Completion):在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常调用完成。一个方法使用异常调用完成的方式退出,是不会给它的上层调用者提供任何返回值的。
8.2.5 附加信息
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。
8.3 方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一。
Class文件的编译过程中不包含传统程序编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。
在Java语言中符合"编译期可知,运行期不可变"这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字节码指令:
- invokestatic:用于调用静态方法。
- invokespecial:用于调用实例构造器
<init>()方法、私有方法和父类中的方法。 - invokevirtual:用于调用所有的虚方法。
- invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
8.3.2 分派
分派(Dispatch)调用可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。
1. 静态分派
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载(Overload)。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入"解析"而不是"分派"的原因。
2. 动态分派
动态分派的实现过程与静态分派有着很大差别,它不是在编译期确定的,而是在运行期根据实际类型确定方法执行版本。动态分派的典型应用是方法重写(Override)。
动态分派的实现过程,与使用 invokevirtual 指令的多态查找过程密切相关,invokevirtual 指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
3. 单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
8.4 动态类型语言支持
8.4.1 动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Scheme、Smalltalk等等。那相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。
8.4.2 Java与动态类型
Java虚拟机层面对动态类型语言的支持一直都存在,但在JDK 7以前的版本中,主要靠字节码指令invokespecial、invokevirtual、invokeinterface和invokestatic来完成方法调用,这些指令的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用"曲线救国"的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,但这样势必让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销。
8.4.3 java.lang.invoke包
JDK 7时新加入的java.lang.invoke包是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为MethodHandle。
MethodHandle与反射相比,有以下几个区别:
访问权限不同:反射是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
性能差异:反射中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息要多。前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
虚拟机优化程度不同:由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。
8.4.4 invokedynamic指令
invokedynamic是JDK 7时新加入的一条字节码指令,用以支持动态类型语言的方法调用。它与MethodHandle机制的作用是一样的,都是为了解决原有4条"invoke*"指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他程序语言的设计者)有更高的自由度。
invokedynamic指令与MethodHandle机制的作用类似,但invokedynamic指令比MethodHandle更加灵活和强大。
8.4.5 实战:掌控方法分派规则
invokedynamic指令与前面4条"invoke*"指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。在Java程序中,我们可以通过"方法句柄"(Method Handle)来实现类似的功能。
8.5 基于栈的字节码解释执行引擎
8.5.1 解释执行
Java语言经常被人们定位为"解释执行"的语言,在Java初生的JDK 1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事。再后来,Java也发展出可以直接生成本地代码的编译器(如GCJ和Excelsior JET),而C/C++语言也出现了通过解释器执行的版本(如Ch),这时候再笼统地说"解释执行",对于整个Java语言来说就成了几乎是没有意义的概念。
8.5.2 基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄存器进行工作。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU(在当前的手机、PDA中相当流行的一种处理器)则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。
8.5.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
}执行过程:
- 首先执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是100。
- 执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量槽中。
- 后续指令依次执行,直到偏移地址为11的指令,iload_1指令的作用是将局部变量表第1个Slot中的整型值复制到操作数栈顶。
- 偏移地址为12的iload_2指令执行过程与iload_1类似,把第2个Slot的整型值入栈。
- 偏移地址为13的iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。
- 偏移地址为14的iload_3指令把存放在第3个局部变量槽中的300入栈到操作数栈中。
- 偏移地址为15的imul指令是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,与iadd完全类似。
- 偏移地址为16的ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。
8.6 本章小结
本章主要介绍了虚拟机字节码执行引擎的基本概念和执行过程。执行引擎是Java虚拟机最核心的组成部分之一,它负责执行字节码指令。虚拟机采用基于栈的架构,通过栈帧来支持方法的调用和执行。
方法调用是执行引擎的重要功能,包括解析和分派两个阶段。解析是在编译期确定方法调用的直接引用,而分派则根据静态类型或实际类型来确定方法的执行版本。静态分派用于方法重载,动态分派用于方法重写。
JDK 7引入的invokedynamic指令和java.lang.invoke包为Java提供了对动态类型语言的支持,使得Java虚拟机能够更好地支持其他动态类型语言,并提供了更灵活的方法调用机制。
理解字节码执行引擎的工作原理,对于深入理解Java程序的运行机制、进行性能优化和故障排查都具有重要意义。