性能文章>深入思考:JVM是如何进行方法调用的>

深入思考:JVM是如何进行方法调用的原创

1年前
281945

导读

1. JVM里面是如何进行方法调用的?

2. 什么是静态分派?什么是动态分派?

3. 怎么保证动态分派的执行效率?

4. 重写和重载的执行原理?

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

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

首先我们介绍一下JVM中5条方法调用的字节码指令,按类别分为非虚方法调用,虚方法调用:
非虚方法:在类加载的解析阶段就确定了唯一的调用版本,主要包括静态方法,私有方法,实例构造器和父类方法。
我们在 一篇图文彻底弄懂Class文件是如何被加载进JVM的 #1.2.3、解析阶段  这篇文章里面提到,我们会在解析阶段把符号引用替换为直接引用,但这里并不是转换所有的符号引用,而是静态方法,私有方法,实例构造器和父类方法。
其他的虚方法调用,是在运行期再确定下来的,我们称为分派调用。

2、分派调用

2.1、静态分派

假设有如下类结构:

我们执行:
1
2
3
Human man = new Man();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
发现最终执行的是 sayHello(Human) 方法,而不是 sayHello(Man) 方法。
  • 上面的Human称为静态类型,静态类型在编译期可知;

  • Man称为变量的实际类型,实际类型在运行期才可以确定。

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

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实例的引用,但是在编译期就已经确定了要执行 sayHello StaticDispatch$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、动态分派

假设有如下类结构:

我们执行:
1
2
3
4
public static void main(String[] args) {
Human man = new Man();
man.sayHello();
}
发现最终执行的是Man类的sayHello()方法。
如下图,在编译之后,生成如下字节码指令。 可以发现只有在实际执行代码的时候,才会创建Human对象并且确定实际的对象类型,然后从本地变量表中获取到对象实例的引用压入操作数栈,交由 invokevirtual 指令去决定最终调用谁的方法。

确定执行方法的过程:

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

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

在以上的流程中,如果动态分派很频繁,那么在运行时就需要频繁的分别在子类和父类的方法元数据中搜索合适的目标方法,基于性能的考虑,JVM会在方法区中维护一个虚方法表,使用虚方法表来代替元数据查找以提高性能。
在 Class文件16进制数字背后的秘密  这篇文章中,我们已经解读了Class文件的结构,并且在 一篇图文彻底弄懂Class文件是如何被加载进JVM的  这篇文章中,阐述了Class文件被加载到JVM的方法区之后,对应的产生一个运行时数据结构:

其中上面的 方法表 就包含了虚方法表(virtual Method Table)。 (在执行invokeinterface指令时,也会用到接口的方法表-Interface Method Table)。
假设有如下类结构:

对应的方法表如下:

因为Cat类中的eat()方法重写了,所以Cat类中的eat()方法是引用了Cat的类型数据;
Cat类中的sleep()方法没有重写,还是引用了Animal的类型数据;
其他的以此类推。
方法表何时初始化好的
方法表一般在类加载的连接阶段进行初始化,在准备了类变量的零值后,虚拟机就会把该类的方法表也初始化了。

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

如下图的案例中,编译期生成Class文件的时候,同时需要根据两个维度来确定生成的方式:
  • 方法所对应的静态类型(方法的接收者);

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

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

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

同样是上面的例子,我们在实际执行的代码的时候:
1
2
p.choice(new Gitlab());
sp.choice(new GitHub());
这个时候,不会根据方法的参数确定要执行什么方法,只会根据方法的接收者确定实际的类型。

References

《深入理解Java虚拟机-JVM高级特性与最佳实践》
欢迎关注微信公众号《Java架构杂谈》。
点赞收藏
arthinking
请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

从 Linux 内核角度探秘 JDK MappedByteBuffer

从 Linux 内核角度探秘 JDK MappedByteBuffer

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

5
4