面向对象-多态

多态

第一节 简介

1.1 什么是多态

  多态,按照字面意思就是具有多种形态,用来描述同一个行为具有多种表现形式或形态能力,作为面向对象的三个特性(继承、封装和多态)之一。

  多态就是同一个接口,使用不同的实例而执行不同操作,多态性是对象多种表现形式的体现,同一个事件发生在不同的对象上会产生不同的结果。

1.2 优点

  • 消除类型之间的耦合关系
  • 可替换性
  • 可扩充性
  • 接口性
  • 灵活性
  • 简化性

1.3 在Java中的实现方式

多态存在的三个必要条件:

  • 继承
  • 重写
  • 父类引用指向子类对象

  多态又可以分为编译时多态运行时多态

Java中使用多态特性的主要有:

  • 方法重载(overload):同一个类中可以创建多个具有相同名字的方法,但可具有不同的参数列表、返回值类型,调用方法时通过传递的参数类型来决定具体使用哪个方法,体现了多态性。是一种编译时多态,运行时调用的是确定的方法。
  • 方法重写(overrite):子类可继承父类中的方法,但有时子类并不想原封不动地继承父类的方法,而是想作一定的修改,这就需要采用方法的重写。重写的参数列表和返回类型均不可修改,要想调用父类中被重写的方法,则必须使用关键字super。是一种运行时多态,在运行时有不同的执行效果。
  • 实现接口:对接口方法的实现,是一种运行时多态
  • 实现抽象类的一个方法:是一种运行时多态

第二节 实现原理

  多态的底层实现是动态绑定,即在运行时才把方法调用与方法实现关联起来,通过方法表来实现。动态绑定涉及到一些JVM知识,如果对JVM不够了解可以先补充一些相关知识。

2.1 虚函数

  虚函数的存在是为了多态。

  Java 中其实没有虚函数的概念,它的普通函数就相当于 C++ 的虚函数,动态绑定是Java的默认行为。如果 Java 中不希望某个函数具有虚函数特性,可以加上 final 关键字变成非虚函数。

2.2 静态绑定与动态绑定

JVM 的方法调用指令有五个,分别是:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器< init >方法、私有方法和父类方法
  • invokevirtual:调用虚方法
  • invokeinterface:调用接口方法,运行时确定具体实现
  • invokedynamic:运行时动态解析所引用的方法,然后再执行,用于支持动态类型语言。

  其中,invokestatic 和 invokespecial 用于静态绑定,invokevirtual 和 invokeinterface 用于动态绑定。可以看出,动态绑定主要应用于虚方法和接口方法。

  静态绑定在编译期就已经确定,这是因为静态方法、构造器方法、私有方法和父类方法可以唯一确定。这些方法的符号引用在类加载的解析阶段就会解析成直接引用。因此这些方法也被称为非虚方法,与之相对的便是虚方法。

  动态绑定是指在执行期间(非编译期)判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。

  虚方法的方法调用与方法实现的关联(也就是分派)有两种,一种是在编译期确定,被称为静态分派,比如方法的重载一种是在运行时确定,被称为动态分派,比如方法的重写。对象方法基本上都是虚方法。

  这里需要特别说明的是,final 方法由于不能被覆盖,可以唯一确定,因此 Java 语言规范规定 final 方法属于非虚方法,但仍然使用 invokevirtual 指令调用。静态绑定、动态绑定的概念和虚方法、非虚方法的概念是两个不同的概念。

2.3 虚拟机中多态的实现

  虚拟机栈中会存放当前方法调用的栈帧,在栈帧中,存储着局部变量表、操作栈、动态连接、返回地址和其他附加信息。多态的实现过程,就是方法调用动态分派的过程,通过栈帧的信息去找到被调用方法的具体实现,然后使用这个具体实现的直接引用完成方法调用。

2.3.1 调用虚方法的执行过程

  以 invokevirtual 指令为例,在执行时,大致可以分为以下几步:

  1. 先从操作栈中找到对象的实际类型class;
  2. 找到class中与被调用方法签名相同的方法,如果有访问权限就返回这个方法的直接引用,如果没有访问权限就报错 java.lang.IllegalAccessError;
  3. 如果第 2 步找不到相符的方法,就去搜索 class 的父类,按照继承关系自下而上依次执行第 2 步的操作;
  4. 如果第 3 步找不到相符的方法,就报错java.lang.AbstractMethodError;

  可以看到,如果子类覆盖了父类的方法,则在多态调用中,动态绑定过程会首先确定实际类型是子类,从而先搜索到子类中的方法。这个过程便是方法覆盖的本质。

  实际上,商用虚拟机为了保证性能,通常会使用虚方法表接口方法表,而不是每次都执行一遍上面的步骤。以虚方法表为例,虚方法表在类加载的解析阶段填充完成,其中存储了所有方法的直接引用。也就是说,动态分派在填充虚方法表的时候就已经完成了。

  在子类的虚方法表中,如果子类覆盖了父类的某个方法,则这个方法的直接引用指向子类的实现;而子类没有覆盖的那些方法,比如 Object 的方法,直接引用指向父类或 Object 的实现。

