JVM-字节码执行引擎

前言

当启动Java程序后,Javac(Java compiler)会先把源文件编译成字节码文件,任何一个字节码文件都对应着一个类或接口的定义信息,之后再由字节码执行引擎对这些文件进行解析,输出执行结果,所以执行引擎到底做了哪些操作将会是本博文的主要讨论方向。

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从人栈到出栈的过程。

2021-9-6-1

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内
部定义的局部变量。

局部变量表的容量以变量槽( Variable Slot,下称Slot)为最小单位,每种类型的数据所需的Slot是不一样的,例如boolean、byte、 char、 short、int、float、 reference 或returnAddress类型的数据会占用一个Slot,而long、double类型的数据将会占用两个Slot。下面是一个简单示例:

2021-9-6-2

在上面的示例-1中,局部变量表将每个Slot都标上了索引值,索引值0存储的值为this——代表该方法所属对象实例的引用;参数a属于long类型,因此占用了2个Slot,索引值1和2的Slot均用来存储变量a,而参数b属于int类型,只占1个Slot,索引值为3,另外还可以观察到参数a和b在局部变量表的实际值分别为”J”、”I”,这实际上是个符号引用,用来代表参数的实际值,因为此时处于编译刚完成的阶段,参数a和b的实际值并不知道,只有当类被加载时或是方法被执行时才能知道参数的值到底是什么,之后符号引用将会转换为直接引用,如果是基本类型将会把实际值存在局部变量表中;索引值为4的Slot比较特殊,同时用来存储变量istr,至于为什么能这样做,是因为当程序计数器执行完代码块1中的相关字节码指令后,将会开始执行代码块2中的字节码指令,此时已经离开了变量i的作用域,虚拟机认为i不会再被使用了,于是被当作垃圾回收了,空出来的Slot将会继续用于存储变量str,这一行为被称为Slot的复用。另外可以看到变量str在局部变量表的实际值并不是”Hello,JVM!”,而是”#7”来代替,在编译段”Hello,JVM!”将会作为字符串常量存储在方法区的常量池中,在常量池中索引为7的内存空间用来存储常量”Hello,JVM!”(实际上在常量池的7号位置上并没有存储”Hello,JVM!”的实际值,而是被用来存储常量的数据类型以及实际值的索引),”#7”作为常量”Hello,JVM!”的符号引用将会在类加载时或是在执行该方法时被替换为直接引用——即实际的内存地址。

下面是该方法所在类对应的字节码文件中反编译后的部分内容,包括常量池以及该方法的相关信息:

常量池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = String #8 // Hello,JVM!
#8 = Utf8 Hello,JVM!
#9 = Class #10 // com/catnyan/entity/User
#10 = Utf8 com/catnyan/entity/User
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lcom/catnyan/entity/User;
#16 = Utf8 add
#17 = Utf8 (JI)V
#18 = Utf8 a
#19 = Utf8 J
#20 = Utf8 b
#21 = Utf8 I
#22 = Utf8 MethodParameters
#23 = Utf8 main
#24 = Utf8 ([Ljava/lang/String;)V
#25 = Utf8 args
#26 = Utf8 [Ljava/lang/String;
#27 = Utf8 SourceFile
#28 = Utf8 User.java

add方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void add(long, int);
descriptor: (JI)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=5, args_size=3
0: bipush 10
2: istore 4
4: ldc #7 // String Hello,JVM!
6: astore 4
8: return
LineNumberTable:
line 8: 0
line 11: 4
line 13: 8
LocalVariableTable: //局部变量表
Start Length Slot Name Signature
0 9 0 this Lcom/catnyan/entity/User;
0 9 1 a J
0 9 3 b I
MethodParameters:
Name Flags
a
b

操作数栈

  • 作用:在方法执行过程中,写入(进栈)和提取(出栈)各种字节码指令

  • 分配时期:同上,在编译时会在方法的 Code 属性的 max_stacks 数据项中确定操作数栈的最大深度

  • 栈容量:操作数栈的每一个元素可以是任意的 Java 数据类型 ——32 位数据类型所占的栈容量为 164 位数据类型所占的栈容量为 2

