概述
区别于物理机的执行引擎是建立在处理器、硬件、指令集和操作系统层面之上,Java虚拟机的执行引擎是由自己实现的,因此可自行制定指令集与体系结构,执行硬件不能直接支持的指令集格式。
依据Java虚拟机规范中制定的虚拟机字节码执行引擎的概念模型(Facade),不同的虚拟机实现执行引擎细节不同,但从外观上看都是一致的:输入字节码文件,等效于处理字节码解析的处理过程,输出执行结果。
本章主要从概念模型的角度讲解虚拟机的方法调用和字节码执行。
运行时栈帧结构
如下图所示,栈帧(Stack Frame)是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素。

栈帧里主要存储了下列信息:
- 局部变量表(Local Variable Table):变量值存储空间,用于存放方法参数和方法内部定义的局部变量
- 操作数栈 (Operand Stack):也叫操作栈,它是一个后入先出的栈(LIFO)
- 动态连接(Dynamic Linking):符号引用在运行期间转化为直接引用
- 方法返回地址:方法退出后返回到方法被调用的位置地址
- 额外的附加信息:具体虚拟机实现规范要求外自定义的信息
在一个线程中每个方法调用开始到结束都对应着一个栈帧在虚拟机栈里入栈和出栈的过程。位于虚拟机栈顶部的栈帧为当前栈帧(Current Stack Frame),与之对应的方法称之为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧。
局部变量表
局部变量表是变量值存储空间,用于存放方法参数和方法内部定义的局部变量。容量单位是变量槽(Variable Slot),能存放boolean
,byte
,char
,short
,int
,float
,reference
,returnAddress
8种Java虚拟机的数据类型。reference
表示一个实例对象的引用,可能是32或64位取决于是32位还是64位虚拟机。returnAddress
类型目前的Java虚拟机中很少见。64位的数据类型long
和double
会分配两个连续的Slot。
虚拟机是通过索引定位使用局部变量表的,从0开始到最大的Slot数量。64位变量是用两个连续的Slot(索引为n和n+1),不允许单独访问任一个,否则在类加载阶段抛出异常。
虚拟机在执行方法时,使用局部变量表完成参数值传递到参数变量列表的过程,在实例方法中(非静态方法)中第0位索引的Slot默认为方法所在对象实例的引用,即开发过程中常用的this
关键字。索引从1开始其次为参数表,参数表分配完毕后根据方法体内定义的变量顺序和作用域分配其余Slot。
局部变量表中的Slot可以重用,当PC计数器的值超过某局部变量的作用域,那这个Slot就可以给其他变量使用。
通过类加载过程的学习,我们知道类变量有两次赋初始值的机会,第一次在“准备阶段”赋初始值,第二次在类初始化阶段赋程序员定义的初始值。局部变量不存在类变量那样的类加载阶段的“准备阶段”来赋予系统初始值。因此,局部变量不在程序中初始化,会编译失败。即使手动生成没有初始化局部变量的如下所示的代码的字节码,也会在字节码校验时被发现导致类加载失败。
代码清单 未赋值的局部变量
public static void main(String[] args){
int a;
System.out.println(a);
}
操作数栈
Java虚拟机的解释执行引擎又被称之为“基于栈的执行引擎”,这里的栈指的就是操作数栈。
操作数栈也叫操作栈,它是一个后入先出的栈(LIFO)。最大深度在编译时写入到Code
属性的max_stacks
数据项中。操作数栈中每个元素可以是任意Java数据类型,32位数据类型栈容量为1,64位数据类型栈容量为2。
当方法开始执行时,操作数栈为空,在执行过程中各种字节码指令会在栈内做写入(入栈)或读取(出栈)操作。例如,整数加法字节码指令iadd运行时操作数栈顶已存入两个int型的数值,执行这个指令时会将这俩出栈并相加,然后将结果入栈。操作数栈中元素的数据类型与字节码指令的序列严格匹配,不能使一个long一个int,编译器会严格保证这一点,类校验阶段数据流分析也会再次校验。
在概念模型中两个栈帧是完全相互独立的,但大部分虚拟机实现中会做一些优化,让两个栈帧出现一些重叠,这样调用方法时可以共用一部分数据,无须额外的参数复制传递。如下图中,操作数栈与局部变量表共享区域。

