字节码执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是相对于物理机的概念,两者都有代码执行能力。不同的是物理机的执行引擎直接建立在物理硬件和操作系统层面上,而虚拟机的执行引擎
字节码执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是相对于物理机的概念,两者都有代码执行能力。不同的是物理机的执行引擎直接建立在物理硬件和操作系统层面上,而虚拟机的执行引擎则有自己的指令集,可以执行不被硬件直接支持的指令
Java虚拟机规范中制定了虚拟机执行引擎的概念模型,不同的虚拟机只要满足这个概念模型的要求(输入字节码,处理过程是字节码解析的等效过程,输出执行结果),具体的实现可以自行制定(解释执行、编译执行或者两者结合等)
运行时栈帧结构
栈帧是支持方法调用和执行的数据结构,是虚拟机栈中的元素。方法的调用伴随着栈帧在虚拟机栈中的入栈和出栈。栈帧存储了方法的局部变量表、操作数栈、动态链接和返回地址等信息(关于Java内存区域方面的内容,可以参见本系列文章第一篇:Java内存区域)
在编译期,栈帧中需要多大的局部变量表和多深的操作数栈都是可以确定的,因此栈帧需要多大的内存,只与具体的虚拟机实现有关,而不受运行时影响
下面对局部变量表、操作数栈、动态链接和返回地址做下简单介绍:
1.局部变量表
局部变量表是一组变量存储空间,方法参数和局部变量都存储在局部变量表里面
slot是虚拟机为局部变量分配内存的最小单元。对于32位的数据类型(byte、char、short、int、float、boolean、returnAddress),每个局部变量占用一个slot,而对于64位的数据类型(long、double)则需要占用两个slot,而reference类型可能是32位也可能是64位
虚拟机通过索引的方式使用局部变量表(索引值从0开始)。如果访问的是64位的变量,那么会同时使用第n和第n+1两个slot,虚拟机不允许通过任何方式单独访问其中某一个slot,这种操作将在类加载的校验阶段抛出异常
另外,当代码执行超出了某个变量的作用域之后,它所占用的slot就可以被其他的局部变量所占用,因此slot实际是可以复用的
还有一点值得注意,就是局部变量并不像上一篇(类加载机制)讲过的类变量那样会有一个“准备阶段”。也就是说局部变量不会被默认赋零值,这也就是为什么局部变量需要显式赋值后才能被使用的原因
2.操作数栈
顾名思义,操作数栈就是存放操作数的栈,是一个后进先出的栈结构。在方法执行的过程中,会有各种字节码指令往操作数栈中写入或者提取数据,也就是入栈和出栈操作
比如字节码指令iadd(整数加法指令),在运行时会将栈顶已经存入的两个int类型数据出栈并相加,然后再将计算结果入栈
操作数栈中元素的数据类型必须与字节码指令序列严格匹配,这一点在编译期以及类加载的校验阶段会进行验证。还是以iadd指令为例,栈顶的两个元素只能是int类型,而不是能其他类型
3.动态连接
每个栈帧都包含一个常量池中该栈帧所属方法的引用。持有该引用的目的是为了支持方法调用中的动态连接。Class文件的常量池中存在大量的符号引用。这些符号引用一部分会在类加载阶段或者第一次使用的时候转化为直接引用,这种方式称为静态解析。另一部分会在每次运行期间转化为直接引用,这种称为动态连接。关于这两种方式会在后面具体讲解
4.返回地址
当一个方法开始执行后,只有两种方式退出该方法:
(1)第一种是执行引擎遇到任意一种方法返回的字节码指令,这种情况下可能会有方法返回值传递给方法调用者。这种方式称为正常完成出口
(2)另外一种是执行过程中出现异常,但是方法体内又没有对异常进行处理,这种退出不会给调用者任何返回值。这种方式称为异常完成出口
不管哪种方式,方法退出后都需要返回到方法被调用的位置,程序才能继续运行。通常来说,方法正常退出时,栈帧中会记录调用者的程序计数器值作为返回地址。方法异常退出时,返回地址需要通过异常处理器表来确定
方法调用
方法调用的任务是确定哪个方法被调用。不同于传统的静态连接的语言,Java采用动态连接。Class文件在编译过程中存储的只是符号引用,而不是方法在运行时内存布局的入口地址(直接引用)。因此,目标方法的直接引用,需要在类加载时期,甚至是运行时才能够最终确定
1.解析调用
有些方法在类加载的解析阶段,就可以将符号引用转化为直接引用,这种方式称为解析调用(Resolution)。解析能够成立的前提是:方法在编译期就有一个可确定的调用版本,并且这个版本在运行时不可改变。符合上述特征的方法主要包括静态方法、私有方法、实例构造器、父类方法以及final方法
2.分派调用
解析调用是一个静态的过程(符号引用转变为直接引用的过程不会延迟到运行时),分派调用(Dispatch)可以是静态的,也可以是动态的
#静态分派
在讲解静态分派前,我们先来看一个实例,大家可以先自己想一下输出结果
运行结果不出意外,但是为什么会是这样的结果,而不是分别吃了苹果和香蕉呢?
在这个例子中,Fruit是变量的静态类型(Static Type),Apple和Banana是变量的实际类型(Actual Type)。对于重载(Overload)方法的调用,以参数的静态类型作为依据。静态类型在编译期可知,因此,在编译期编译器会根据参数的静态类型来决定使用哪个重载版本。这种通过静态类型来决定方法执行版本的分派方式就称为静态分派
#动态分派
下面我们通过另一个实例来看下动态分派,大家同样可以先自己想一下输出结果
运行结果同样不出意外,我们来分析一下。在这个例子中,显然不是通过静态类型来决定方法版本,而是通过实际类型。我们通过javap来看一下反汇编代码:
红框中8和20两行分别把新创建的Boy对象和Girl对象压入操作数栈顶,9和21行分别通过invokevirtual指令进行方法调用。invokevirtual指令运行时的解析过程大致分为以下步骤:
(1)找到操作数栈顶的第一个元素所指向对象的实际类型C
(2)然后在C中查找与常量池中描述符和简单名称都相符的方法,如果找到,则进行权限验证,如果通过验证则返回这个方法的直接引用,否则抛出java.lang.AbstractMethodError异常
(3)否则,按照继承关系自下而上对C的父类进行第二步的查找和验证
(4)如果没有找到则抛出java.lang.AbstractMethodError异常
可以看出invokevirtual指令的第一步就是查找对象的实际类型,这就是Java中方法重写(Override)的本质。这种在运行时通过实际类型来决定方法执行版本的分派方式就称为动态分派
#虚拟机动态分派的实现
动态分派是运行时非常频繁的一个动作,如果每次都从类的元数据中搜索合适的目标方法,显然不是一个高效的方案,那么虚拟机是如何高效的做到动态分派的呢?
最常用的一种方案是建立一个虚方法表(Vritual Method Table)。虚方法表中存放着各个方法的实际入口地址。如果子类中没有重写父类的某个方法,那么在子类的虚方法表中存储的就是父类中该方法的地址入口。如果子类中重写了父类的某个方法或者新声明并且实现了某个方法,那么在子类的虚方法表中存储的就是子类中该方法的地址入口
虚方法表一般在类加载的连接阶段进行初始化,在类变量赋初始值后,虚拟机会把该类的虚方法表也进行初始化。除了使用虚方法表这种稳定的优化手段,虚拟机在条件允许的时候还会采取一些激进的优化手段,这个后面章节再议
基于栈的字节码解释执行引擎
前面提到过,虚拟机在执行代码的时候有解释执行和编译执行。下面我们对解释执行做下介绍
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,依赖操作数栈进行工作。计算机世界里与之对应的另一种常见的架构是基于寄存器的指令集。我们通过计算“1+1”这个例子来看下两者有何不同
(1)基于栈的指令集:
iconst_1
iconst_1
iadd
istore_0
两条iconst_1将两个常量1压入栈,iadd将栈顶的两个元素出栈、相加再将结果压入栈,最后istore_0将栈顶的元素出栈并存放到局部变量表的第0个slot
(2)基于寄存器的指令集:
mov eax, 1
add eax, 1
mov将1存入寄存器eax,add将寄存器eax中的值与另一个常量1相加,再将结果存入寄存器eax
这两种指令集方式各有优缺点。基于栈的指令集优点是不直接依赖硬件,可移植性强。而基于寄存器的指令集直接依赖于硬件,直接受硬件制约;也正因如此,基于栈的指令集通常来说执行效率不如基于寄存器的指令集
基于栈的解释器执行过程
了解了上面的基本理论,下面我们通过一个相对复杂的例子,来具体看一下虚拟机是如何执行的
通过javap来看一下反汇编代码:
从上图可以看到,该方法需要深度为2的操作数栈和4个slot的局部变量空间。下面我们来逐步看一下执行过程中代码、操作数栈和局部变量表的变化
0: bipush 87
首先执行偏移地址为0的指令,bipush指令将取值范围为-128~127的整形常量推入操作数栈。在我们这个例子中,这个值是87
2: istore_1
执行偏移地址为2的指令,istore_1指令将操作数栈顶的整形值出栈并存入局部变量表的第一个slot中。在我们这个例子中,这个值是87
在我们例子中,一直到偏移地址为8指令都是在重复上面两步,结果是分别将a、b、c这三个整形数字放到局部变量表中,在此不再赘述
9: iload_1
执行偏移地址为9的指令,iload_1指令将局部变量表中第1个slot中的整形值复制到操作数栈顶。在我们这个例子中,这个值是87
10: iload_2
执行偏移地址为10的指令,iload_2指令将局部变量表中第2个slot中的整形值复制到操作数栈顶。在我们这个例子中,这个值是7
11: iadd
执行偏移地址为11的指令,iadd指令将操作数栈顶的两个整形值出栈,相加后再重新推入操作数栈顶。在我们这个例子中,这个过程是7+87=94
12: iload_3
执行偏移地址为12的指令,iload_3指令将局部变量表中第3个slot中的整形值复制到操作数栈顶。在我们这个例子中,这个值是11
13: imul
执行偏移地址为13的指令,imul指令将操作数栈顶的两个整形值出栈,相乘后再重新推入操作数栈顶。在我们这个例子中,这个过程是11*94=1034
14: ireturn
最后,执行偏移地址为14的指令,ireturn指令将操作数栈顶整形值返回给方法调用者。在我们这个例子中,这个值是1034。至此,这个方法执行结束
上面的执行过程仅仅是一种概念模型,现实中的虚拟机会对这一系列过程做出优化以提高性能。即便如此,我们从这个概念模型中也可以看出基于栈的解释器的基本执行过程
思维导图:
笔记5结束