注意:操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译时编译器需要验证一次、在类校验阶段的数据流分析中还要再次验证

动态连接

  • 定义:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接

  • 静态解析和动态连接区别:

    Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用:

    • 一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态解析
    • 另一部分会在每一次运行期间转化为直接引用(动态连接

方法返回地址

方法退出的两种方式

  • 正常退出:执行中遇到任意一个方法返回的字节码指令
  • 异常退出:执行中遇到异常、且在本方法的异常表中没有搜索到匹配的异常处理器区处理

作用:

在方法返回时都可能在栈帧中保存一些信息,用于恢复上层方法调用者的执行状态

  • 正常退出时,调用者的 PC 计数器的值可以作为返回地址
  • 异常退出时,通过异常处理器表来确定返回地址

方法退出的执行操作:

  • 恢复上层方法的局部变量表和操作数栈
  • 若有返回值把它压入调用者栈帧的操作数栈中
  • 调整 PC 计数器的值以指向方法调用指令后面的一条指令等

在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部一起称为栈帧信息

方法调用

  • 方法调用是最普遍且频繁的操作
  • 任务:确定被调用方法的版本,即调用哪一个方法,不涉及方法内部的具体运行过程

下面详细介绍方法调用的类型

解析调用

特点:

  • 是静态过程
  • 在编译期间就完全确定,在类装载解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,而不会延迟到运行期再去完成,即编译期可知、运行期不变

适用对象:

private 修饰的私有方法,类静态方法,类实例构造器父类方法4

分派调用

在介绍不同类型的分派之前,首先需要了解一下静态类型和实际类型,下面是用代码示例进行说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//父类
public class Human {
}

//子类
public class Man extends Human {
}

public class Main {

public static void main(String[] args) {
//这里的 Human 是静态类型,Man 是实际类型
Human man = new Man();
}
}

这里定义变量man所用的类型为静态类型,引用的类型为实际类型

静态分派

  • 依赖静态类型来定位方法的执行版本
  • 典型应用是方法重载
  • 发生在编译阶段,不由 JVM 来执行

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 guy) {
System.out.println("Hello,gentleman!");
}

public void sayHello(Woman guy) {
System.out.println("Hello,lady!");
}


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

输出结果:

1
2
Hello,guy
Hello,guy

编译器在生成字节码指令的时候会根据变量的静态类型选择调用合适的方法

动态分派

  • 依赖动态类型来定位方法的执行版本
  • 典型应用是方法重写
  • 发生在运行阶段,由 JVM 来执行

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static class Father {
public void sayHello(){
System.out.println("hello world ---- father");
}
}

//继承 + 方法重写
static class Son extends Father {
@Override
public void sayHello(){
System.out.println("hello world ---- son");
}
}

public static void main(String[] args){
Father son = new Son();
son.sayHello();
}

输出结果:

1
hello world ---- son

查看字节码指令调用情况:

image-20210907194417356

疑惑来了,我们可以看到,JVM 选择调用的是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?

当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual 指令需要完成以下几个步骤才能实现对一个方法的调用:

2021-9-6-3

单分派和多分派

单分派:

根据一个宗量对目标方法进行选择(方法的接受者与方法的参数统称为方法的宗量)

多分派:

根据多于一个宗量对目标方法进行选择

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static class QQ{}

static class _360{}

public static class Father {
public void hardChoice(QQ arg) {
System.out.println("Father choose QQ");
}

public void hardChoice(_360 arg) {
System.out.println("Father choose 360");
}
}

public static class Son extends Father {
public void hardChoice(QQ arg) {
System.out.println("Son choose QQ");
}

public void hardChoice(_360 arg) {
System.out.println("Son choose 360");
}
}

public static void main(String[] args){
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}

在编译阶段调用哪个方法将会由编译器来选择,即静态分派的过程。这是选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ) 方法的符号引用(见下图)。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

image-20210907211522133

之后是在运行阶段究竟调用哪个方法将会由虚拟机进行选择,即动态分派的过程。在执行”son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。