在面向过程的语言中,每个方法调用都是固定且不变的,所以在连接阶段就可以唯一确定要调用哪个方法;而对于面向对象的语言,如:Java,有些方法必须要等到运行过程中才能确定,这也是为了支持语言的多态性
在 虚拟机栈 的「动态连接」部分介绍过,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
连接其实是将符号引用转化为直接引用的过程。有些符号引用在加载阶段或第一次使用的时候就被转化为直接引用 (如:静态常量,静态方法等),称为静态解析;而有些符号引用只有在每一次运行期间才会转化成直接引用,称为动态连接
所以本篇文章主要是为了介绍不同类型的方法调用具体是如何被确定!!
解析其实是发生在类加载过程的连接阶段,具体内容可见 类加载的过程
所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号,在类加载的解析阶段,会将一部分符号引用转化为直接引用
但是这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间不可改变。换句话说:调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来
在 Java 语言中符合「编译期可知,运行期不可变」这个要求的方法,主要有:静态方法 (static) 和私有方法 (private)
前者与类型直接关联,后者在外部不可被访问,这两个方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析
调用不同类型的方法,字节码指令集里设计了不同的指令。在 Java 虚拟机支持以下 5 条方法调用字节码指令,分别是:
invokestatic:调用静态方法
invokespecial:调用实例构造器<init>()
方法、私有方法、父类中的方法 (使用super
关键字调用的方法)
invokevirtual:调用所有虚方法
invokeinterface:调用接口方法,会在运行时再确定一个实现该接口的对象
invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
只要能被invokestatic
和invokespecial
指令调用的方法,都可以在解析阶段中确定唯一的调用版本
Java 中符合上面条件的方法有:静态方法、私有方法、实例构造器、父类方法;但有一个比较特殊:final (它使用invokevirtual
指令调用) 修饰的方法也会在解析阶段中确定唯一的调用版本
上面说到的 5 种方法统称为「非虚方法 (Non Virtual Method)」;与之相反,其他方法就被称为「虚方法 (Virtual Method)」
下面演示一下非虚方法的调用,先给出演示的源代码:
public class Father {
public void f1() {
System.out.println("Father public");
}
}
public class Son extends Father {
public void f1() {
System.out.println("Son public");
}
private void f2() {
System.out.println("Son private");
}
public static void f3() {
System.out.println("Son static");
}
public final void f4() {
System.out.println("Son final");
}
public void test() {
// 调用本类 f1 方法
f1();
// 调用父类 f1 方法
super.f1();
// 调用本类私有方法
f2();
// 调用 static 方法
Son.f3();
// 调用 final 方法
f4();
}
}
子类经过反编译后的代码:(为了节约篇幅,只挑出了test()
对应的反编译代码)
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokevirtual #8 // Method f1:()V // invokevirtual 虚方法
4: aload_0
5: invokespecial #9 // Method com/lfool/myself/Father.f1:()V // invokespecial 非虚方法
8: aload_0
9: invokespecial #10 // Method f2:()V // invokespecial 非虚方法
12: invokestatic #11 // Method f3:()V // invokespecial 非虚方法
15: aload_0
16: invokevirtual #12 // Method f4:()V // invokevirtual 非虚方法 (final 例外)
19: return
LineNumberTable:
line 23: 0
line 25: 4
line 27: 8
line 29: 12
line 31: 15
line 32: 19
LocalVariableTable:
Start Length Slot Name Signature
0 20 0 this Lcom/lfool/myself/Son;
上面介绍的解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转化为明确的直接引用,不必延迟到运行期间再去完成
下面要介绍的分派调用可能是静态的也可能是动态的,而本部分先介绍「静态分派」
先抛出一段面试中经常被问的代码:(下面的代码其实是一个方法重载的例子,关于方法重载的详细介绍可见 重载 vs 重写)
public class StaticDispatch {
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();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
// result
hello, guy!
hello, guy!
对于这个结果,是意外呢?还是在意料之中!!
解释原理之前,先介绍两个概念:静态类型、动态类型
Human man = new Man();
我们把等号前面的Human
称为变量 (此处的变量指 man) 的「静态类型 (Static Type)」或者「外观类型 (Apparent Type)」
我们把等号后面的Man
称为变量 (此处的变量指 man) 的「实际类型 (Actual Type)」或者「运行时类型 (Runtime Type)」
静态类型和实际类型在程序中都有可能发生变化,区别在于:
静态类型的变化仅仅在使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是在编译期可知的
实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化,虽然 (Man) human 变了,但是 human 本身并没有变
sr.sayHello((Man) human);
sr.sayHello((Woman) human);
先把让我们重新回到最上面的代码,虚拟机 (更准确的说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为判断依据
由于静态类型在编译期可知,所以在编译阶段,Javac 编译器就根据参数的静态类型决定了会使用哪个重载版本
因此选择了sayHello(Human guy)
作为调用目标,并把这个方法的符号引用写到main()
方法里的invokevirtual
指令的参数中
26: invokevirtual #13 // Method sayHello:(Lcom/lfool/myself/StaticDispatch$Human;)V // 调用的是参数为 Human 的方法
同时也引出了静态分派的定义:所有依赖静态类型来决定方法执行版本的分派动作。静态分派最典型应用表现就是方法重载
回过头去想一下重载的特点;方法名相同,参数列表不同。所以当我们去调用一个重载方法时,可以根据参数确定调用的是哪一个版本的重载方法,所以在编译期间可知!
但是在很多情况下选择重载的版本并不是「唯一」的,往往只能确定一个「相对更合适」的版本,如下面的例子:
public class Overload {
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
当我们注释掉方法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:上述情况仅出现在面试题中...😝
静态分配最典型应用表现是方法重载,而动态分派最典型应用表现就是方法重写!
同样的,先抛出一段代码:(下面的代码其实是一个方法重写的例子)
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
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();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
// result
man say hello
woman say hello
woman say hello
对于这个结果,应该没有上面重载的例子那么出乎意料~
显然这里选择调用的方法版本不是根据静态类型来决定,因为man
和woman
对应的静态类型都是Human
,而最后输出的结果分别对应Man
和Woman
中重写的sayHello()
方法
应该很明显可以看出,选择调用的方法版本是根据实际类型来决定的,那 Java 虚拟机是如何根据实际类型来分派执行版本的呢?
我们尝试从主函数对应的反编译代码中去找答案:
xxxxxxxxxx
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 #2 // class com/lfool/myself/DynamicDispatch$Man // 创建一个 Man 对象,并将其引用值压入栈顶
3: dup // 复制栈顶数值并将复制值压入栈顶
4: invokespecial #3 // Method com/lfool/myself/DynamicDispatch$Man."<init>":()V // 调用超类构造方法,实例初始化方法,私有方法
7: astore_1 // 将栈顶引用型数值存入第二个本地变量 (下标为 1)
8: new #4 // class com/lfool/myself/DynamicDispatch$Woman // 创建一个 Woman 对象,并将其引用值压入栈顶
11: dup // 复制栈顶数值并将复制值压入栈顶
12: invokespecial #5 // Method com/lfool/myself/DynamicDispatch$Woman."<init>":()V // 调用超类构造方法,实例初始化方法,私有方法
15: astore_2 // 将栈顶引用型数值存入第三个本地变量 (下标为 2)
// ------------------------------------- 上面是准备动作 -------------------------------------
16: aload_1 // 将第二个引用类型本地变量推送至栈顶 (Man)
17: invokevirtual #6 // Method com/lfool/myself/DynamicDispatch$Human.sayHello:()V // 调用实例方法
20: aload_2 // 将第三个引用类型本地变量推送至栈顶 (Woman)
21: invokevirtual #6 // Method com/lfool/myself/DynamicDispatch$Human.sayHello:()V // 调用实例方法
// ------------------------------------- 上面是关键部分 -------------------------------------
24: new #4 // class com/lfool/myself/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method com/lfool/myself/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method com/lfool/myself/DynamicDispatch$Human.sayHello:()V
36: return
LineNumberTable:
line 25: 0
line 26: 8
line 27: 16
line 28: 20
line 29: 24
line 30: 32
line 31: 36
LocalVariableTable:
Start Length Slot Name Signature
0 37 0 args [Ljava/lang/String;
8 29 1 man Lcom/lfool/myself/DynamicDispatch$Human;
16 21 2 woman Lcom/lfool/myself/DynamicDispatch$Human;
0 ~ 15 行 (字节码行号) 是准备动作,结合注释可以知道它的作用是建立man
和woman
的内存空间,调用Man
和Woman
类型的实例构造器,将这两个实例的引用放在下标为 1 和 2 的局部变量表的变量槽中,这些动作实际对应了 Java 源代码中的这两行:
xxxxxxxxxx
Human man = new Man();
Human woman = new Woman();
16 和 20 行的aload
指令分别把创建的两个对象推送至栈顶,这两个对象都是要执行sayHello()
方法的所有者,称为接收者
17 和 21 行是方法的调用指令,这两条指令一模一样,所以重点就在invokevirtual
指令,它运行时解析过程大致分为以下几步:
找到操作数栈顶第一个元素所指向的对象的实际类型,记作 C
如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError
异常
否则,按照继承关系从下往上一次对 C 的各个父类进行第二步的搜索和验证过程
如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError
异常
正是因为invokevirtual
指令执行的第一步就是在运行期间确定接收者的实际类型 (操作数栈顶第一个元素所指向的对象),所以两次调用中的invokevirtual
指令并不是直接把常量池中方法的符号引用解析道直接引用上就结束了,而是还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java 中方法重写的本质
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
回过头去想一下重写的特点;返回值类型,方法名,参数必须相同。所以在编译期间,并不能知道到底是调用哪一个对象的重写方法,能知道的只有需要调用方法的返回值类型,方法名,参数;需要在运行期间根据操作数栈顶第一个元素所指向的对象的实际类型来确定调用的方法
既然这种多态性的根源在于invokevirtual
指令的执行逻辑,那自然可以得出的结论就只会对方法有效,对字段无效,因为字段不使用这条指令
Java 中只有虚方法,字段永远不可能是虚的。换句话说:字段永远不参与多态,哪个类的方法调用访问某个名字的字段时,该名字指的就是这个类能看到的那个字段
当子类中声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。子类可以通过super
调用父类的字段,除private
类型
下面给出一个例子加深印象:
xxxxxxxxxx
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father guy = new Son();
System.out.println("This guy has $" + guy.money);
}
}
// result
I am Son, i have $0
I am Son, i have $4
This guy has $2
在创建Son
对象的时候,会隐式调用Father
的构造函数,而Father
构造函数中对showMeTheMoney()
的调用时一次虚方法调用,实际执行的版本是Son::showMeTheMoney()
方法,所以输出的是I am Son
虽然这个时候Father
中的money
字段已经被初始化成 2 了,但Son::showMeTheMoney()
方法中访问的却是子类的money
字段,这时候结果自然还是 0,因为它要等到子类的构造函数执行的时候才会被初始化
方法的接收者和方法的参数统称为方法的宗量
根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种
单分派是根据一个宗量对目标方法进行选择
多分派是根据多于一个宗量对目标方法进行选择
直接给出例子:
xxxxxxxxxx
public class Dispatch {
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());
}
}
// result
father choose 360
son choose qq
首先分析编译阶段中编译器的选择过程,也就是静态分派过程。这个时候选择的依据有两点:
静态类型:Father or Son
方法参数:QQ or 360
首先确定静态类型,然后在一个类中根据参数选择方法,这与方法重载的特点不谋而合
选择结果的最终产物是产生了两条invokevirtual
指令,两条指令的参数分别对应常量池中指向Father::hardChoice(360)
和Father::hardChoice(QQ)
方法的符号引用,具体如下:
xxxxxxxxxx
24: invokevirtual #8 // Method com/lfool/myself/Dispatch$Father.hardChoice:(Lcom/lfool/myself/Dispatch$_360;)V
35: invokevirtual #11 // Method com/lfool/myself/Dispatch$Father.hardChoice:(Lcom/lfool/myself/Dispatch$QQ;)V
因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型
再来分析运行阶段中虚拟机的选择,也就是动态分派过程。由于在编译阶段就已经确定了方法的签名,所以唯一可以影响虚拟机选择的因素只有一个:
方法的接收者:Father or Son
因为是根据一个宗量进行选择,所以 Java 语言的动态分派属于单分派类型
上面介绍了invokevirtual
指令背后所做的事情,但在实际的实现中可能会有差别!
动态分派动作十分的频繁,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法
基于执行性能的考虑,真正运行一般不会如此频繁地去反复搜索类型元数据,而是为类型在方法区建立一个虚方法表 (vtable);如果是接口,就会建立一个接口方法表 (itable)
使用虚方法表索引来替代元数据查找可以提高性能,虚方法表中存放着各个方法的实际入口地址
如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口就是一样的,都指向父类的实现入口
如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址
在上图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型
为了程序实现方便,具有相同签名的方法,在父类和子类的虚方法表中具有相同的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