方法调用

在面向过程的语言中,每个方法调用都是固定且不变的,所以在连接阶段就可以唯一确定要调用哪个方法;而对于面向对象的语言,如:Java,有些方法必须要等到运行过程中才能确定,这也是为了支持语言的多态性

虚拟机栈 的「动态连接」部分介绍过,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接

连接其实是将符号引用转化为直接引用的过程。有些符号引用在加载阶段或第一次使用的时候就被转化为直接引用 (如:静态常量,静态方法等),称为静态解析;而有些符号引用只有在每一次运行期间才会转化成直接引用,称为动态连接

所以本篇文章主要是为了介绍不同类型的方法调用具体是如何被确定!!

解析

解析其实是发生在类加载过程的连接阶段,具体内容可见 类加载的过程

所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号,在类加载的解析阶段,会将一部分符号引用转化为直接引用

但是这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间不可改变。换句话说:调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来

在 Java 语言中符合「编译期可知,运行期不可变」这个要求的方法,主要有:静态方法 (static) 和私有方法 (private)

前者与类型直接关联,后者在外部不可被访问,这两个方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析

调用指令

调用不同类型的方法,字节码指令集里设计了不同的指令。在 Java 虚拟机支持以下 5 条方法调用字节码指令,分别是:

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本

Java 中符合上面条件的方法有:静态方法、私有方法、实例构造器、父类方法;但有一个比较特殊:final (它使用invokevirtual指令调用) 修饰的方法也会在解析阶段中确定唯一的调用版本

上面说到的 5 种方法统称为「非虚方法 (Non Virtual Method)」;与之相反,其他方法就被称为「虚方法 (Virtual Method)」

下面演示一下非虚方法的调用,先给出演示的源代码:

子类经过反编译后的代码:(为了节约篇幅,只挑出了test()对应的反编译代码)

静态分派

上面介绍的解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转化为明确的直接引用,不必延迟到运行期间再去完成

下面要介绍的分派调用可能是静态的也可能是动态的,而本部分先介绍「静态分派」

先抛出一段面试中经常被问的代码:(下面的代码其实是一个方法重载的例子,关于方法重载的详细介绍可见 重载 vs 重写)

对于这个结果,是意外呢?还是在意料之中!!

解释原理之前,先介绍两个概念:静态类型动态类型

我们把等号前面的Human称为变量 (此处的变量指 man) 的「静态类型 (Static Type)」或者「外观类型 (Apparent Type)」

我们把等号后面的Man称为变量 (此处的变量指 man) 的「实际类型 (Actual Type)」或者「运行时类型 (Runtime Type)」

静态类型和实际类型在程序中都有可能发生变化,区别在于:

