JVM学习笔记(四)——字节码执行引擎

时间:2021-07-15 17:59:26

代码编译的结果从机器码转变为字节码,是存储格式的一小步,确实编程语言发展的一大步。正是因为有了字节码这一中间格式才有了Java语言跨平台的特性。

字节码并不能直接基于物理机执行引擎执行,因为物理机执行引擎是建立在特定的处理器,指令集以及操作系统之上的,并不具备跨平台特性。所以执行字节码的责任就交给了虚拟机中的字节码执行引擎。

1 运行时栈帧结构

栈帧是用于刻画Java程序运行时一个方法的调用、执行以及返回过程的数据结构。通过学习前面的博客我们知道Java程序运行时有一块区域叫做虚拟机栈,而虚拟机栈中的元素就是栈帧。一个方法从调用到返回的过程就是一个栈帧从入栈到出栈的过程。

一个栈帧主要由以下4部分构成:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 返回地址

JVM学习笔记(四)——字节码执行引擎

1.1 局部表量表

局部变量表是一个变量值存储空间,用于存储方法参数以及方法内部局部变量。局部变量表的基本存储单位为slot,一个slot可以存放一个int、byte,char,boolean,reference等基本数据结构。

当执行一个方法时,虚拟机使用局部变量表完成从形参到实参的转变过程。如果执行的是实例方法,则局部变量表的0号slot用于存储方法所属实例对象的索引,即this。其余的方法参数则按照顺序从第1号slot开始存储;如果执行的是类方法,方法参数从第0号slot开始存储。

1.2 操作数栈

操作数栈用于存储字节码执行过程中的操作数。当一个方法刚开始执行时,其操作数栈是空的,方法在执行的过程中会有各种字节码指令往操作数栈中写入或读取操作数。举例来说,当执行一个整数加法的指令iadd时,执行引擎会将操作数栈栈顶的两个元素取出(出栈),相加获得结果后再压入栈。

1.3 动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

1.4 返回地址

当一个方法执行完成后有两种返回方式:

  • 正常返回:执行引擎执行到任意一个返回的字节码指令
  • 异常返回:在方法执行过程中遇到异常而退出。异常包括虚拟机内部异常以及代码中使用athrow字节码抛出的异常

无论以何种方式返回,方法退出前都需要回到方法被调用的位置。一般来说方法正常退出时,调用者PC计数器的值即为返回地址;异常退出时,返回地址通过异常处理器来确定。

2 方法调用

方法调用不是方法执行,方法调用的唯一任务就是确定方法执行的版本,并不涉及具体方法的执行。Class文件在编译的过程中并不包含传统编译中的连接,一切方法调用在编译期间只是符号引用而不是方法在实际执行时的内存地址入口。方法调用主要分为两种:

  • 解析调用
  • 分派调用

2.1 解析

所有方法调用的目标方法在Class文件中都只是一个符号引用,在类加载的过程中会将其中一部分符号引用转换成直接引用,能够转换的前提是:该方法在编译时即可确定其调用的版本,且该方法在运行期间是不会改变的。上述解析过程称为静态解析,而与之相对应的就是动态解析。符合静态解析标准的方法主要有以下几种:

  • 私有方法
  • 静态方法
  • 父类方法
  • 被final修饰的方法

可以看出,上述几种方法都是不支持覆写的,所以在编译期即可确认其执行版本,因而支持静态解析。

Java虚拟机一共提供了5个方法调用的指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器方法,私有方法和父类方法
  • invokevirtual:调用虚方法
  • invokeinterface: 调用接口方法,会在运行时再确定一个实现此接口的对象
  • invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的

invokestatic,invokespecial两个指令所调用的方法都是在编译期即可确定其唯一调用版本,符合这个条件的有静态方法,私有方法,实例构造器和父类方法四类。它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以统称为非虚方法,与之相反,其它方法就称为虚方法(除去final方法)。

Java中的非虚方法除了使用invokestatic与invokespecial指令调用的方法之后还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无须对方法接收都进行多态选择,又或者说多态选择的结果是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

静态调用一定是个静态过程,在编译期完全确定。

2.2 分派调用

与解析调用不同的是,分派调用既有静态分派也有动态分派。

2.2.1 静态分派

静态分派多发生在方法的重载上,来看下下面这个例子:

package com.xtayfjpk.jvm.chapter8;  

public class StaticDispatch {

static abstract class Human {

}
static class Man extends Human {

}
static class Woman extends Human {

}

public void sayHello(Human guy) {
System.out.println("hello guy...");
}
public void sayHello(Man man) {
System.out.println("hello man...");
}
public void sayHello(Woman woman) {
System.out.println("hello woman...");
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}

执行结果为:

hello guy...
hello guy...

为什么虚拟机执行的是public void sayHello(Human guy)呢?这里需要解释一个概念,首先来看下main方法中的前两行代码:

Human man = new Man();  
Human woman = new Woman();

一个实例对象有静态类型和实际类型两个类型,静态类型在编译时即确定而实际类型则需要到运行时才可确定。上述两个变量的静态类型均为Human,而实际类型则为ManWoman

静态类型在编译时即可确定并不是说静态类型不可改变,下面两行代码即可改变静态类型:

sd.sayHello((Man)man);  
sd.sayHello((Woman)woman);

由于虚拟机在编译重载方法调用指令时是通过参数的静态类型进行选择的,并且静态类型是在编译期即可确定的,所以在上述的例子中虚拟机执行的方法是public void sayHello(Human guy)

2.2.2 动态分派

与静态分派相对应的便是动态分派,动态分派的含义也较容易理解,即在运行时才确定方法执行的具体版本并进行分派。动态分派最典型的场景就是——方法重写。

package com.xtayfjpk.jvm.chapter8;  

public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

运行结果:

man say hello
woman say hello
woman say hello

在上述代码中,两个静态类型均为Human的对象调用相同的方法却实际上并没有执行相同的方法,说明其方法的分派并不是通过静态类型来确定,而是根据两个变量的实际类型来确定的。Java虚拟机是如何利用实际类型来分派方法的执行版本的呢?来看看上述代码的字节码:

public static void main(java.lang.String[]);  
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #16 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man
3: dup
4: invokespecial #18 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman
11: dup
12: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
24: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman
27: dup
28: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
36: return

第16-21行是关键部分,第16和第20两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将执行的sayHello()方法的所有者,称为接收者(Receiver),第17和第21两行是方法调用指令,单从字节码的角度来看,这两条调用指令无论是指令(都是invokevirtual)还是参数(都是常量池中Human.sayHello()的符号引用)都完全一样,但是这两条指令最终执行的目标方法并不相同,其原因需要从invokevirutal指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下步骤:

  • a.找到操作数栈顶的第一个元素所指向的对象实际类型,记作C。
  • b.如果在类型C中找到与常量中描述符和简单名称都相同的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过则返回java.lang.IllegalAccessError错误。
  • c.否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索与校验过程。
  • d.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError错误。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

2.2.3 动态分派实现

由于动态分派在Java程序运行过程中经常会出现,所以通常Java虚拟机在动态分派过程中并不是通过上述查找过程实现的,而是通过虚方法表实现的。

虚拟机为每个类构建了一个方法表,方法表中的每一项存放对应方法的实际入口地址。如果某个方法在子类中没有实现,则子类虚方法表中的该方法指向的是父类的该方法;相反则指向子类的该方法。因而动态分派的过程实际上就是查找虚方法表的过程。

另外为了实现上的方便,具有相同签名的方法,在父类,子类的虚方法表中应该具有一样的索引号,这样当类型转换时,仅需变更查找的方法表即。