虚拟机字节码执行引擎

一、栈帧

定义

栈帧:的用于支持虚拟机方法调用方法执行的数据结构,它的虚拟机运行时数据区中虚拟机栈**的栈元素。每个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

结构

每一个栈帧都包含了:

  • 局部变量表
  • 操作数栈
  • 动态连接
  • 方法返回地址
  • 一些额外的附加信息。

一个线程中的方法调用连可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧对应的方法叫做当前方法。结构如下图

1

1、局部变量表

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

  • 在编译期间,就在class文件的Code属性中确定了需要分配的局部变量表的最大容量
  • 变脸表以槽为单位(Slot)。一般以32位位一个Slot,
    • 32位以内的数据类型:boolean、byte、char、short、int、float、reference和returnAdress占一个Slot
    • 64位的数据类型double和long占两个Slot,局部变量表为线程私有,即使占有两个Slot,不是原子操作也不会引起数据安全问题。
  • Slot是可以重用的
  • 定义局部变量必须初始化,不然不会编译通过

2、动态连接

在class文件的常量池中存在大量的符号引用,这些符号引用转换为直接引用有两种:

  • 静态解析:类加载或者第一次使用阶段完成,
  • 动态连接:在第一次运行期间完成

下面详细来介绍这两种方式。

二、方法调用

方法调用不等于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,即调用哪一个方法。

个人理解就是确定将符号引用转换为哪个直接引用。

1、解析

前面提到过符号引用会有一部分在类加载的解析阶段转化为直接引用,这种解析能成立的条件就是:

  1. 方法在程序真正运行之前就有一个可确定的调用版本
  2. 这个方法的调用版本在运行期是不可改变的

那么有哪些方法能符合这两个条件呢,首先先来看一下Java虚拟机中方法调用的5个字节码:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器init方法、私有方法和父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
  • invokedynamic

以上5个指令中,调用虚方法和接口方法显然会在运行时期才能确定,而invokedynamic的逻辑是由用户所设定的引导方法决定的,所以,只要是被invokestatic和invokespecial指令调用的方法,都可以在解析阶段就确定唯一的调用版本,因为前者与类型直接关联,后者在外部不可以被访问,它们的特点决定了不可能通过继承或者别的方式重写其他版本。

当然还有一种,就是final关键字修饰的方法,虽然它的通过invokevirtual来调用的,但是由于它无法被覆盖,所以也没有其他版本。

综上所述,这些在类加载的时候就会把符号引用解析为直接引用的方法被称为非虚方法。他们包括:

  • 静态方法
  • 私有方法
  • 实例构造器
  • 父类方法
  • final修饰的方法

而其他的方法则称为虚方法

解析是一个静态的过程,在编译期就可以完全确定,在类加载阶段就会完成符号引用到直接引用。

而分派则是一个动态则有可能是静态的也有可能是动态的。根据宗量数又可分为单分派和多分派,下面来详细研究。

2、分派

2.1、静态分派

首先来看一段代码:

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
public class HelloWorld {

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 static void sayHello(Woman guy) {
System.out.println("hello, lady");
}

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

这是关于重载的代码,两个输出都是hello,guy。接下来从原理上分析一下原因,也就是为什么会选择对应的方法。

####2.1.1 静态类型与实际类型

首先要明确两个概念,变量的静态类型和实际类型。

1
Human man = new Man();

在这段代码中,Human成为变量的静态类型,后面的Man成为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别在于:

