Java的多态(深刻版)

前言

面向对象的三大特性:封装、继承、多态。在这三个特性中,若是没有封装和继承,也不会有多态。html

那么多态实现的途径和必要条件是什么呢?以及多态中的重写和重载在JVM中的表现是怎么样?java

(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)segmentfault

多态

多态是同一个行为具备多个不一样表现形式或形态的能力。

举个栗子,一只鸡能够作成白切鸡、豉油鸡、吊烧鸡、茶油鸡、盐焗鸡、葱油鸡、手撕鸡、清蒸鸡、叫花鸡、啤酒鸡、口水鸡、香菇滑鸡、盐水鸡、啫啫滑鸡、鸡公煲等等。ide

多态实现的必要条件

用上面的“鸡的十八种吃法“来举个栗子。spa

首先,咱们先给出一只鸡:code

class Chicken{  
     public void live(){  
        System.out.println("这是一只鸡");  
     }  
}

1. 子类必须继承父类

对于子类必须继承父类,小编我的认为,是由于按照面向对象的五大基本原则所说的中的依赖倒置原则:抽象不依赖于具体,具体依赖于抽象。既然要实现多态,那么一定有一个做为"抽象"类来定义“行为”,以及若干个做为"具体"类来呈现不一样的行为形式或形态。htm

因此咱们给出的一个具体类——白切鸡类:对象

class BaiqieChicken extends Chicken{ }

但仅是定义一个白切鸡类是不够的,由于在此咱们只能作到复用父类的属性和行为,而没有呈现出行为上的不一样的形式或形态blog

2. 必须有重写

重写,简单地理解就是从新定义的父类方法,使得父类和子类对同一行为的表现形式各不相同。咱们用白切鸡类来举个栗子。继承

class BaiqieChicken extends Chicken{   
     public void live(){  
        System.out.println("这是一只会被作成白切鸡的鸡");  
     }  
}

这样就实现了重写,鸡类跟白切鸡类在live()方法中定义的行为不一样,鸡类是一只命运有着无限可能的鸡,而白切鸡类的命运就是作成一只白切鸡。

可是为何还要有“父类引用指向子类对象”这个条件呢?

3. 父类引用指向子类对象

其实这个条件是面向对象的五大基本原则里面的里氏替换原则,简单说就是父类能够引用子类,但不能反过来。

当一只鸡被选择作白切鸡的时候,它的命运就不是它能掌控的。

Chicken c = new BaiqieChicken();  
c.live();

运行结果:

这是一只会被作成白切鸡的鸡

为何要有这个原则?由于父类对于子类来讲,是属于“抽象”的层面,子类是“具体”的层面。“抽象”能够提供接口给“具体”实现,可是“具体”凭什么来引用“抽象”呢?并且“子类引用指向父类对象”是不符合“依赖倒置原则”的。

当一只白切鸡想回头从新选择本身的命运,抱歉,它已经在锅里,逃不出去了。

BaiqieChicken bc = new Chicken();  //这句是运行不了的  
bc.live();

多态的实现途径

多态的实现途径有三种:重写、重载、接口实现,虽然它们的实现方式不同,可是核心都是:同一行为的不一样表现形式

1. 重写

重写,指的是子类对父类方法的从新定义,可是子类方法的参数列表和返回值类型,必须与父类方法一致!因此能够简单的理解,重写就是子类对父类方法的核心进行从新定义。

举个栗子:

class Chicken{  
     public void live(String lastword){  
        System.out.println(lastword);  
     }  
}  
class BaiqieChicken extends Chicken{  
     public void live(String lastword){  
         System.out.println("这只白切鸡说:");  
         System.out.println(lastword);  
     }  
}

这里白切鸡类重写了鸡类的live()方法,为何说是重写呢?由于白切鸡类中live()方法的参数列表和返回值与父类同样,但方法体不同了。

2. 重载

重载,指的是在一个类中有若干个方法名相同,但参数列表不一样的状况,返回值能够相同也能够不一样的方法定义场景。也能够简单理解成,同一行为(方法)的不一样表现形式。

举个栗子:

class BaiqieChicken extends Chicken{  
     public void live(){  
        System.out.println("这是一只会被作成白切鸡的鸡");  
     }  
     public void live(String lastword){  
         System.out.println("这只白切鸡说:");  
         System.out.println(lastword);  
     }  
}

这里的白切鸡类中的两个live()方法,一个无参一个有参,它们对于白切鸡类的live()方法的描述各不相同,但它们的方法名都是live。通俗讲,它们对于白切鸡鸡生的表现形式不一样。

3. 接口实现

接口,是一种没法被实例化,但能够被实现的抽象类型,是抽象方法的集合,多用做定义方法集合,而方法的具体实现则交给继承接口的具体类来定义。因此,接口定义方法,方法的实如今继承接口的具体类中定义,也是对同一行为的不一样表现形式