2.3.2 调用重写的虚方法

  假设有类A和类B,B为A的子类,并重写了类A的method()方法,两个方法分别打印不同的输出。根据下列调用猜想一下实际的输出。

1
2
3
4
A a = new B();
B b = new B();
a.method();
b.method();

  很明显最终都会打印B中method()的输出,编译时通过A类的method()验证,运行时却实际调用的B类的method()。因为不管编译时引用变量是什么数据类型,最终在方法表中存放的仍是运行时要执行的引用。

2.3.3 方法表在方法调用中的工作

  在JVM执行Java字节码时,类型信息被存放在方法区中,通常为了优化对象调用方法的速度,方法区的类型信息中增加一个指针,该指针指向一张记录该类方法入口的表(称为方法表),表中的每一项都是指向相应方法的指针。

  由于Java的单继承机制,一个类只能继承一个父类,而所有的类又都继承自Object类。方法表中最先存放的是Object类的方法,接下来是该类的父类的方法,最后是该类本身的方法。这里关键的地方在于,如果子类改写了父类的方法,那么子类和父类的那些同名方法共享一个方法表项,都被认作是父类的方法。

注意这里只有非私有的实例方法才会出现,并且静态方法也不会出现在这里,原因很容易理解:静态方法跟对象无关,可以将方法地址直接引用,而不像实例方法需要间接引用。

更深入地讲,静态方法是由虚拟机指令invokestatic调用的,私有方法和构造函数则是由invokespecial指令调用,只有被invokevirtual和invokeinterface指令调用的方法才会在方法表中出现。

  由于以上方法的排列特性(Object——父类——子类),使得方法表的偏移量总是固定的。例如,对于任何类来说,其方法表中equals方法的偏移量总是一个定值,所有继承某父类的子类的方法表中,其父类所定义的方法的偏移量也总是一个定值。

  前面说过,方法表中的表项都是指向该类对应方法的指针,这里就开始了多态的实现:在父类A的方法表中,method方法的指针指向的就是A的method方法入口。而对于子类B来说,它的方法表中的method方法则会指向其自身的method方法而非其父类的(这在类加载器载入该类时已经保证,同时JVM会保证总是能从对象引用指向正确的类型信息)。

  结合方法指针偏移量是固定的以及指针总是指向实际类的方法域,我们不难发现多态的机制就在这里:在调用方法时,实际上必须首先完成实例方法的符号引用解析,结果是该符号引用被解析为方法表的偏移量。虚拟机通过对象引用得到方法区中类型信息的入口,查询类的方法表,当将子类对象声明为父类类型时,形式上调用的是父类方法,此时虚拟机会从实际类的方法表(虽然声明的是父类,但是实际上这里的类型信息中存放的是子类的信息)中查找该方法名对应的指针(这里用“查找”实际上是不合适的,前面提到过,方法的偏移量是固定的,所以只需根据偏移量就能获得指针),进而就能指向实际类的方法了。

2.3.4 实现接口的多态

  上上面的过程仅仅是利用继承实现多态的内部机制,多态的另外一种实现方式:实现接口相比而言就更加复杂,原因在于,Java的单继承保证了类的线性关系,而接口可以同时实现多个,这样光凭偏移量就很难准确获得方法的指针。所以在JVM中,多态的实例方法调用实际上有两种指令:

  • invokevirtual指令用于调用声明为类引用的方法。
  • invokeinterface指令用于调用声明为接口的方法。

  当使用invokeinterface指令调用方法时,就不能采用固定偏移量的办法,只能老老实实挨个找了(当然实际实现并不一定如此,JVM规范并没有规定究竟如何实现这种查找,不同的JVM实现可以有不同的优化算法来提高搜索效率)。我们不难看出,在性能上,调用接口引用的方法通常总是比调用类的引用的方法要慢。这也告诉我们,在类和接口之间优先选择接口作为设计并不总是正确的。


参考:

🔗 Java多态