Java中的多态
多态是面向对象的三大核心概念之一。多态简单来讲就是父类引用指向子类对象。
在Java中要实现多态需要满足三个必要条件:继承、重写、父类引用指向子类对象。
Parent p=new Child();
多态的引入带来了这写好处:
- 消除类型之间的耦合关系
- 可替换性
- 可扩充性
- 接口性
- 灵活性
- 简化性
JVM是如何实现多态的
JVM方法调用的背景知识
Java的方法调用
Java的class文件的编译过程中并不包含传统编译过程的链接阶段。class文件中的方法都是以符号引用的形式存储的,而不是方法的入口地址。这个特性使得Java具有强大的动态拓展的能力,但同时也增加了Java方法调用过程的复杂性。Java的方法需要在类加载期间或者运行期间才能确定真正的入口地址,即符号引用转换为直接引用。
JVM中提供了5种方法调用的字节码指令:
invokestatic:调用静态方法invokespecial:调用实例构造器<init>方法、私有方法和父类方法invokevirtual:调用虚方法invokeinterface:调用接口方法,会在运行是再确定一个实现此接口的对象invokedynamic:先在运行时动态解析处调用点限定符所用的方法,然后再执行该方法。
只要能被invokestatic和invokespecial指令调用的方法,都可以在类加载过程中的解析阶段确定唯一的调用版本,符合这个条件的方法有:静态方法、私有方法、实例构造器和父类方法,它们在类加载过程中的解析阶段就会将符号引用解析为该方法的直接引用。这些方法可被称为非虚方法(也就是不涉及多态的方法)。
因此方法调用可以分为两种,一种是类加载过程中的解析阶段完成的,另一种要在运行时完成,叫作分派。
分派
正是因为多态的存在,使得在判断方法调用的版本的时候会存在选择的问题,这也是分派阶段存在的原因。
静态分派
编译器会根据变量的静态类型作为判断的依据选择合适的重载版本。
1 | public class StaticDispatch { |
静态分派就是Java语言是心啊重载的本质。jvm通过静态类型来选择具体的方法,完成符号引用到直接引用的转换。
动态分派
动态分派与多态的重写有关。
1 | public class DynamicDispatch { |
查看字节码
1 | public static void main(java.lang.String[]); |
从字节码中我们可以看出在完成man和woman两个对象的创建之后,它将创建的对象压入栈中。
然后通过invokevirtual进行方法调用。
invokevirtual指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C。
- 如果在类型C中找到与常量池中描述符和简单名称一样的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,返回
java.lang.IllegalAccessError异常。 - 否则,按照继承关系从下到上依次对C的各个父类进行搜索和验证。
- 如果还没有找到合适的方法,则抛出
java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程叫做动态分派。
虚拟机是如何实现动态分配的
由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机会进行优化。常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table,在invokeinterface执行时也会用到接口方法表,Interface Method Table),使用虚方法表索引来替代元数据查找以提升性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类和子类的虚方法表中都应该具有一样的索引号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。