概述
区别于物理机的执行引擎是建立在处理器、硬件、指令集和操作系统层面之上,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
另外编译器能确定的重载版本并不是唯一的,往往是最合适的。例如:
static void sayHello(char c) {
System.out.println("hello char");
}
static void sayHello(byte c) {
System.out.println("hello byte");
}
static void sayHello(short i) {
System.out.println("hello short");
}
static void sayHello(int i) {
System.out.println("hello int");
}
static void sayHello(long l) {
System.out.println("hello long");
}
static void sayHello(float f) {
System.out.println("hello float");
}
static void sayHello(double f) {
System.out.println("hello double");
}
static void sayHello(Character character) {
System.out.println("hello character");
}
static void sayHello(Integer integer) {
System.out.println("hello integer");
}
static void sayHello(Object arg) {
System.out.println("hello object");
}
static void sayHello(char... chars) {
System.out.println("hello chars");
}
static void sayHello(Serializable e) {
System.out.println("hello Serializable");
}
static void sayHello(int... ints) {
System.out.println("hello ints");
}
public static void main(String[] args) {
sayHello('a');
}
上面的代码执行sayHello方法参数为char类型的'a',执行后打印hello char
,注释掉参数为char的方法。再执行会打印hello int
,因为char类型代表了其Unicode的十进制数值('a'为97)。之所以不会重载byte和short是因为他们的大小是不安全的。随后注释掉参数为int类型的方法,执行后打印hello long
。依此进行下去会依次按参数类型为char->int->long->float->double->Character(自动装箱)->Serializable(包装类实现了此接口)->Object(包装类父类)->char[](不定长参数)->int[]->....进行匹配,选出最优的类型进行编译。
这里的解析和分派之间并不是排他关系,比如静态方法会在类加载期解析,而静态方法显然可以拥有重载版本,选择重载版本的过程也是通过静态分派完成的。
动态分派
运行期根据实际类型确认方法执行版本的分派过程称为动态分派。
static abstract class Human{
abstract void sayHello();
}
static class Man extends Human {
@Override
void sayHello() {
System.out.println("hello man");
}
}
static class Woman extends Human{
@Override
void sayHello() {
System.out.println("hello woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
}
输出结果跟大多数的预期一样。通过javap命令查看上面的代码:
Last modified 2017-8-29; size 484 bytes
MD5 checksum ca9632cb6e56d5ef9eab197995316769
Compiled from "OverwriteTest.java"
public class OverwriteTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#22 // java/lang/Object."<init>":()V
#2 = Class #23 // OverwriteTest$Man
#3 = Methodref #2.#22 // OverwriteTest$Man."<init>":()V
#4 = Class #24 // OverwriteTest$Woman
#5 = Methodref #4.#22 // OverwriteTest$Woman."<init>":()V
#6 = Methodref #12.#25 // OverwriteTest$Human.sayHello:()V
#7 = Class #26 // OverwriteTest
#8 = Class #27 // java/lang/Object
#9 = Utf8 Woman
#10 = Utf8 InnerClasses
#11 = Utf8 Man
#12 = Class #28 // OverwriteTest$Human
#13 = Utf8 Human
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 main
#19 = Utf8 ([Ljava/lang/String;)V
#20 = Utf8 SourceFile
#21 = Utf8 OverwriteTest.java
#22 = NameAndType #14:#15 // "<init>":()V
#23 = Utf8 OverwriteTest$Man
#24 = Utf8 OverwriteTest$Woman
#25 = NameAndType #29:#15 // sayHello:()V
#26 = Utf8 OverwriteTest
#27 = Utf8 java/lang/Object
#28 = Utf8 OverwriteTest$Human
#29 = Utf8 sayHello
{
public OverwriteTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class OverwriteTest$Man
3: dup
4: invokespecial #3 // Method OverwriteTest$Man."<init>":()V
7: astore_1
8: new #4 // class OverwriteTest$Woman
11: dup
12: invokespecial #5 // Method OverwriteTest$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method OverwriteTest$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method OverwriteTest$Human.sayHello:()V
24: return
LineNumberTable:
line 22: 0
line 23: 8
line 24: 16
line 25: 20
line 26: 24
}
SourceFile: "OverwriteTest.java"
InnerClasses:
static #9= #4 of #7; //Woman=class OverwriteTest$Woman of class OverwriteTest
static #11= #2 of #7; //Man=class OverwriteTest$Man of class OverwriteTest
static abstract #13= #12 of #7; //Human=class OverwriteTest$Human of class OverwriteTest
通过查看上面分解的字节码文件,可以看出0-7行实例化Man并执行指令astore_1保存到局部变量表Slot1中,对应代码Human man = new Man();
,8-15行实例化Woman并并执行指令astore_2保存到局部变量表Slot 2中,对应代码Human woman = new Woman();
;16行aload_1指令将astore_1压入操作数栈顶;17和21行符号引用都是为Human.sayHello()方法(常量池中6#定义的Methodref符号引用),都是一样的。
那么问题来了,符号引用一样为什么实际执行的目标方法却不一样?原因就是invokevirtual指令多态查找解析过程中在运行期首先会确认栈顶的元素所指向的对象的实际类型,会把常量池中类方法的符号引用解析到不同的直接引用上去,这就是重写的本质。
单分派和多分派
方法的接受者和方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。单分派根据一个宗量对目标方法进行选择,多分派根据多个宗量对目标方法进行选择。
static class QQ{
}
static class _360{
}
static class Father{
void hardChoice(QQ qq) {
System.out.println("father choice QQ");
}
void hardChoice(_360 _360) {
System.out.println("father choice 360");
}
}
static class Son extends Father {
void hardChoice(QQ qq) {
System.out.println("son choice QQ");
}
void hardChoice(_360 _360) {
System.out.println("son choice 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new QQ());
son.hardChoice(new _360());
}
输出结果
father choice QQ
son choice 360
代码很简单,main方法中两次调用hardChoice()方法。
编译阶段编译器的选择过程,也就是静态分派的过程。这时目标方法依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这里的静态类型都是Father,最终产生两条invokevirtual指令,两条指令分别指向常量池中的Father.hardChoice(360)和Father.hardChoice(QQ)的方法符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
再看运行期阶段虚拟机的选择过程,也就是动态分派的过程。在执行son.hardChoice(new _360())
这句代码时,更准确的说是在执行这条代码对应的invokevirtual指令时,由于编译器已经确定目标方法的签名必须是hardChoice(QQ),唯一可以影响虚拟机选择的因素只有此方法的接受者实际类型是Father还是Son。因为只有一个总量作为选择依据,所以Java语言的动态分派属于单分派类型。
总之,Java语言是一门静态多分派,动态单分派的语言。
虚拟机动态分派的实现
前面介绍的分派过程对于虚拟机概念模型解析理解已经足够,让我们了解了分派中会“做什么”,但虚拟机具体实现是怎么做的,各种虚拟机会有些差别。
动态分派选择方法版本的过程需要运行时在类的方法元数据中搜索合适的目标方法,频繁搜索性能影响较大,因此具体的虚拟机实现都会采取优化手段。最常用是“稳定优化”手段是在类方法区中建立一个虚方法表(Virtual Method Table,简称vtable,与之对应在执行invokeinterface指令时也会用到接口方法表Interface Method Table,简称itable),使用虚拟机方法表索引代替元数据查找以提高性能。上节中代码对应的虚方法表如下图所示:
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化后,虚拟机会把该类的方法表也初始化完毕。