interface Chicken{  
    public void live();  
}  
class BaiqieChicken implements Chicken{  
     public void live(){  
        System.out.println("这是一只会被作成白切鸡的鸡");  
     }  
}  
class ShousiChicken implements Chicken{  
     public void live(){  
        System.out.println("这是一只会被作成手撕鸡的鸡");  
     }  
}

从上面咱们能够看到,对于鸡接口中的live()方法,白切鸡类和手撕鸡类都有本身对这个方法的独特的定义。

在虚拟机中多态如何表现

前文咱们知道,java文件在通过javac编译后,生成class文件以后在JVM中再进行编译后生成对应平台的机器码。而JVM的编译过程当中体现多态的过程,在于选择出正确的方法执行,这一过程称为方法调用

方法调用的惟一任务是肯定被调用方法的版本,暂时还不涉及方法内部的具体运行过程。(注:方法调用不等于方法执行)

在介绍多态的重载和重写在JVM的实现以前,咱们先简单了解JVM提供的5条方法调用字节码指令:

invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
invokevirtual:调用全部的虚方法(这里的虚方法泛指除了invokestatic、invokespecial指令调用的方法,以及final方法)。
invokeinterface:调用接口方法,会在运行时再肯定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所应用的方法(说人话就是用于动态指定运行的方法)。

而方法调用过程当中,在编译期就能肯定下来调用方法版本的静态方法、实例构造器<init>方法、私有方法、父类方法和final方法(虽是由invokevirtual指令调用)在编译期就已经完成了运行方法版本的肯定,这是一个静态的过程,也称为解析调用

分派调用则有多是静态的也多是动态的,可能会在编译期发生或者运行期才肯定运行方法的版本。

而分派调用的过程与多态的实现有着紧密联系,因此咱们先了解一下两个概念:

静态分派:全部依赖静态类型来定位方法执行版本的分派动做。
动态分派:根据运行期实际类型来定位方法执行版本的分派动做。

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);  
     }  
}

想一想以上代码的运行结果是什么?3,2,1,运行结果以下:

hello, guy!  
hello, guy!

为何会出现这样的结果?让咱们来看这行代码:

Human man = new Man();

根据里氏替换原则,子类必须可以替换其基类,也就是说子类相对于父类是“具体类”,而父类是处于“奠基”子类的基本功能的地位。

因此,咱们把上面代码中的“Human”称为变量man的静态类型(Static Type),然后面的"Man"称为变量的实际类型(Actual Type),两者的区别在于,静态类型是在编译期可知的;而实际类型的结果在运行期才能肯定,编译期在编译程序时并不知道一个对象的实际类型是什么。

在了解了这两个概念以后,咱们来看看字节码文件是怎么说的:

javac -verbose StaticDispatch.class

重载JVM.png

咱们看到,图中的黄色框的invokespecial指令以及<init>标签,咱们能够知道这三个是指令是在调用实例构造器<init>方法。同理,下面两个红色框的invokevirtual指令告诉咱们,这里是采用分派调用的调用虚方法,并且入参都是“Human”。

由于在分派调用的时候,使用哪一个重载版本彻底取决于传入参数的数量和数据类型。并且,虚拟机(准确说是编译期)在重载时是经过参数的静态类型而不是实际类型做为判断依据,而且静态类型是编译期可知的。

因此,在编译阶段,Javac编译期就会根据参数的静态类型决定使用哪一个重载版本。重载是静态分派的经典应用。

2. 重写

咱们仍是用上面的例子:

public class StaticDispatch {  
     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 say hello  
woman say hello

相信你看到这里也会会心一笑,这一看就很明显嘛,重写是按照实际类型来选择方法调用的版本嘛。先别急,咱们来看看它的字节码:
重写JVM.png
嘶...这好像跟静态分派的字节码同样啊,可是从运行结果看,这两句指令最终执行的目方法并不相同啊,那缘由就得从invokevirtual指令的多态查找过程开始找起。

咱们来看看invokevirtual指令的运行时解析过程的步骤:

  1. 找到操做数栈顶的第一个元素所指向的对象的实际类型,记做C。
  2. 若是在在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,若是经过则返回这个方法的直接引用,查找过程结束;若是不经过,则返回java.lang.IllegalAccessError异常。
  3. 不然,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  4. 若是始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

咱们能够看到,因为invokevirtual指令在执行的第一步就是在运行期肯定接收者的实际类型,因此字节码中会出现invokevirtual指令把常量池中的类方法符号引用解析到了不一样的直接引用上,这个就是Java重写的本质。

总结一下,重载的本质是在编译期就会根据参数的静态类型来决定重载方法的版本,而重写的本质在运行期肯定接收者的实际类型

结语

坚持写技术文章的确是一件不容易的事情。如今技术更新愈来愈快,但依然想把基础再打牢一点。

若是本文对你理解多态有帮助,请给一个赞吧,这会是我最大的动力~

参考资料:

Java多态性理解

从虚拟机指令执行的角度分析JAVA中多态的实现原理

《深刻理解Java虚拟机》

相关文章
相关标签/搜索