  • 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译器是可知的。
  • 实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
1
2
3
4
5
6
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
hw.sayHello((Man)man);
hw.sayHello((Woman)man);

####2.1.2 方法的接受者

另一个需要明确的概念就是方法的接收者,比如在上面的代码中,两个方法的接受者就是对象hw。

2.1.3 静态分派

明确了上面几个概念后再来看一下这个代码,在main方法中,在两个sayHello的接收者已经是对象hw的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。而虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断依据的。

而静态类型是编译期可知的,因此,编译期在编译阶段会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派过程称作静态分派。静态分派的典型应用就是方法重载。

2.1.4 更多关于重载

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
31
32
33
34
public class HelloWorld {

public static void sayHello(Object arg) {
System.out.println("hello Object");
}

public static void sayHello(int arg) {
System.out.println("hello int");
}

public static void sayHello(long arg) {
System.out.println("hello long");
}

public static void sayHello(Character arg) {
System.out.println("hello Character");
}

public static void sayHello(char arg) {
System.out.println("hello char");
}

public static void sayHello(char... arg) {
System.out.println("hello char...");
}

public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}

public static void main(String[] args) {
sayHello('a');
}
}

代码的输出为hello char,因为找到了参数为cha的重载方法,那么接下来:

  • 如果注释掉char参数的方法,那么输出就会为int,因为’a’除了字符串,还可以表示数字97,索引int也可以适合
  • 如果注释掉int,那么输出就会为long,因为int匹配不到后自动向上转型为long。按照char-int-long-float-double这个顺序会一直转型上去,但是不会向下转型为byte或short
  • 如果注释掉long,会输出Character,因为char可以自动装箱
  • 如果注释掉Character,会输出Serializable,因为Character类实现了Serializable接口。
  • 如果注释掉Serializable,会输出Object,因为是上面的父类,如果有多个父类,就会从继承关系中从下向上搜索,越接近上层优先级越低。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HelloWorld {

public static void sayHello(Object arg) {
System.out.println("hello Object");
}

public static void sayHello(String s) {
System.out.println("hello String");
}

public static void main(String[] args) {
sayHello(null);//hello String
}
}

这是一道笔试题,输出的是hello String。

因为null可以转换为任意类型。所以按照上面说的继承关系中越上级优先级,会优先匹配String。如果注释掉String,就会输出Object。

2.2、动态分派

再来看一段代码:

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

public class HelloWorld {

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();//man say hello
woman.sayHello();//woman say hello
man = new Woman();
man.sayHello();//woman say hello
}
}

一段关于重写的代码,通过它来看一下动态分派的过程。

首先,按照上面静态分派的思路,前面两个方法的接收者都是Human,参数也一样(都没有参数),所以从二者在编译时期的符号引用应该是相同的,但是在运行期间却执行了不同的方法,原因就要从invokevirtual指令的多态查找过程说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素(即为方法的接收者)所指向的对象的实际类型,记作C
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常
  3. 否则按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
  4. 如果始终没有找到,则抛出java.lang.AbstractMethodError异常

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

2.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
31
32
33
public class HelloWorld {

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 choode 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 choode 360");
}
}

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

这里即有重写又有重载,所以从两个阶段来看

2.3.1 编译期

首先在编译阶段编译器的选择过程,即静态分派的过程。选择的依据有两点:

  • 静态类型是Father还是Son
  • 方法参数是QQ还是360

这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别指向常量池中Father.hardChoice(360)和Father.hardChoice(QQ)方法的符号引用。因为根据两个宗量进行选择,所以静态分派属于多分派类型。

2.3.2 运行期

再来看一下运行阶段虚拟机的选择,即动态分派的过程。再执行son.hardChoice的代码,准确来说是invokevirtual指令时,虚拟机不会关心参数到底是QQ还是360,唯一影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。所以动态分派属于单分派类型。

2.3.3 静态多分派 动态单分派

从上面可以看出,Java是属于静态多分派,动态单分派的语言。

分派类型 参考宗量 执行者 确定时期 引用类型
静态多分派 接收者的静态类型、参数的静态类型 编译器 编译期 符号引用
动态多分派 接收者的实际类型 虚拟机 运行期 实际引用

最后还有一点就是静态分派和解析,这两者并不是二选一的关系,它们是不同层次上的筛选、确定目标方法的过程。比如静态方法也可以重载,重载选择过程就是通过静态分派完成的,而同时它又是在运行期不可更改的,所以解析过程就直接把符号引用转换成直接引用了。