动态连接
Class文件的常量池中包含了大量的符号引用,这些符号引用是字节码中的方法调用指令的参数,它们会转换为直接引用。转化方式有两种:
- 静态解析:符号引用在类加载阶段或第一次使用时转化为直接引用
- 动态连接:符号引用在运行期间转化为直接引用
在下面的方法调用
一节中会详细讲解如何进行解析、连接。
方法返回地址
方法执行完毕后有两种退出方式:
- 正常返回(Normal Method Invocation Completion)执行引擎遇到任一返回的字节码指令,则返回指令对应的类型的值给调用者
- 异常中断退出(Abrupt Method Invocation Completion)不论是JVM内部发生异常还是执行athrow字节码指令抛出异常,都不会返回任何值给调用者
不论上面哪种退出方式,在退出方法后都需要返回到方法被调用的位置。正常返回时调用者的PC计数器的值会最为返回地址,栈帧中可能会保存这个值。异常返回时,返回地址通过异常处理器表来确定,栈帧一般不会保存着部分信息。
方法退出的过程体现在在虚拟机栈上是:把当前栈帧出栈,恢复上层方法的局部变量表和操作数栈,把返回值push进调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧中,如:调试信息。
在实际开发中,一般把动态连接、方法返回地址与其他附加信息归为一类,称之为栈帧信息。
方法调用
方法调用阶段唯一任务是确定调用哪一个方法(方法的版本),暂不涉及具体的方法执行过程。Class文件编译不包含传统编译中的连接步骤,一切方法在Class中存储的只是符号引用,而不是方法的直接引用(在实际运行时内存布局中的入口地址)。
这个特性为Java提供了强大的动态扩展能力,也使得方法调用过程变得相对复杂,需要再类加载到运行期间才能确定目标方法的直接引用。
解析
所有待调用的目标方法在Class文件里,都是在常量池中的符号引用。在类加载的解析阶段会将一部分可以确定调用版本的符号引用转换为直接引用,这些方法在运行期间调用版本不会改变。换句话说,调用目标在程序中写好、编译器进行编译时就必须确定下来。这一类方法的调用称之为解析(Resolution)。
符合“编译器可知,运行期不可变”要求的方法主要包括静态方法(与类直接关联)和私有方法(对外不可访问)两大类。这两类特性决定他们不会通过继承或其他方式重写其他版本,因此都适合在类加载阶段进行解析。
Java虚拟机中有5条方法调用的字节码指令:
-
invokestatic
静态方法调用 -
invokespecial
调用实例构造器<init>方法、私有方法和父类方法 -
invokevirtual
调用所有的虚方法 -
invokeinterface
调用所有接口方法,会在运行时确定一个实现此接口的对象 -
invokedynamic
运行时动态解析出调用点限定符所引用的方法
前4条指令时固化在Java虚拟机内部,invokedynamic
指令的分配逻辑是用户设定的引导方法决定的。
只要能被invokestatic
和invokespecial
指令调用都可以在解析阶段确定唯一的调用版本,这些对应方法称之为非虚方法
,与之对应其他所有的(除了final修饰的方法,因为final修饰的方法无法覆盖只有唯一版本,所以也是非虚方法)方法都称之为虚方法
。
分派
Java是面向对象的程序语言,具备面向对象的三大特征:继承、封装和多态。分派调用过程是多态最基本的体现,如重载和重写在Java虚拟机中的实现,虚拟机能正确的找到对应的目标方法。
解析调用的过程是静态的,编译时完全确定,在类加载阶段就把涉及的符号引用转化为直接引用。而分派(Dispatch)调用则可能是静态的也可能是动态的,分派根据宗量数可分为单分派和多分派,两两组合就构成了四种分派组合情况:静态单分派、静态多分派、动态单分派和动态多分派。
静态分派
所有依赖静态类型定位方法执行版本的分派称为静态分派(英文技术文档中为Method Overload Resolution,国内文档都翻译为“静态分派”)。静态分派的典型应用是方法重载,它的实际执行动作发生在编译期。
Human man = new Man();
Human称为变量的静态类型,Man称为变量的实际类型。
静态类型和实际类型在使用中都会发生变化,静态类型只在使用时发生变化,而变量本身的静态类型并不会改变,并且最终静态类型在编译期可知。
而实际类型变化的结果在运行期才能确定,编译期在编译程序时并不知道一个对象的实际类型是什么。
如下代码:
//实际类型变化,具体new哪个取决于运行期中代码的执行
Human man = new Man();
man = new Woman();
//静态类型变化,代码中指定好了就不会变化
man.sayHello((Man)man);
man.sayHello((Woman)man);
Java编译器在重载时是通过变量的静态类型而不是实际类型来做判断依据。所以如下代码只会打印静态类型参数的方法。
public class StaticTester {
static class Human {
}
static class Woman extends Human {
}
static class Man extends Human {
}
public void sayHello(Human human) {
System.out.println("hello guy!");
}
public void sayHello(Woman woman) {
System.out.println("hello woman");
}
public void sayHello(Man man) {
System.out.println("hello man");
}
public static void main(String[] args) {
StaticTester st = new StaticTester();
Human man = new Man();
Human woman = new Woman();
//输出静态类型对应的方法
st.sayHello(man);
st.sayHello(woman);
//输出指定的实际类型作为静态类型对应的方法
st.sayHello((Man)man);
st.sayHello((Woman)woman);
}
}
输出结果
hello guy!
hello guy!
hello man
hello woman
另外编译器能确定的重载版本并不是唯一的,往往是最合适的。例如:

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化后,虚拟机会把该类的方法表也初始化完毕。
网友评论