第 8 章 虚拟机字节码执行引擎
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步
8.1 概述
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎則是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
各种虚拟机执行引擎要有统一的外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
8.2 运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。每个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并写入到方法表的Code属性中。
一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法称为“当前方法”。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作
8.2.1 局部变量表
局部变量表是一组变量值的存储空间,用来存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals
数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽作为最小单位,每个变量槽都应该能存放一个boolean
、byte
、char
、short
、int
、float
、reference
或returnAddress
类型的数据。第七种reference
类型表示对一个对象实例的引用。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是根据引用直接或间接查找到对象在Java堆中的数据存放的起始地址或索引;二是根据引用直接或间接查找到对象所属数据类型在方法区中的存储的类型信息。第八种目前已经很少见,基本弃用了。而long
和double
类型一般需要两个变量槽。不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
Java虚拟机采用索引定位的方式使用局部变量表。当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法,那么第0位索引默认为this
。参数则按照参数表顺序排列,占用从1开始的局部变量槽。紧接着是方法内部定义的局部变量。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用
此外,局部变量不像类静态变量一样存在“准备阶段”。类变量在准备阶段会被赋予系统初值。但是如果一个局部变量定义了但没有赋予初始值,那它是完全不能使用的,所以不要认为Java中任何情况下都存在诸如整形变量默认为0、boolean
型默认为false
这样的默认值规则。
8.2.2 操作数栈
操作数栈是一个后入先出(Last In first Out, LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks
数据项中。操作数栈的每一个元素可以是任意的Java数据类型
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和人栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。举个例子,整数加法的字节码指令iadd
在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int
型的数值,当执行这个指令时,会将这两个int
值出栈并相加,然后将相加的结果人栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd
指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int
型,不能出现一个long
和一个float
使用iadd
命令相加的情况
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递,如下图。
8.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
8.2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者这种退出方法的方式称为正常调用完成
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow
字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常调用完成(Abrupt Method Invocation Completion)。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时执行的操作有
- 恢复上层方法的局部变量表和操作数栈
- 把返回值压入调用者栈帧的操作数栈中
- 调整PC计数器的值以指向方法调用指令后面的一条指令等
8.2.5 附加信息
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息
8.3 方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪个方法),暂时还未涉及方法内部的具体运行过程。
Java语言具有很强的动态扩展能力的同时,Java方法调用过程也相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
解析是指在类加载阶段就将方法的符号引用转化为了直接引用。但是这种方法必须在真正运行之前有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译的那一刻就已经确定了,不必延迟到运行期确定。
调用不同类型的方法在字节码中要使用不同的指令
invokestatic
:调用静态方法invokespecial
:调用实例构造器<init>()
方法、私有方法和父类中的方法invokevirtual
:调用所有的虚方法invokeinterface
:调用接口方法,会在运行时再确定一个实现该接口的对象invokedynamic
:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
符合“编译期可知,运行期不可变”这个要求的方法,也就是只要能被invokestatic
和invokespecial
指令调用的方法,都会在类加载时候将符号引用解析为直接引用。例如:静态方法、私有方法、实例构造器、父类方法以及被final
修饰的方法。它们统称为__非虚方法__;其他方法都是__虚方法__。
8.3.2 分派
分派调用主要与面向对象特征的多态有关。
8.3.2.1 静态分派
首先我们先来理解静态类型和动态类型的概念
假设有如下代码的三个类
1 | public class Human{} |
这时,在主方法中写
1 | Human man = new Man(); |
Human
称为变量的静态类型,或者叫“外观类型”;而Man
称为变量的“实际类型”或者叫“运行时类型”。静态类型在编译期是可以确定的,而动态类型需要在运行时才可以确定,例如Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
。在编译期间我们无法确定human
的实际类型。
我们下面举一个重载的例子
1 | public class Dispatch{ |
运行结果
1 | hello, human |
虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段, Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)
作为调用目标,并把这个方法的符号引用写到main()
方法里的两条 invokevirtual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
但是静态分派的结果,也不一定是确定的。我们再来看一个例子
1 | public class Dispatch{ |
这个时候,静态分派还可以执行吗?当然,只不过由于没有以Man
和Woman
为参数类型的sayHello()
方法,所以这里会进行隐式的类型转换,编译器会选择sayHello(Human)
作为调用目标。
8.3.2.2 动态分派
我们再举一个典型的重写的例子
1 | public class Human(){ |
运行结果
1 | man say hello |
从重写的理论上,出现这种情况是因为两个变量的实际问题不同。那么我们如何在虚拟机的层面上理解这件事呢?
首先先看main
函数经过javac编译后的字节码,前面15行主要是初始化过程。而17行和21行分别是两个sayHello()
的调用。但从字节码的角度来看,这两行无论是指令(都是invokevirtual
)还是参数(都是#22
)都完全一样,但是为什么最终实际执行的方法不一样呢?我们下面来研究下invokevirtual
指令
invokevirtual
指令的运行解析大致分为以下几步
- 找到操作数栈顶的第一个元素所指向的对象的__实际类型__,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回
java.lang IllegalAccessError
异常。 - 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常。
由于 invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
8.3.2.3 单分派和多分派
方法的接收者(obj.fun()
中的obj
称为接收者)与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
Java是一种静态多分派、动态单分派的语言,也就是说:Java中静态分派可以有多分派,但是动态分派只能是单分派。下面我们举个例子来理解这句话
1 | public class QQ{} |
运行结果
1 | father choose 360 |
我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是 Father
还是Son
,二是方法参数是QQ
还是360
.这次选择结果的最终产物是产生了两条invokevirtual
指令,两条指令的参数分别为常量池中指向Father::hardChoice(360)
及Father::hardChoice(QQ)
方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型
再看看运行阶段的虚拟机的选择,也就是动态分派过程。唯一能够影响虚拟机选择的因素只有该方法的接受者的实际类型是Father
还是Son
。因为只有一个宗量作为选择依据,所以Java语言的动态分派是单分派类型。
8.3.2.4 虚拟机动态分派的实现
动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法。但是Java虚拟机基于执行性能的考虑,真正运行时不会那么频繁的搜索类型元数据。而是采用虚方法表
虚方法表中在放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址将会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有同样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,除了准备类的变量初始值之外,虚拟机也会把该类的虚方法表也初始化完毕。
8.4 动态类型语言支持
invokedynamic
指令,是二十多年来唯一增加的字节码指令集。它是为了JDK7的项目要求:实现动态类型(Dynamically Typed Language)支持而进行的改进之一,也是为JDK8里可以顺利实现Lambda表达式而做的技术储备。
8.4.1 动态类型语言
什么是动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:JavaScript、Lua、PHP、Python、Ruby等。相对的,在编译期就进行类型检查过程的语言(如C++和Java等)就是最常用的静态类型语言。
“变量无类型而变量值才有类型”是动态语言的一个核心特征。
静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型言中需用大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。
8.4.2 Java与动态类型
早在1997年出版的《Java虚拟机规范》中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”。而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如 Clojure、 Groovy、 Jython和 JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这是一件很美妙的事情。
但遗憾的是,Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK1.7以前的字节码指令集中,4条方法调用指令( invokevirtual、 invokespecial invokestatic, invokeinterface)的第一个参数都是被调用的方法的符号引用,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他一些消耗性能的方式。
为了保障性能,动态类型方法调用的问题放在Java虚拟机层面上解决是最合适的,因此jdk7中新添加了invokedynamic
指令来提供字节码层面的动态类型方法调用,以及java.lang.invoke
包来提供用户代码层面上的动态类型方法调用
8.4.3 java.lang.invoke包
java.lang.invoke
包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为Methodhandle
(方法句柄)
举例如下
1 | package Default; |
8.4.4 invokedynamic指令
这个指令主要是为了JVM可以为其他动态语言提供支持
在某种程度上, invokedynamic指令与 Methodhandle机制的作用是一样的,都是为了解决原有4条invoke
指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层Java代码和APl来实现,另一个用字节码和Class中其他属性、常量来完成。
8.5 基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本章中,我们首先讨论解释执行
8.5.1 解释执行
Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后, Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。
大多数程序代码转化为物理机的目标代码或虚拟机能执行的指令集之前,需要经过下面的步骤
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机内部执行。
8.5.2 基于栈的指令集与基于寄存器的指令集
Java编译器输岀的指令流,基本上是一种基于栈的指令集架构( Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,也就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
下面分别使用这两种指令集进行1+1
的计算
首先是基于栈的
1 | iconst_1 |
首先iconst
将两个常量1压入栈,iadd
把栈顶两元素出栈、相加,再把结果压入栈中,最后istore
把栈顶的值放到局部变量表的第0个变量槽。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的计算结果也存储在操作数栈之中。
而如果使用基于寄存器的指令集
1 | mov eax, 1 |
mov
指令把EAX寄存器的值设为1,然后add
指令再把这个值加1,结果就保存在EAX寄存器里面。每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。使用栈架构的指令集,用户程序不会直接使用寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能。此外,代码相对更加紧凑(基本基本不包含操作数)且编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)。
而栈架构指令集的主要缺点就是理论上执行速度慢一些。
8.5.3 基于栈的解释器执行过程
下面举例说明基于栈的执行过程。
首先给出一段简单的Java代码
1 | public int calc(){ |
这段代码的字节码指令如下,javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间
1 | public int calc(); |
下面根据图片演示这段字节码在解释器中如何执行
但是上面的执行过程只是概念模型,实际上Java解释器会对字节码进行优化,不会按照上述图示逐条执行。