JVM字节码执行引擎
运行时栈帧结构
局部变量表
操作数栈
动态连接
方法返回地址
附加信息
方法调用
解析
分派 –“重载”和“重写”的实现
静态分派
动态分派
单分派和多分派
JVM动态分派的实现
基于栈的字节码解释执行引擎
基于栈的指令集与基于寄存器的指令集
JVM字节码执行引擎
虚拟机是相对于“物理机”而言的,这两种机器都有代码执行能力,其区别主要是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎是自己实现的。因此程序员可以自行制定指令集和执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型称为各种虚拟机执行引擎的统一外观。虚拟机实现中,可能会有两种的执行方式:解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码)。有些虚拟机值采用一种执行方式,但是有点采用了两种,甚至有可能包含几个不同级别的编译器执行引擎。
所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件、处理过程是等效字节码解析过程,输出的是执行结果。
运行时栈帧结构
栈帧(Stack Frame)是一种数据结构,它主要是用来支持虚拟机进行方法调用和方法执行。它是虚拟机运行时数据区的虚拟机栈的栈元素。
包含内容:栈帧包含了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息等。
执行过程:一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。在活动线程中,只有栈顶的栈帧才是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法,执行引擎所运行的所有的字节码指令都只针对当前栈帧进行操作。
执行意义:每个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
值得注意的是:在编译程序代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多大的内存,并不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机的实现。
局部变量表
一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了改方法所需分配的最大局部变量表的容器。
包含类型:boolean、byte、char、short、int、float、reference或returnAddress类型八种类型。
容量单位:变量槽(slot)。不过虚拟机中并没有明确确定每一个变量槽所占据的内存空间大小,只是有导向性地说明每个变量槽都应该存放的八种类型:boolean、byte、char、short、int、float、reference或returnAddress类型的数据。这种描述和明确指出“每个Slot占用32位长度的内存空间”有一些差别,它允许Slot的长度随着不同的处理器、操作系统或者虚拟机而发生改变。在64位系统上使用64位长度的内存空间来实现一个slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。
在Java中占32位以内的数据类型有boolean、byte、char、short、int、float、reference或returnAddress类型等,前六种不解释,而后面的reference是对象的引用。虚拟机规范并没有说明它的长度,也没有明确指出这个引用应有怎样的结构,但一般来说:虚拟机实现至少都应当能从此引用中直接或间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。而returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向一条字节码指令的地址。
对于64为的数据类型,虚拟机会以高位在前的方式为其分配两个连续的Slot空间。即long和double两种类型。做法是将long和double类型速写分割为32位读写的做法。不过由于局部变量表建立在线程的堆栈上,是线程的私有数据,无论读写两个连续的Slot是否是原子操作,都不会引起数据安全问题。
虚拟机索引方式:虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量。如果是32为数据类型的数据,索引n就表示使用第n个Slot,如果是64位数据类型的变量,则说明要使用第n和第n+1两个Slot。
在方法执行过程中,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法(非static方法),那么局部变量表中的第0位索引的Slot默认是用来传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量相应的Slot就可以交给其他变量去使用。节省栈空间。但也有可能会影响到系统的垃圾收集行为。
还有一点要说明的是:局部变量不像前面介绍的类变量那样存在“准备阶段”。我们知道,类变量在加载过程中要经过两次赋初始值的过程:一次在准备阶段,赋予系统初始值,另外一次在初始化阶段,赋予程序员定义的初始值。但局部变量不一样,如果一个局部变量定义了但是没有赋初始值是不能使用的。所有不要认为Java中任何情况下都存在着诸如整型变量默认为0,布尔型变量默认为false之类的默认值。这一点要好好注意一下。
操作数栈
操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。
操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位所占的栈容量为2.在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的。但是大多数的虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。这样在进行方法调用时就可以共用一部分数据,而无须进行额外的参数复制传递。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中的栈就是指操作数栈。
动态连接
每个栈帧都包含着一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用的是为了支持方法调用过程中的动态连接。
在Class文件中存在着大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分在类加载阶段第一次使用阶段的时候转换为直接引用,这种转换称为静态解析。另外一部分将在每次的运行期间转化为直接引用,这部分称为动态转换。
方法返回地址
当一个方法被执行后,有两种方式可以退出这个方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
另外一种退出方式是:在方法执行过程中遇到异常,并且这个异常没有在方法体内得到处理,无论是JVM内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。这种方式被称为异常退出出口。此方式不会给上层调用者产生任何返回值。
无论采用哪一种退出方式,在方法退出后,都会返回到方法被调用的位置,程序才能继续执行。方法返回时可能要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出之后,调用者的PC计数器的值就可以作为返回地址。栈帧中很可能会保存这个计数器值,而方法异常退出后,返回地址就要通过异常处理器表来确定,栈帧一般不保存这部分信息。
方法退出实际上就是把当前栈帧出栈的操作:因此退出时可能执行的操作:恢复上层方法局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向指令后面的一条指令。
附加信息
增加一些没有描述的信息到栈帧之中。一般将动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。
方法调用
Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用都在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前所说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
所有的方法调用的目标方法在Class文件里面都只是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是L方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不会改变的。换句话说:调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。
在JVM中提供了5条方法调用字节码指令,分别是:
invokestatic:调用静态方法
involespecial:调用实例构造器方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用限定符所引用的方法,然后再执行该方法。
只要能被invokestatic和invokeapecial指令调用的方法,都是可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。非虚方法除了上述的两种以外,还有一种就是被final修饰的方法,虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,所以可以把final方法看作是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就可以确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的或者是动态的,根据分派依据的总量数可以分为单分派和多分派,这两种分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派和动态多分派四种分派组合。
分派 –“重载”和“重写”的实现
静态分派
JVM在重载时是通过参数的静态类型而不是实际类型做判定的,并且静态类型是编译期可知的,因此在编译阶段,Javac编译器会根据参数的静态类型决定使用那个重载版本,然后再把该方法的符号引号写到main()方法的两条invokevritual指令的参数中。
所有依赖于静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型是方法的重载。
静态分派发生的时间:静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
静态方法会在类加载期进行解析,而静态方法显然是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。
动态分派
运行期根据实际类型确定方法执行版本的分派过程称为动态分派。动态分派与方法重写有着密切的关系。
单分派和多分派
方法的接收者与方法的参数统称为方法的宗量。而根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种,单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。
JVM动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,为了避免频繁的搜索,最常用的“稳定优化”的手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都是指向父类的实现入口,如果子类中重写了这个方法,子类方法表中的地址将会替换成指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同的签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更在、查找的方法表,就可以从不同的虚方法表中按照索引转换出所需的入口地址。
基于栈的字节码解释执行引擎
Java程序在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。当然也可以选择其中的一部分步骤实现一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装到一个封闭黑匣子中,如大多数的JS执行器。
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
基于栈的指令集主要优点就是可移植。除此之外,还有其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单等。
缺点是:执行速度相对较慢。