【一看就懂】超详细❤️ 搞懂Java中的多态!

根目录

【根目录】Java编程思想【读书笔记】【不断更新…】


向上转型编译器遇到的问题

  • 我们知道向上转型的时候是以一个基类的视角来看待导出类,这个时候会发生接口(指类中的方法)的变窄,也就是丢失了导出类独有的方法。【这些都是编译器能够理解的】
  • 但是编译器是不知道以基类视角调用的方法的具体执行,是按照哪一个导出类来执行的。
  • 举个例子:下面有GrandFather类,Father类以及Son类,依次继承如下图。当Son向上转型,将引用存于GrandFather类型的变量man中时,这个man调用say()方法,究竟会发生什么,编译器是不可能知道的!只有在运行时,这个调用方法的具体实现才能被确定下来!为了了解这个问题,我们来看看什么是绑定!
    在这里插入图片描述

什么是绑定?

  • 将一个方法调用和一个方法主体进行相互关联,这就成为绑定!
  • 绑定分为(1)前期绑定 ; (2)后期绑定
  • (1)前期绑定 :前期绑定多是对于面向过程的编程语言使用的。在编译期间,编译器和连接程序实现方法调用和方法主体的绑定。(C语言中就只用这种前期绑定)
  • (2)后期绑定:后期绑定也称作动态绑定或者运行时绑定。当遇到上面所说的向上转型的问题(编译器在编译时只能保证方法名,参数,返回值,但是并不能在编译时确定方法调用绑定的方法体具体是谁)时,只能通过运行时的后期绑定来解决这个问题。后期绑定的含义就是在运行时根据对象的类型进行绑定。这其中使用到了一些机制!下面我们来看看!

