0%

JVM是如何进行方法调用的 | 静态分派,动态分派

思考:

  1. JVM里面是如何进行方法调用的?
  2. 什么是静态分派?什么是动态分派?
  3. 怎么保证动态分派的执行效率?
  4. 重写和重载的执行原理?

我们知道方法调用会产生栈帧,存在虚拟机栈中,那么究竟JVM是如何进行方法调用的呢?

1、JVM调用指令与非虚方法

首先我们介绍一下JVM中5条方法调用的字节码指令,按类别分为非虚方法调用,虚方法调用:

方法调用类型 字节码指令 指令作用
非虚方法调用 invokestatic 调用静态方法
invokespecial 调用实例构造器<init>方法,私有方法和父类方法
虚方法调用 invokevirtual 调用所有的虚方法,注意:调用final方法为非虚方法
invokeinterface 调用接口方法,运行时确定一个实现此接口的对象
invokedynamic JDK 7实现“动态类型语言”(Dynamically Typed Language)支持而进行的,也是为JDK8实现Lambda提供了技术基础

**非虚方法:**在类加载的解析阶段就确定了唯一的调用版本,主要包括静态方法,私有方法,实例构造器和父类方法。

我们在 一篇图文彻底弄懂Class文件是如何被加载进JVM的#1.2.3、解析阶段 这篇文章里面提到,我们会在解析阶段把符号引用替换为直接引用,但这里并不是转换所有的符号引用,而是静态方法,私有方法,实例构造器和父类方法。

其他的虚方法调用,是在运行期再确定下来的,我们称为分派调用。

2、分派调用

2.1、静态分派

假设有如下类结构:

image-20200111102141997

我们执行:

1
2
3
Human man = new Man();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);

发现最终执行的是sayHello(Human)方法,而不是sayHello(Man)方法。

  • 上面的Human称为静态类型,静态类型在编译期可知;
  • Man称为变量的实际类型,实际类型在运行期才可以确定。

如下图,在编译期就根据静态类型确定了符号引用,实际执行的时候,会把符号引用转化为直接引用,调用sayHello(Human)方法:

image-20200111104140923

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 #7 // class com/itzhai/jvm/executeengine/分派/StaticDispatch$Man
3: dup
4: invokespecial #8 // Method com/itzhai/jvm/executeengine/分派/StaticDispatch$Man."<init>":()V
7: astore_1
8: new #9 // class com/itzhai/jvm/executeengine/分派/StaticDispatch
11: dup
12: invokespecial #10 // Method "<init>":()V
15: astore_2
16: aload_2
17: aload_1
18: invokevirtual #11 // Method sayHello:(Lcom/itzhai/jvm/executeengine/分派/StaticDispatch$Human;)V
21: return
...

其中16~18表示把本地变量表中的Man实例引用和StaticDispatch实例引用加载到操作数栈中,然后执行sayHello方法。虽然这里是Man实例的引用,但是在编译期就已经确定了要执行sayHelloStaticDispatch$Human版本的方法。

**静态分配:**这种依赖静态类型来定位方法执行版本的分派动作成为静态分派。最常见的使用场景是方法重载

思考

以下代码执行结果是什么,把sayHello的char方法注释掉呢,执行结果是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StaticDispatch2 {

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

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

public static void main(String[] args) {
StaticDispatch2 sd = new StaticDispatch2();
sd.sayHello('a');
}

}

2.2、动态分派

假设有如下类结构:

image-20200111110735387

我们执行:

1
2
3
4
public static void main(String[] args) {
Human man = new Man();
man.sayHello();
}

发下最终执行的是Man类的sayHello()方法。

如下图,在编译之后,生成如下字节码指令。可以发现只有在实际执行代码的时候,才会创建Human对象并且确定实际的对象类型,然后从本地变量表中获取到对象实例的引用压入操作数栈,交由invokevirtual指令去决定最终调用谁的方法。

image-20200111113845925

确定执行方法的过程:

image-20200111120618499

image-20200111120843572

**动态分派:**这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

如何保证动态分派的执行效率

在以上的流程中,如果动态分派很频繁,那么在运行时就需要频繁的分别在子类和父类的方法元数据中搜索合适的目标方法,基于性能的考虑,JVM会在方法区中维护一个虚方发表,使用虚方法表来代替元数据查找以提高性能。

Class文件16进制背后的秘密 这篇文章中,我们已经解读了Class文件的结构,并且在 一篇图文彻底弄懂Class文件是如何被加载进JVM的 这篇文章中,阐述了Class文件被加载到JVM的方法区之后,对应的产生一个运行时数据结构:

image-20200105102811544

其中上面的方法表就包含了虚方法表(virtual Method Table)。(在执行invokeinterface指令时,也会用到接口的方发表-Interface Method Table)。

假设有如下类结构:

image-20200111140742605

对应的方法表如下:

image-20200111141509563

因为Cat类中的eat()方法重写了,所以Cat类中的eat()方法是引用了Cat的类型数据;

Cat类中的sleep()方法没有重写,还是引用了Animal的类型数据;

其他的以此类推。

方发表何时初始化好的

方法表一般在类加载的连接阶段进行初始化,在准备了类变量的零值后,虚拟机就会把该类的方法表也初始化了。

2.3、静态(编译期)多分派

如下图的案例中,编译期生成Class文件的时候,同时需要根据两个维度来确定生成的方式:

  • 方法所对应的静态类型;
  • 参数对应的静态类型;

image-20200111124351766

因为是根据方法的接收者方法的参数两个宗量来确定生成的Class文件,所以Java的静态分派属于多分派类型。

2.4、动态(执行期)单分派

同样是上面的例子,我们在实际执行的代码的时候:

1
2
p.choice(new Gitlab());
sp.choice(new GitHub());

这个时候,不会根据方法的参数确定要执行什么方法,这个参数在编译期已经确定下来了,只会根据方法的接收者确定实际的类型,所以Java的动态分派属于单分派。

References

《深入理解Java虚拟机-JVM高级特性与最佳实践》

欢迎关注我的其它发布渠道