目录
在Java虚拟机(JVM)层面来看多态的本质,主要聚焦于方法调用的动态绑定机制。Java 中的多态性允许子类对象替代父类对象使用,且调用方法时能够调用到子类中重写的方法,这就是所谓的“运行时多态”。
一、解析
所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。调用表在程序代码写好、编译器运行编译那一刻就已经确定下来。这类方法调用被称为解析。
在 Java 语言中符合编译期可知,运行期不变这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问。这两种方法各自的特点决定了他们都不可能通过继承或别的方式重写其他版本,因此他们都是和在类加载阶段进行解析。
调用不同类型的方法,字节码指令集设计了不同的指令。
- invokestatic:用于调用静态方法
- invokespecial:用于调用实例构造器方法、私有方法和父类中的方法
- invokevirtual:用于调用所有虚方法
- invokeinterface:用于调用接口方法,会在运行时在确定一个实现该接口的对象
- invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 语言里符合这个条件的方法有静态方法、私有方法、实例构造器、父类方法四种,在加上被 final 修饰的方法(使用 invokevitual 指令调用),这 5 种方法会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法,与之相反的方法就是虚方法。
解析调用一定是一个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把设计的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种方法调用形式:分派(Dispatch)调用则复杂许多,可能是静态的也可能是动态的,可以分为单分派和多分派。组合起来分为静态单分派、静态多分派、动态单分派、动态多分派。
二、分派
Java 是一门面向对象的程序语言,因为具有面向对象的 3 个基本特征:继承、封装和多态。分派的调用过程将揭示多态性特征的基本体现。
2.1 静态分派
以代码为例,运行结果是什么?
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);
}
}
先猜测一下运行的结果是什么,在看结果,运行结果如下
hello, guy
hello, guy
虚拟机为什么会选择执行参数类型为 Human 的重载版本呢?在解决这个问题之前,我们先通过如下代码来定义两个关键概念:
Human man = new Man();
上述代码中的 Human 称为“静态类型”,或者叫外观类型,后面的 Man 称为变量的"实际类型"或者叫做运行时类型。静态类型和实际类型在程序中都可能发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
main() 里面两次 sayHello() 调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断依据的。由于静态类型在编译期可知,所以在编译阶段,Javac 编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了 sayHello(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 main(String[] args) {
sayHello('a');
}
}
2.2 动态分派
动态分派与重写(Override)有着密切的关联。在来看下面的案例,看一下运行结果是什么。
public class DynamicDispatch {
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();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
运行结果如下:
man say hello
woman say hello
woman say hello
我们把在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。在Java中字段永远不参与多态,当子类声明了与父类同名的字段时,虽然在子类的内部两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。
2.3 单分派和多分派
方法的接收者与方法的参数统称为方法宗量。根据分派基于多少总量,可以将分派划分为单分派和多分派两种。
总结,JVM层面的多态本质在于它提供了基于分派实现动态绑定的底层机制,确保了即使通过父类引用也能正确调用子类中重写的方法,从而实现了面向对象编程中的多态性。
往期经典推荐