Java前期绑定的底层实现机制

  • 下图为在main方法中调用一个static方法的字节码(使用的IDEA的jclasslib插件)。可以看到编译器将这个调用表示为invokestatic#5。下面我们来看看JVM是如何处理这条字节码指令的。
    在这里插入图片描述
  • 首先invokestatic#5的 #5 表示的是常量池中的第5张常量表! 这个常量表(CONSTATN_Methodref_info ) 记录的是方法say()信息的符号引用(包括say方法所在的类名,方法名和返回类型)。JVM会首先根据这个符号引用找到say方法所在的类的全限定名: com.mmall.pojo.Solution。
    在这里插入图片描述
  • 第二步,JVM会对这个全限定名称的全局唯一的类,进行加载 - 连接 - 初始化(这里在讲JVM的时候再具体将,大概就是进行初始化工作,一些安全性的检查,以及生成Class模板对象,将类的相关信息(包括方法的描述)存入方法区)
  • 第三步,JVM根据这个全限定名的类对应的方法区中,找到这个say方法的直接地址,并将这个直接地址记录到方法调用发生的类的常量池索引#5这张常量表中。这个过程叫做常量池解析,也就是完成了绑定的效果,下次再次调用Solution.say()方法时,就会直接找到say方法的直接地址,也就直接对应了say方法的字节码。
  • JVM完成了常量池解析动作,就可以进行say方法的调用了…
  • 注意为什么叫做前期绑定,是因为在编译期间编译器是能够确定被调用的方法具体是哪一个,并编译时记录在调用所在类Solution的常量池中 (invokestatic#5) 。

Java后期绑定的底层实现机制

  • 在编译期间编译器无法确定的事情,可以在运行期间由JVM的一些机制来确定。
  • 这里涉及JVM方法区中一个非常重要的数据结构——方法表
  • 在JVM加载类子系统加载二进制class文件的时候,会在方法区中存入该类的相关信息,其中有一个重要的数据结构就是方法表,它以数组的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址
  • 我们来举个例子:假如有如下两个类↓
    在这里插入图片描述
  • 那么上面两个类的方法表如下图所示(注意任何一个类都是继承Object类,因此优先排列父类Object的方法)如果是本类扩展的方法,顺着方法表index++来存储方法字节码所在的内存地址。
    在这里插入图片描述
  • 上图中的方法表有两个特点:(1) 子类方法表中继承了父类的方法,比如Father extends Object。 (2) 相同的方法(相同的方法签名:方法名和参数列表)在所有类的方法表中的索引相同。比如Father方法表中的say()和Son方法表中的say()都位于各自方法表的第8项中。
  • 下面开始,我们来从字节码入手看看后期绑定具体流程:先看看代码长什么样?
    在这里插入图片描述
  • 我们来看看这个main方法的对应字节码长什么样?
    在这里插入图片描述
  • (1)字节码看来,首先new了个Son类型的对象,之后invokespecial #3调用初始化方法,然后将引用存入man变量压入局部变量表1位置,取出局部变量表1位置的对象压入操作数栈,invokevirtual #4 调用man变量的say方法…
  • (2)invokevirtual #4后,JVM会首先根据引用找到Father的全限定名(如上图)【因为这里调用的变量类型是Fahter类型的】
  • (3)在Father类的方法区中的方法表中查找say方法,如果能够找到,那么将这个say方法在方法表中的索引下标8记录在Solution调用发生的类的常量池中的第#4张常量表中(常量池解析)【如果Father中没有找到该方法,即使Son中有该方法,也通过不了编译,因为向上转型,使用的是父类变量调用方法,父类中没有该方法肯定不合理
  • (4)在调用invokevirtual指令前有一个aload_1指令,它会将开始创建在堆中的Son对象的引用压入操作数栈。然后invokevirtual指令会根据这个Son对象的引用首先找到堆中的Son对象,然后进一步找到Son对象所属类型的方法表。过程如下图所示:
    在这里插入图片描述
  • (4)在这一步中,通过第(3)步中解析完成的#4 常量表中的方法表的索引项8,可以定位到Son类型方法表中的具有相同的方法签名(方法名和参数列表)的say方法【上面提到了相同方法签名的方法在方法表中的下标相同】,然后通过直接地址找到该方法字节码所在的内存空间。
  • 很明显,这里过程发生在运行时,运行时才能通过方法表下标最终定位到应该执行的具体方法字节码在内存中的地址,因此称作后期绑定(或者运行时绑定、动态绑定)

Java中使用的是什么绑定?

  • 需要注意Java中既有前期绑定,又有后期绑定!
  • Java中除了static方法和final方法是使用前期绑定以外,其他的都是使用后期绑定!
  • static和final为前期绑定的原因: static修饰的方法全局唯一,因此可以在编译期间确定绑定关系;final修饰的方法不能够被导出类重写,因此在编译期间,也是可以避免向上转型时的编译器无法确定具体方法体的问题的,因此也可以进行前期绑定。
  • 需要注意的是,private方法对于子类来说可以继承,但是子类没有使用的权限,那么private方法其实是隐式的final方法!因此private方法也是前期绑定。
  • 总结:Java前期绑定的标志是final,static,private;其他都是动态绑定。

动态绑定的意义?

  • 意义在于增加了代码的可扩展性!当代码需要增加新的子类的时候,可以通过一个基类类型的参数进行接收子类的引用,调用不同子类的方法,从而实现多态!
  • 举个例子:原先没扩展之前的代码如下:
    在这里插入图片描述
  • 增加新的导出类后的效果如下,意义如红字,不用再写很多不同的重载方法,扩展性强!
    在这里插入图片描述

构造器中多态的细节!

  • 我们知道构造器比较特殊,它其实是隐式的static方法。上面已经解释过了,static方法属于前期绑定,并不存在多态,但是还是非常有必要理解构造器怎么通过多态,在复杂的层次结构中运作!

回顾一下构造器调用的顺序

  • 构造器的调用顺序已经在之前的博客中讲了很多次了,但是那些都是在没有考虑多态的条件下的顺序。
  • 之前提到的构造器调用的顺序,是沿着继承层次,从基类想导出类的方向依次调用构造器。这样做是有特殊意义的,构造器在此检查了对象是否被正确地构造。
  • 如果一个类没有显式的构造器,那么编译器会给他一个默认的构造器。
  • 如果一个类显式地创建了个参数列表不为空的构造器,那么就不需要编译器给他默认的无参构造了(除非自己在显式地写一个无参构造)
  • 如果导出类没有显式地指定构造方法,那么就是默认调用默认无参构造器,如果这个时候父类没有无参构造,那么就会报错。
    在这里插入图片描述
  • 下面我们从一个例子看看多态下的构造器调用顺序:
    在这里插入图片描述
  • 步骤解析:(1)调用基类构造器,从根一直追溯下来,分别是Meal,Lunch,PortableLunch;(2)之后来到了Sandwich类中,按照声明顺序调用成员的初始化方法:Bread,Cheese,Lettuce;(3)最后才是调用导出类构造器的主体Sandwich。

【警告】在构造器内部使用多态会发生什么?

  • 构造器调用层次结构的时候,会出现一个两难的问题:如果在一个构造器的内部调用正在构造的对象的某个动态绑定的方法,那么会发生什么?

  • 在一般的方法内部,动态绑定是在运行时发生的,上面已经讲过了;如果要调用构造方法内的一个动态绑定方法的话,就会调用该方法被子类覆盖后的定义(如果子类没有覆盖才会调用父类的方法)!但是这样的话可能会发生一些错误(详见下面代码)

  • 从概念上讲,在任何的构造器内部,整个对象可能只是部分被初始化,我们能保证的只有基类对象已经完成了初始化!

  • 如果构造器只是构建对象过程中的一步,那么对象经过构造器初始化后,可能还没有被完全初始化,但是动态绑定的方法调用却会从基类方向向导出类方向延伸,动态绑定可以通过已经完成初始化的父类来调用导出类的方法! 也就是说:我们在构造器内部使用动态绑定,那么就可能会发生这个方法所操纵的成员可能还未进行初始化的情况!

  • 我们来看一个例子:在下面代码中,new RoundGlyph(5)的时候回来到RoundGlyph的调用器,但是RoundGlyph有父类Glyph,所以先调用Glyph的构造器,在Glyph的构造器内调用了子类RoundGlyph的方法!但是这个时候子类的成员radius还没来得及被初始化,因此打印出了第二行 radius = 0!
    在这里插入图片描述

  • 因此初始化的实际过程是:(1)在任何事物发生前,将分配给对象的存储空间初始化成二进制的0(零值初始化);(2)优先调用基类的构造器,如果构造器内有被子类覆盖的方法,那么就会直接调用(这个调用是在子类初始化之前发生的);(3)回到子类中,按照声明顺序调用其他类的构造器;(4)最后才是调用子类的构造器


向下转型与运行时的类型识别RTTI

  • 我们知道向上转型是安全的,因为只是借口缩小了,丢失了一些导出类独有的方法罢了
  • 但是向下转型时,就不是安全的了,因为返回来使用导出类独有的特性可能就很难描述一个基类;
  • Java是怎么保证向下转型安全的: 在Java中所有的转型都会进行检查!(即使简单加上一个括号进行转型,也会在运行期间进行检查,如果异常那么就会报:ClassCastException(类转型异常)
  • 这种运行期间检查称为:运行时类型识别RTTI



————— END —————




  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值