前言
执行引擎是Java虚拟机的核心组成部分之一,其模型使用IPO概念解读为:输入的是字节码文件、处理过程是等效字节码解析过程,输出的是执行结果。既然是执行字节码,必然有一个入口,对于程序而言那就是方法的调用执行,而方法调用和运行的基本单位是栈帧,所以下文会先介绍栈帧结构和方法调用,最后在分析虚拟机的字节码执行引擎。
本文大纲:
1、运行时栈帧结构
2、方法调用
3、基于栈的字节码解释执行引擎
4、总结
一、运行时栈帧结构
应用程序运行时会为每个线程创建一个栈,在调用方法时会在相应栈中创建一个栈帧。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。每个栈帧都包括了一下几部分:局部变量表、操作数栈、动态连接、方法返回地址和附加信息。栈帧结构图如下:
1.1 局部变量表
局部变量表就是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽(slot)为最小单位,每个slot都应该能存放一个boolean,byte,short,int,char,float,reference,returnAddress类型的数据,对于64位的数据类型只有double,long两种(reference可能为32位也可能为64位),这两种类型占用两个连续slot。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果是实例方法(非static)那么局部变量表中第0位索引的slot默认是用于传递方法所属对象实例的引用,方法中可以通过this来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量slot,参数表分配完毕之后,再根据方法体内部定义的变量顺序和作用域分配其余的slot。
类变量有两次赋值的过程,一次在准备阶段,赋予系统初始值(比如int默认值为0,boolean默认值为false,object类型默认值为null等),另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为类变量赋值也没用关系,类变量仍然具有一个确定的初始值。但是局部变量若是定义了但没有赋初始值是没法使用的,类加载将会失败,但是主流开发IDE在编译阶段就会提示我们程序编译失败,比如:Elipcse。
1.2 操作数栈
操作数栈是一个后入先出栈,是用来进行存储方法在执行的过程中的一些指令的操作数和中间值的场所,在方法执行的过程中,会有各种的字节码指令往操作数栈中写入和读取内容,也就是出栈和入栈操作。
同时这也是相对于基于寄存器的虚拟机的弱势,因为每次的出栈和入栈操作无疑多了很多的操作,造成数据的读取和写入比较繁琐,速度上是比基于寄存器的虚拟机要慢的,但是可移植性较好。Java虚拟机的解释执行引擎为“基于栈的执行引擎”,其中的栈指的就是操作数栈。
1.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用就是为了支持方法调用过程中的动态连接。
这些符号引用会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。这两个过程就是方法调用过程的两种方式。
1.4 方法返回地址
方法被执行后,有两种方式退出这个方法。第一种方法是执行引擎遇到任意一个方法的返回的字节码指令。另外一种退出方式是在方法执行过程中遇到了异常,并且这个异常并没有在方法体中得到处理。
方法退出之后,需要返回到方法被调用的位置,程序才能继续执行,方法返回时需要在栈帧中保存一些信息,用以帮助它恢复它上层方法的执行状态。一般情况下,调用者的pc计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值,方法异常退出时,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,所以可能需要执行这些操作:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈的操作数栈中,调整pc计数器的值。
1.5 附加信息
虚拟机规范允许具体的虚拟机实现增加一些虚拟机规范里面没有描述的信息到栈帧之中,例如与调试相关的信息。一般将动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。
二、方法调用
方法调用的主要任务就是确定被调用方法的版本(即调用哪一个方法),该过程不涉及方法具体的运行过程。按照调用方式共分为两类:
2.1 解析调用
在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是,方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。符合这个条件的有静态方法,私有方法,实例构造器和父类方法四类,它们在类加载的时候会把符号引用解析为该方法的直接引用。
解析调用一定是一个静态的过程,编译期间就完全确定,在类装载的解析阶段就会把涉及到的符号引用全部转化为可确定的直接引用,不会延迟到运行期间再去完成。
2.2 分派调用
分派调用可能是静态的也可能是动态的,分派机制与java的多态机制关系密切。
静态分派:依赖静态类型来定位方法执行版本的分派动作,称为静态分派。静态分派的最典型的应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
动态分派:在运行期间根据实际类型来确定方法执行版本的分派调用过程称为动态分派。这跟多态性的另一个特性重写有着很密切的关联。
2.3静态分派案例
执行结果是:
hello guy
hello guy
原因:虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据,并且静态类型是编译期可知的,所以在编译阶段,javac编译器就根据参数的静态类型决定使用哪个重载版本,并把这个方法的符号引用写入invokevirtual指令的参数中。Man实例和Woman实例都是使用Human这个静态类型声明。
2.4 动态分派案例
执行结果:
man say hello
woman say hello
woman say hello
原因:invokevirtual指令有多态查找的机制,会根据实际类型来调用方法,而不是声明的参数类型。
三、基于栈的字节码解释执行引擎
Java虚拟机的字节码执行引擎可以说是基于栈的字节码解释执行引擎,这里的栈指的是操作数栈,为什么是解释执行呢?原因是先解析字节码文件再执行的。但是也不严格,现今的即时编译器可以编译执行,只能说大部分时候是解释执行。
Java语言常被人说是一种“解释执行”的语言,与C语言等编译执行的语言不一样。
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。程序运行时通过解析字节码指令流运行程序。
其次,Java虚拟机使用的是基于栈的指令集,而不是基于寄存器的指令集,这两种指令集有什么区别呢?其实就是Java与C之间的区别,可移植性的不同。基于栈的指令集不依赖于具体的操作系统,而基于寄存器的指令集与操作系统底层关系密切。
四、总结
简单总结一下,本文主要介绍了Java虚拟机的字节码执行引擎,其执行方式是解释执行为主。同时介绍了运行时栈帧的结构以及方法是如何被调用的,调用的方式有解析调用和分派调用两种,而分派调用又包括静态分派和动态分派。静态分派与多态中的重载对应,动态分派与多态中的重写对应。
感谢您的阅读,谢谢。
参考文献:《深入理解Java虚拟机》作者:周志明
始发于微信公众号: Java框架源码分析