深入理解JVM 第八章 虚拟机字节码执行引擎

本文详细解析了Java虚拟机(JVM)字节码执行引擎的概念模型,包括运行时栈帧结构、方法调用机制、动态连接、方法返回地址及附加信息等内容。深入探讨了基于栈的字节码解释执行引擎的工作原理,以及方法调用中的静态分派与动态分派的区别。

1 概述

在Java虚拟机规范中规定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能由解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同急别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一直的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

2 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的站元素。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里弥漫从入栈到出栈的过程。

每一个栈帧都包括了方法的局部变量表、操作数栈、动态链接和方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性中,因此一个栈帧需要分配多少内存,不会收到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
在这里插入图片描述

2.1 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范并没有明确指明一个Slot应占用的内存空间大小,只说明每个Slot都应该能存放一个bolean、byte、char、short、int、float、reference或returnAddress类型的数据。局部变量没有赋初始值是不能用的。

2.2 操作数栈

同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量位1,64位数据类型所占的栈容量位2。

在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以公用一部数据,而无须进行额外的参数复制传递了。
在这里插入图片描述

2.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就可以以常量池中指向的方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接。

2.4 方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。另一种退出方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码使用athrow字节码指令产生的异常,只要在本方法的异常表中国没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,不会给它的上层调用者产生任何的返回值。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的所有操作有:回复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

2.5 附加信息

虚拟机规范允许具体的虚拟机实现怎加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。

3 方法调用

3.1 解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。即调用目标程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中,符合"编译器可知,运行期不可变"这个要求的方法主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应,Java虚拟机里面提供了四条方法调用字节码指令,分别是:
invokestatic:调用静态方法
invokespecial:调用实例构造器 <init 方法、私有方法和父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象。

只要能被invokestatic和invokespecial指令调用得方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法。此外,final方法也是一种废墟方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无需对方法接收者进行多态选择,又或者说堕胎选择的结果肯定是唯一的。除此之外,其他方法就称为虚方法(final方法除外)。

3.2 分派

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派情况。

3.2.1 静态分派

在这里插入图片描述
在这里插入图片描述
我们把上面代码中的"Human"称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的"Man"则称为变量的实际类型(Actual type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,而且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

再回到代码当中,main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象"sr"的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同、实际类型不同地变量,但==虚拟机(准确地说是编译器)在重载时是通过参数地静态类型而不是实际类型作为判定依据的,==并且静态类型是编译器可知的,所以再编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号音号引用卸道main()方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是"唯一的",往往只能确定一个"更加合适的"版本。这种模糊的结论在由0和1构成的计算机世界中算是比较"稀罕"的事件,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

3.2.2 动态分派

在这里插入图片描述
在这里插入图片描述
使用javap命令输出这段代码的字节码:
在这里插入图片描述
在这里插入图片描述
0-15行的字节码是准备动作,作用是建立man和women的内存空间、调用Man和Women类型的实例构造器,将这两个实例的引用存放在第1和第2个局部变量表Slot中。16-21行是关键部分,16和20两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行sayHello()方法的所有者,称为接收者(Receiver);17和21两行是方法调用指令,单从字节码的角度来看,这两条调用指令无论是指令还是参数都完全一样,但是这两条指令最终执行的目标方法并不一样,其原因需要从invokevirtual指令的多态查找过程说起,invokevirtual指令的运行时解析过程大致分为以下步骤:

(1) 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
(2) 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行方法权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
(3) 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
(4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类发方符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3.2.3 单分派与多分派

在这里插入图片描述
在这里插入图片描述
编译阶段编译器的选择过程即静态分派的过程。这时候选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分配属于多分派类型。

运行阶段虚拟机的选择,即动态分配过程。在执行"son.hardChoice(new QQ())“这句代码时,更准确地说,在执行这句代码所对应地invokevirtual指令时,由于编译器已经决定目标方法地签名必须为hardCohice(QQ),虚拟机此时不会关心传递过来地参数"QQ"到底是"腾讯QQ"还是"奇瑞QQ”,因为这时候参数的静态类型、实际的接收者都不会对方法的选择构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分配属于单分派类型。

4 基于栈的字节码解释执行引擎

基于栈的指令集和基于寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流里面的指令大部分都是零地址指定,它们依赖操作数栈进行工作。与之想对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值