这章原名叫“虚拟机字节码执行引擎”,实际就是讲的函数如何调用和执行的。
1、概述
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,
其区别是物理机的执行引擎是直接建立在处理器、 硬件、 指令集和操作系统层面上的,
而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。
在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择[1],也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果
2、运行时栈帧结构
2.1 什么是栈帧
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。
每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、 操作数栈、 动态连接、 方法返回地址和一些额外的附加信息。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中[2],
因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作
2.2、栈帧结构图
2.3、栈帧的详细内容
每一个栈帧都包括了局部变量表、 操作数栈、 动态连接、 方法返回地址和一些额外的附加信息。
局部变量表(Local Variable Table)
一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。容量以变量槽(Variable Slot,下称Slot)为最小单位 。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,
如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。
其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
操作数栈(Operand Stack)
操作数栈也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。 同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
动态连接 (Dynamic Linking)
每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
方法返回地址
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:
恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。
3、方法调用
注:这里的调用只包含调用,不包含执行。
3.1、解析(Resolution)
确定方法调用目标的过程,就是解析的过程。
JVM调用字节码指令有5种:
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、 私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
3.2、分派(Dispatch)
分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数[1]可分为单分派和多分派。
静态分派
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
静态分派的典型应用是方法重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
举例:
class A ;
class B extern A;
void call(A a);
void call(B b);
A a = new B(); // 这里明确两个名词: 对象a的静态类型(A),动态类型(B)。
call(a); // 会调用void call(A a); 因为重载是在编译时决定的,重载决定的要素是静态类型,编译期未运行,不可能是运行态(动态)。
另外,对于原始数据类型,静态分派时会进行自动转换,转换顺序为:char->int->long->float->double ->装箱->Object->可变参数列表(最后)
动态分派
和多态性的另外一个重要体现[3]——重写(Override)有着很密切的关联。
显然的,静态分派的优先级要高于动态分派,因为静态分派是在编译期就已经确定了的。
总结一句:今天的Java语言是一门静态多分派、 动态单分派的语言。单分派是指已经确定了类型,只是看选父类还是子类了。
3.3、动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期
Java1.7引入了invokedynamic 、MethodHandle ,但仍然不支持动态语言,详略。
4、方法执行
Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
在本章中,我们先来探讨一下在解释执行时,虚拟机执行引擎是如何工作的。
4.1 解释执行
Javac编译器完成了程序代码经过词法分析、 语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。
因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
4.2 基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上[1]是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
基于栈的:用内存做栈,存临时对象,计算时要压栈出栈。
基于寄存器的:用硬件提供的寄存器存临时对象,计算式直接使用。
对于“1+1”这个计算:
基于栈的命令:
iconst_1
iconst_1
iadd
istore_0
基于寄存器的命令:
mov eax,1
add eax,1
优缺点:
基于栈的指令集主要的优点就是可移植, 缺点是执行速度相对来说会稍慢一些。 寄存器则相反。所有主流物理机的指令集都是寄存器架构 。