Back
详情
详解什么是多态,以及JVM是如何实现的多态

什么是多态

多态(polymorphism)是面向对象编程的三大特性之一,它建立在继承的基础之上。

它有多种定义:

  • 一个对象变量可以指示多种实际类型的现象称为多态。
  • 多态是面向对象编程语言的重要特性,它允许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定

在Java中实现多态有两种方式:

  • 方法重载(类内部之间的多态):就是在类中可以创建多个方法,它们具有相同的名字,但可具有不同的参数列表、返回值类型。我们举个例子来解释,就是一对夫妇生了多胞胎,多胞胎之间外观相似,其实是不同的孩子。
  • 方法重写(父类与子类之间的多态):子类可继承父类中的方法,但有时子类并不想原封不动地继承父类的方法,而是想作一定的修改,这就需要采用方法的重写。重写的参数列表和返回类型均不可修改。我们再举个例子,就是子承父业,但是儿子有自己想法,对父亲得产业进行再投资的过程。

那么JVM内部是如何实现多态机制的呢?首先我们先了解一下JVM执行方法的一些内部机制。

运行时栈帧结构

Java虚拟机规范中,为所有的Java虚拟机字节码执行引擎规定了统一的输入输出:

  • 输入为字节码形式的二进制流。
  • 输出为执行结果。

在解释运行阶段,JVM以方法作为最基本的执行单元,栈帧是用于支持虚拟机进行方法调用和执行的数据结构,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。处于栈顶的栈帧就是当前栈帧,对应的方法就是正在运行的当前方法。

在这里我们以服务解释方法调用为前提,简单说明JVM的运行时栈帧结构。

  • 局部变量表。用于存放方法参数和方法内部定义的局部变量。
  • 操作数栈。一个后入先出的LIFO栈,辅助方法执行中的运算操作。
  • 动态连接。动态连接是一个指向运行时常量池中该栈帧所属方法的引用,指向的显然是一个符号引用。它的存在主要是支持方法调用过程中的动态连接
    • 方法调用中,符号引用一部分在类加载或者第一次使用时被转化成直接引用,这种转化称为静态解析
    • 另外一部分符号引用在每一次运行期间都转化为直接引用,这种转化称为动态连接
  • 方法返回地址。
    • 正常退出方法时,方法返回地址指向主调方法的PC计数器。
    • 异常退出方法时,方法返回地址指向异常处理表。
  • 附加信息。服务于调试、性能收集等等。

JAVA的方法调用

Java的class文件的编译过程中并不包含传统编译过程的链接阶段。

class文件中的方法都是以符号引用的形式存储的,而不是方法的入口地址

这个特性使得Java具有强大的动态拓展的能力,但同时也增加了Java方法调用过程的复杂性。

Java的方法需要在类加载期间或者运行期间才能确定真正的入口地址,即符号引用转换为直接引用。

JVM中提供了5种方法调用的字节码指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器方法、私有方法和父类方法
    • 在Java11以后,invokespecial已经常常不被用来调用私有方法。Java11及以后,类中的私有方法往往用invokevirtual来调用,接口中的私有方法往往用invokeinterface调用,invokespecial往往仅用于实例构造器方法和父类中的方法
  • invokevirtual:调用虚方法
  • invokeinterface:调用接口方法,在运行时确定实现该接口的对象
  • invokedynamic:先在运行时动态解析处调用点限定符所用的方法,然后再执行该方法。

虚方法与非虚方法

只要能被invokestatic和invokespecial指令调用的方法,都可以在类加载过程中的解析阶段确定唯一的调用版本,符合这个条件的方法有:静态方法、私有方法、实例构造器和父类方法,它们在类加载过程中的解析阶段就会将符号引用解析为该方法的直接引用。这些方法可被称为非虚方法(也就是不涉及多态的方法)。

而其他那些属于类的,需要在运行时动态确定调用版本的方法,我们称之为虚方法,最常见的虚方法就是普通的实例方法。

字节码方法解析过程

解析过程是JVM将常量池内的符号引用替换为直接引用的过程。

  • 符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用是可以直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。

《Java虚拟机规范》中明确要求在执行方法调用字节码指令之前,必须先对它们使用的符号引用进行解析。

即所有invoke...指令之前。由于对同一个符号引用收到多次解析请求是很常见的事,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。(invokedynamic有一些特殊性质,这里不做解释)。

方法解析第一步需要解析出方法表的class_index项中索引的方法所属的类或接口的符号引用(实际类型),如果解析成功,那么用C表示这个类,接下来虚拟机将按照以下步骤进行后续的方法搜索。

  1. 如果我们在解析一个类方法,但C是一个接口,直接抛出java.lang.IncompatibleClassChangeError异常。
    1. 如果我们在解析的是接口方法,但C是一个类,也抛出java.lang.IncompatibleClassChangeError异常。
  2. 如果通过了第一步,在C中查找是否有简单名称和描述符都与目标匹配的方法,有则返回直接引用
  3. 否则,依次在C的父类、接口列表、父接口中进行查找。如果找到则根据情况返回直接引用或者抛出java.lang.AbstractMethodError异常。
  4. 如果都找不到,说明方法查找失败。抛出java.lang.NoSuchMethodError
  5. 最后,如果成功返回了直接引用,就对这个方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError异常

由于invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程叫做动态分派。

动态分派的实现

由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机会进行优化。
常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table,在invokeinterface执行时也会用到接口方法表,Interface Method Table),使用虚方法表索引来替代元数据查找以提升性能

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址

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

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