先把让我们重新回到最上面的代码,虚拟机 (更准确的说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为判断依据

由于静态类型在编译期可知,所以在编译阶段,Javac 编译器就根据参数的静态类型决定了会使用哪个重载版本

因此选择了sayHello(Human guy)作为调用目标,并把这个方法的符号引用写到main()方法里的invokevirtual指令的参数中

同时也引出了静态分派的定义:所有依赖静态类型来决定方法执行版本的分派动作。静态分派最典型应用表现就是方法重载

回过头去想一下重载的特点;方法名相同,参数列表不同。所以当我们去调用一个重载方法时,可以根据参数确定调用的是哪一个版本的重载方法,所以在编译期间可知!

但是在很多情况下选择重载的版本并不是「唯一」的,往往只能确定一个「相对更合适」的版本,如下面的例子:

很显然,它会输出hello char

当我们注释掉方法sayHello(char arg),它会输出hello int

当我们继续注释掉方法sayHello(int arg),它会输出hello long

当我们继续注释掉方法sayHello(long arg),它会输出hello Character

当我们继续注释掉方法sayHello(Character arg),它会输出hello Serializable

当我们继续注释掉方法sayHello(Serializable arg),它会输出hello Object

当我们继续注释掉方法sayHello(Object arg),它会输出hello char...

来梳理一下选择重载版本的顺序:char -> int -> long -> Character -> Serializable -> Object -> char...

之所以会选择 Serializable 对应的方法,是因为 Character 实现了 Serializable 接口

PS:上述情况仅出现在面试题中...😝

动态分派

静态分配最典型应用表现是方法重载,而动态分派最典型应用表现就是方法重写!

同样的,先抛出一段代码:(下面的代码其实是一个方法重写的例子)

对于这个结果,应该没有上面重载的例子那么出乎意料~

显然这里选择调用的方法版本不是根据静态类型来决定,因为manwoman对应的静态类型都是Human,而最后输出的结果分别对应ManWoman中重写的sayHello()方法

应该很明显可以看出,选择调用的方法版本是根据实际类型来决定的,那 Java 虚拟机是如何根据实际类型来分派执行版本的呢?

我们尝试从主函数对应的反编译代码中去找答案:

0 ~ 15 行 (字节码行号) 是准备动作,结合注释可以知道它的作用是建立manwoman的内存空间,调用ManWoman类型的实例构造器,将这两个实例的引用放在下标为 1 和 2 的局部变量表的变量槽中,这些动作实际对应了 Java 源代码中的这两行:

16 和 20 行的aload指令分别把创建的两个对象推送至栈顶,这两个对象都是要执行sayHello()方法的所有者,称为接收者

17 和 21 行是方法的调用指令,这两条指令一模一样,所以重点就在invokevirtual指令,它运行时解析过程大致分为以下几步:

正是因为invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型 (操作数栈顶第一个元素所指向的对象),所以两次调用中的invokevirtual指令并不是直接把常量池中方法的符号引用解析道直接引用上就结束了,而是还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java 中方法重写的本质

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

回过头去想一下重写的特点;返回值类型,方法名,参数必须相同。所以在编译期间,并不能知道到底是调用哪一个对象的重写方法,能知道的只有需要调用方法的返回值类型,方法名,参数;需要在运行期间根据操作数栈顶第一个元素所指向的对象的实际类型来确定调用的方法

既然这种多态性的根源在于invokevirtual指令的执行逻辑,那自然可以得出的结论就只会对方法有效,对字段无效,因为字段不使用这条指令

Java 中只有虚方法,字段永远不可能是虚的。换句话说:字段永远不参与多态,哪个类的方法调用访问某个名字的字段时,该名字指的就是这个类能看到的那个字段

当子类中声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。子类可以通过super调用父类的字段,除private类型

下面给出一个例子加深印象:

在创建Son对象的时候,会隐式调用Father的构造函数,而Father构造函数中对showMeTheMoney()的调用时一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出的是I am Son

虽然这个时候Father中的money字段已经被初始化成 2 了,但Son::showMeTheMoney()方法中访问的却是子类的money字段,这时候结果自然还是 0,因为它要等到子类的构造函数执行的时候才会被初始化

单分派与多分派

方法的接收者和方法的参数统称为方法的宗量

根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种

直接给出例子:

首先分析编译阶段中编译器的选择过程,也就是静态分派过程。这个时候选择的依据有两点:

首先确定静态类型,然后在一个类中根据参数选择方法,这与方法重载的特点不谋而合

选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别对应常量池中指向Father::hardChoice(360)Father::hardChoice(QQ)方法的符号引用,具体如下:

因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型

再来分析运行阶段中虚拟机的选择,也就是动态分派过程。由于在编译阶段就已经确定了方法的签名,所以唯一可以影响虚拟机选择的因素只有一个:

因为是根据一个宗量进行选择,所以 Java 语言的动态分派属于单分派类型

动态分派的实现

上面介绍了invokevirtual指令背后所做的事情,但在实际的实现中可能会有差别!

动态分派动作十分的频繁,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法

基于执行性能的考虑,真正运行一般不会如此频繁地去反复搜索类型元数据,而是为类型在方法区建立一个虚方法表 (vtable);如果是接口,就会建立一个接口方法表 (itable)

使用虚方法表索引来替代元数据查找可以提高性能,虚方法表中存放着各个方法的实际入口地址

8

在上图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型

为了程序实现方便,具有相同签名的方法,在父类和子类的虚方法表中具有相同的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址

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