虚拟机字节码执行引擎
1. 所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的有效过程,输出的是执行结果。
2. 运行时栈帧结构:
栈帧是支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息,每一个方法调用从调用开始到执行完成都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。栈帧的概念结构如下:
下面详细讲解栈帧各个部分的作用和数据结构:
①局部变量表:存放方法参数和方法内定义的局部变量(注意局部变量表的数据结构并不是一个栈)。局部变量表以变量槽Slot为基本单位,Slot的长度并没有被规定,对于一些长数据要占用多个Slot,会按照高位对齐的方式为其分配连续的Slot空间。
虚拟机如何使用局部变量表?方式如下:
②操作数栈:是一个后入先出的栈,用于存储字节码指令的操作数和操作结果。在方法刚开始执行的时候这个方法的操作数栈是空的,在方法执行过程中,各种字节码指令会往操作数栈中写入和提取内容,也就是入栈/出栈操作。
③动态链接:保存栈帧的基址。
④方法返回地址:保存返回地址。
3. Java虚拟机的方法调用机制:
方法调用不同于方法执行,方法调用的唯一任务是确定被调用方法的版本,即该调用哪一个方法,暂时还不涉及到方法具体的执行过程。
①解析:所有方法调用中的目标方法在Class文件中都是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转换为直接引用,这种解析能成立的前提是:方法在真正运行之前就有一个可确定的调用版本,并且这个方法的调动版本在运行期是不可改变的。这类方法的调用称为解析。
②分派:
Java是一个面向对象语言,因为java具备面向对象的三个基本特征:继承、封装和多态。这一节我们关心的是重载和覆盖是如何实现的,如何找到正确的目标方法。
⑴静态分派:
依赖静态类型(即外观类型,也就是声明这个变量时所采用的类型)来定位方法执行版本的分派动作称为静态分派。静态分派的典型作用是方法重载。也就是说,虚拟机在重载时是通过参数的静态类型而不是实际类型(你可能会让一个父类的引用指向一个子类的对象,这里子类就是这个父类引用的实际类型)来作为判断依据。比如说:
它的执行结果就是:hello, guy!
hello, guy!
静态分派发生在编译阶段,因此静态分派的动作实际上不是由虚拟机来完成的。有的时候重载版本并不是唯一的,这种情况发生在你用字面量作为实参的时候,字面量没有显示的静态类型,这时候要按照“就近原则”来确定重载的版本。比如代码里有void f(int)和void f(double)两个方法,现在我进行方法调用f(1),那么将会调用f(int)方法,如果代码里没有f(int)方法,那么编译器将会选择f(double)方法来执行。不对这一点详述了。
⑵动态分派:
动态分派则和覆盖有着很重要的联系。先看代码如下:
它的输出结果是:
这个结果并不出乎我们意料,但是虚拟机是如何知道应该调用哪个方法的呢?
我们对这个类的.class文件使用javap工具输出它的字节码,会发现class文件中这两处方法调用都是相同的invokevirtual #22,也就是说指令和参数都是一模一样的,但是它们的执行结果却不相同,这就要从invokevirtual指令的多态查找过程说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
因此上例的结果就不言而喻了。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态分派。
⑶单分派和多分派:
方法的接受者和方法的参数统称为方法的宗量,根据分派基于多少种宗量,将分派分为单分派和多分派,单分派基于一个宗量对目标方法进行选择,多分派基于多个宗量对目标方法进行选择。
Java语言的静态分派属于多分派类型,动态分派属于单分派类型。
⑷虚拟机动态分派的实现:
前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派过程中“会做什么”的问题。但是虚拟机“具体是如何做到”的,各个虚拟机会有所差别。
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机的实际实现中基于性能考虑,大部分不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是,为类在方法区建立一张虚方法表,与此对应,在invokeinterface执行时也会用到接口方法表,使用虚方法表索引代替元数据查找来提高性能。虚方法表结构如下所示:
虚方法表结构
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口;如果子类重写了这个方法,子类方法表中的地址将会替换为子类实现版本的入口地址。
为了程序上实现的方便,具有相同签名的方法,在父类和子类的虚方法表中应当具有相同的索引号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完毕。
4. 基于栈的字节码解释执行引擎:
这一节介绍虚拟机如何执行方法中的字节码指令。
①基于栈的指令集与基于寄存器的指令集:
Java编译器输出的指令流,基本上是一种基于栈的指令集结构。指令流中的指令大部分都是零地址指令(零地址指令就是指令中包含0个操作数地址),它们依赖操作数栈进行工作。与之相对应的另外一套常用的指令集结构是基于寄存器的指令集结构。举个简单的例子来说明这两者的差别:分别使用这两者计算1+1:
基于栈的指令集会是这样的:
两个iconst_1指令会连续把两个常数1压入栈,iadd指令把栈顶的两个值出栈,相加,然后把结果存回栈顶,最后istore_0把栈顶的值存回局部变量表的第0个Slot中。
基于寄存器的指令集则可能是这样的:
mov指令把eax寄存器设置为1,然后让eax加1保存回eax。
两种指令集结构的优缺点比较:
②基于栈的解释器执行过程:
考察这样一段代码:
使用javap查看他的字节码指令如下:
它在执行过程中的代码、操作数栈、局部变量表的变化情况如下:
值得一提的是,在生成的字节码指令中,并没有任何关于”a”、”b”、”c”这些标识符的信息,字节码指令中只有对局部变量表、操作数栈内的操作数进行操作的指令,它是怎么知道计算(a+b)*c时该取局部变量表的哪些对应的操作数的呢?我们可以看到,int a=100时生成的指令是istore_1,int b=200时生成的是istore_2,int c=300时生成的是istore_3,也就是说,虽然没有显式的把变量标识符跟局部变量表中的Slot对应,但其实它已经使用了默认的按变量声明顺序(以及参数传递顺序)对应的方式。
同样,在C、C++等高级语言程序编译连接后产生的exe文件中,反汇编后我们可以看到对于一个int a=100;语句产生的汇编语言指令是003513BE mov dword ptr [a],64h ,他表示把100存储到ptr [a]所指示的地址的双字中,在机器最终运行的指令中,它是一个具体地址,没有任何关于标识符a的信息。