运行时栈帧结构
JVM执行引擎
执行引擎是Java虚拟机最核心的组成部分之一。 “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、 硬件、 指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。 在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。 栈帧存储了方法的局部变量表、 操作数栈、 动态连接和方法返回地址等信息。 每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、 操作数栈、 动态连接、 方法返回地址和一些额外的附加信息。 在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
(1)局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。 在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、 byte、 char、 short、 int、 float、 reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个Slot占用32位长度的内存空间”是有一些差别的,它允许Slot的长度可以随着处理器、 操作系统或虚拟机的不同而发生变化。 只要保证即使在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。 其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
(2)操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In FirstOut,LIFO)栈。 同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。 操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。 32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。 在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
(3)动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。 我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。 这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。 另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
(4)方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。 一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。 在程序运行时,进行方法调用是最普遍、 最频繁的操作,但前面已经讲过,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。 这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
继续前面关于方法调用的话题,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。 换句话说,调用目标在程序代码写好、 编译器进行编译时就必须确定下来。 这类方法的调用称为解析(Resolution)。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器<init>方法、 私有方法和父类方法。
- invokevirtual:调用所有的虚方法。
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、 私有方法、 实例构造器、 父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。 这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。
Java中的非虚方法除了使用invokestatic、 invokespecial调用的方法之外还有一种,就是被final修饰的方法。 虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。 在Java语言规范中明确说明了final方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。 而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。 这两类分派方式的两两组合就构成了静态单分派、 静态多分派、 动态单分派、 动态多分派4种分派组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。
分派
众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、 封装和多态。 本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。
静态分派
静态分类即常说的"重载"。
static class Base { }
static class Derived extends Base { }
public static void test(Base base) {
System.out.println("test Base");
}
public static void test(Derived derived) {
System.out.println("test Derived");
}
public static void main(String[] args) {
Base base = new Base();
Base derived = new Derived();
test(base);
test(derived);
}
我们把上面代码中的“Base”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Derived”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。 并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。 静态分派的典型应用是方法重载。 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。 另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。
动态分派
了解了静态分派,我们接下来看一下动态分派的过程,它和多态性的另外一个重要体现——重写(Override)有着很密切的关联。
static class Base {
public void test() {
System.out.println("Base test");
}
}
static class Derived extends Base {
public void test() {
System.out.println("Derived test");
}
}
public static void main(String[] args) {
Base base = new Base();
Base derived = new Derived();
base.test();
derived.test();
}
现在的问题还是和前面的一样,虚拟机是如何知道要调用哪个方法的?显然这里不可能再根据静态类型来决定,因为静态类型同样都是Base的两个变量base和derived在调用test()方法时执行了不同的行为,并且变量在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?
虚拟机动态分派的实现
前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派中“会做什么”这个问题。 但是虚拟机“具体是如何做到的”,可能各种虚拟机的实现都会有些差别。
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。 面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。 如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。 如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。 但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、 子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
注意:Java中的虚方法表和C++中虚表类似的,但也有差别。比如以下Java代码:
public class Base {
public Base() {
test();
}
public void test() {
System.out.println("Base test()");
}
}
public class Derived extends Base {
public Derived() {
test();
}
public void test() {
System.out.println("Derived test()");
}
}
public static void main(String[] args) {
Derived derived = new Derived();
}
输出结果为:
如果是C++代码的话,输出结果为:
Base test()
Derived test()
从上面两个输出结果来看,C++对象的构造顺序为先构造基类,然后再构造子类,在基类构造过程时,对象的虚表指针指向的是基类类型数据,此时虚函数对应的虚表指针为基类的test函数;在子类构造过程时,对象的虚表指针指向的是子类类型数据类型,此时虚函数对应的虚表指针为子类的test函数。
javac编译流程
分析源码是了解一项技术的实现内幕最有效的手段,Javac编译器不像HotSpot虚拟机那样使用C++语言(包含少量C语言)实现,它本身就是一个由Java语言编写的程序,这为纯Java的程序员了解它的编译过程带来了很大的便利。
javac编译过程大致可以分为3个过程,分别是:
● 解析与填充符号表过程(词法分析、语法分析、填充符号表)
● 插入式注解处理器的注解处理过程(对注解进行支持)
● 分析与字节码生成过程(语义分析与字节码生成)
泛型与类型擦除
泛型是JDK 1.5的一项新增特性,它的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。 这种参数类型可以用在类、 接口和方法的创建中,分别称为泛型类、 泛型接口和泛型方法。
泛型思想早在C++语言的模板(Template)中就开始生根发芽,在Java语言处于还没有出现泛型的版本时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。 例如,在哈希表的存取中,JDK 1.5之前使用HashMap的get()方法,返回值就是一个Object对象,由于Java语言里面所有的类型都继承于java.lang.Object,所以Object转型成任何对象都是有可能的。 但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。 在编译期间,编译器无法检查这个Object的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException的风险就会转嫁到程序运行期之中。
泛型技术在C#和Java之中的使用方式看似相同,但实现上却有着根本性的分歧,C#里面泛型无论在程序源码中、 编译后的IL中(Intermediate Language,中间语言,这时候泛型是一个占位符),或是运行期的CLR中,都是切实存在的,List<int>与List<String>就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。
Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此,对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
public void test(List<Integer> list) {
System.out.println("List<Integer>");
}
public void test(List<String> list) {
System.out.println("List<String>");
}
由于类型擦除,以上代码是编译不过的。因为参数List<Integer>和List<String>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样。