首页 > 代码库 > Java虚拟机(九)——多态性理解

Java虚拟机(九)——多态性理解

介绍

??对于面向对象的三大特征,很多人可以毫不犹豫地讲出来封装,继承,多态封装,和继承自不必说,而对于多态的理解,可能对于不少人来说,总好像理解了,但是好像又有点迷惑,这篇文章着重介绍这个特性。

??多态的定义:指允许不同类的对象对同一消息做出响应。即同一消息可以根据发送对象的不同而采用多种不同的行为方式。这种技术称为动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。

??现实中,关于多态的例子不胜枚举。比方说按下 F1 键这个动作,如果当前在 Flash 界面下弹出的就是 AS 3 的帮助文档;如果当前在 Word 下弹出的就是 Word 帮助;在 Windows 下弹出的就是 Windows 帮助和支持。同一个事件发生在不同的对象上会产生不同的结果。

多态的作用:消除类型之间的耦合关系,增加程序的灵活性和扩展性。

Java中多态性的体现

Java多态性主要体现在以下两个方面。

重写(overwrite)

子类继承父类,重写父类方法,注意的是方法签名必须相同, 返回类型必须是本类或其子类的实例(jdk 1.5 版本以后)。

重载(overload )

类内部可以有很多同名的方法,注意的是名称相同, 参数及返回值类型可以不同, 这就叫重载。

多态性的实现原理

要了解多态机制的具体实现机制,就需要深入了解Java 虚拟机对方法的调用过程和分派特性。
??首先需要明白,方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

??所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

??在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

??**只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法**4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。

??Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。,在Java语言规范中明确说明了final方法是一种非虚方法。

??解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

分派

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 sd = new StaticDispatch();
        sd.sayHello(man);
        sd.sayHello(woman);
    }
}

运行结果

Hello guy!
Hello guy!

过程分析:

??我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么
main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sd”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

动态分派

我们把运行期根据实际类型确定方法执行版本的分派过程称为动态分派,动态分派的典型应用是方法重写。

/**
 * 动态分派
 *
 * @author bridge
 */
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

利用 javap -c 命令反编class文件,可以得到Main方法的字节码如下:

 public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class methodInvoke/DynamicDispatch$Man
       3: dup
       4: invokespecial #3                  // Method methodInvoke/DynamicDispatch$Man."<init>":()V
       7: astore_1
       8: new           #4                  // class methodInvoke/DynamicDispatch$Woman
      11: dup
      12: invokespecial #5                  // Method methodInvoke/DynamicDispatch$Woman."<init>":()V
      15: astore_2
      16: aload_1
      17: invokevirtual #6                  // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
      20: aload_2
      21: invokevirtual #6                  // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
      24: new           #4                  // class methodInvoke/DynamicDispatch$Woman
      27: dup
      28: invokespecial #5                  // Method methodInvoke/DynamicDispatch$Woman."<init>":()V
      31: astore_1
      32: aload_1
      33: invokevirtual #6                  // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
      36: return

字节码分析:

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:

Human man=new Man();
Human woman=new Woman();

接下来的16~21句是关键部分,16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver)

17和21句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:


1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。

3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。

4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

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

单分派和多分派

??方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派多分派两种。
单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

/**
 * 单分派与多分派演示
 *
 * @author bridge
 */
public class Dispatch {

    static class QQ {
    }

    static class _360 {
    }

    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("Father choose QQ");
        }

        public void hardChoice(_360 arg) {
            System.out.println("Father choose 360");
        }

    }

    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("Son choose QQ");
        }

        public void hardChoice(_360 arg) {
            System.out.println("Son choose 360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果

Father choose 360
Son choose QQ

Main 方法的字节码如下:

public class methodInvoke.Dispatch {
  public methodInvoke.Dispatch();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class methodInvoke/Dispatch$Father
       3: dup
       4: invokespecial #3                  // Method methodInvoke/Dispatch$Father."<init>":()V
       7: astore_1
       8: new           #4                  // class methodInvoke/Dispatch$Son
      11: dup
      12: invokespecial #5                  // Method methodInvoke/Dispatch$Son."<init>":()V
      15: astore_2
      16: aload_1
      17: new           #6                  // class methodInvoke/Dispatch$_360
      20: dup
      21: invokespecial #7                  // Method methodInvoke/Dispatch$_360."<init>":()V
      24: invokevirtual #8                  // Method methodInvoke/Dispatch$Father.hardChoice:(LmethodInvoke/Dispatch$_360;)V
      27: aload_2
      28: new           #9                  // class methodInvoke/Dispatch$QQ
      31: dup
      32: invokespecial #10                 // Method methodInvoke/Dispatch$QQ."<init>":()V
      35: invokevirtual #11                 // Method methodInvoke/Dispatch$Father.hardChoice:(LmethodInvoke/Dispatch$QQ;)V
      38: return
}

分析:
在main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显示得很清楚了。

我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型

根据上述论证的结果,我们可以总结一句:到目前为止,Java语言是一门静态多分派、动态单分派的语言。


参考资料

【深入理解Java虚拟机】 周志明著

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    Java虚拟机(九)——多态性理解