讲多态之前,先说下这篇文章的主要解惑点:
- 什么是多态,多态的表现形式和条件
- 为什么要用多态,多态的实际用途
- 多态的底层实现机制是什么样子的
搞清楚上述问题还是先弄一个实际的例子讲解会比较好:
/**
* @author :炜哥
* @date :创建于 2021/4/22 19:53
* @description:多态举例
* @modified By:
* @version: 1.0
*/
//正常状态下的爹
class NormalFather {
//战斗力
protected int fight_num = 100;
//要钱的时候
void say() {
System.out.println("这里有三万,拿去吧");
}
}
//愤怒的爹
class AngerFather extends NormalFather {
//战斗力
protected int fight_num = 10086;
void say() {
System.out.println("要什么要!没钱没钱!败家玩意儿!滚犊子!!!");
}
void throwSomething() {
System.out.println("砸了锅碗瓢盆");
}
}
//开心的爹
class HappyFather extends NormalFather {
//战斗力
protected int fight_num = 5;
void say() {
System.out.println("十万够不够,儿砸别亏待自己,不够再问爹爹拿奥~~~");
}
void sing() {
System.out.println("甜甜地唱了首歌");
}
}
多态构成条件和表现形式
多态概念要构成以下三个条件,缺一不可:
- 要有继承或者接口实现(我们这里是继承
extends
) - 要重写父类的方法(重写了
say()
方法) - 父类引用指向了子类的对象(看下面)
NormalFather father = new AngerFather();//向上转型
father.say();//表现形式
father.throwSomething();//编译报错
执行结果:
要什么要!没钱没钱!败家玩意儿!滚犊子!!!
结果符合预期,但上面的 throwSomething
方法为什么会编译报错,左边的父类引用(正常爹)不是已经指向了子类对象(愤怒的爹)了吗?请记住我下面说的话:**使用多态方式调用方法时,编译器首先都会去检查父类中是否有该方法,然后才会去执行子类中重写的方法,**所以能懂了吧,上面的例子中第一步就卡住了(父类中没有 throwSomething
方法,怎么执行?),后面的底层原理也会讲到为什么会编译错误。
而且,多态的表现形式只针对方法,不针对变量,方法是由对象的实际类型决定,变量是由引用类型决定,比如我们再加一句话:
NormalFather father = new AngerFather();//向上转型
father.say();//表现形式
father.throwSomething();//编译报错
System.out.println("这时候爹爹的战斗力为:" + father.fight_num);
输出的是:
这时候爹爹的战斗力为:100
而不是愤怒状态下的战斗值10086,如果想要愤怒状态下的爹也行,改成:
System.out.println("这时候爹爹的战斗力为:" + ((AngerFather) father).fight_num);//向下转型
这时候爹爹的战斗力为:10086
向下转型可以弥补多态概念中不能执行父类中未定义方法这个缺陷,而且也可以调用子类对象的变量属性,但这是有条件的,这个father需要指向子类对象,这样才能顺利得进行向下转型,否则运行期间会报错:
NormalFather father = new NormalFather();
AngerFather angerFather = (AngerFather)father;//运行期间报错,编译期无影响
angerFather.say();
![image.png](https://img-blog.csdnimg.cn/img_convert/b4f486092cd68d05120b0ad6f61249d4.png#align=left&display=inline&height=76&margin=[object Object]&name=image.png&originHeight=76&originWidth=1264&size=12747&status=done&style=none&width=1264)
需要这样的条件,所以向下编译一直都被认为是不安全的,还是不太理解?一句话解释下:
- 柯基一定是一种狗,但狗一定是柯基吗?(有个范围大小在里面)
而且在多态概念里,向上转型时构造器的执行顺序也是先父类构造器,然后才是子类构造器,跟初始化一个子类对象时相同。
为什么要多态,有啥用?
NormalFather father = new AngerFather();
AngerFather angerFather = (AngerFather)father;
AngerFather angerFather = new AngerFather();
看到这里我相信很多人都会有这种问题,这两个有啥区别,转来转去的,不是多此一举么?即向下转型和直接new子类对象有什么区别?
一句话,实际使用没什么区别,但设计上有很大好处,还是看上面的例子,比方说定义一个借钱时爹的说话方法:
public void getMoney(AngerFather father){
father.say();
}
public void getMoney(HappyFather father){
father.say();
}
富二代在向爹要钱的时候,因为爹有很多种状态,所以 getMoney
方法需要定义很多种爹来接受,现在只有两种,如果又出现了变态的爹咋办,况且爹平时喜怒无常,万一还有更多状态的爹那不是要定义更多的借钱方法了?这时候多态的好处来了:
public static void getMoney(NormalFather father){
father.say();
}
形参是一个父类,后面不管怎么传各种状态的爹(子类)都可以接收,而且会根据不同爹的状态出现不同的 say
,可能很多人觉得这种丢参数的方法很理所应当,但其实是java的多态机制帮了我们:
还是回到上面的问题,向下转型和直接new子类对象有啥区别,看下面这个升级方法:
public static void getMoney(NormalFather father){
father.say();
if(father instanceof AngerFather){
//判断是否是愤怒的爹,是的话调用throwSomething方法
((AngerFather) father).throwSomething();
}
if(father instanceof HappyFather){
//判断是否是开心的爹,是的话调用sing方法
((HappyFather) father).sing();
}
}
这样做,不光实现了多态概念,而且配合 instanceof
判断和向下转型可以调用实际子类对象原本调不到的方法 throwSomething
和 sing
。
Perfect!!!
静态绑定和动态绑定
多态中有个绑定概念不得不提,你可以简单理解成主体和方法之间的绑定,绑定之后就不会变了,之间的主要区别就是:静态绑定在编译期确定绑定关系,动态绑定是在运行期根据运行的对象确定绑定关系。
像 private
、 final
、 static
、构造器都属于静态绑定,在多态里面即使子类覆盖重写了父类的静态方法,在多态机制里也不会产生作用,比如这时候把 say
方法都改成静态修饰:
//正常状态下的爹
class NormalFather {
protected int fight_num = 100;
//要钱的时候
static void say() {
System.out.println("这里有三万,拿去吧");
}
}
//愤怒的爹
class AngerFather extends NormalFather {
protected int fight_num = 10086;
static void say() {
System.out.println("要什么要!没钱没钱!败家玩意儿!滚犊子!!!");
}
void throwSomething() {
System.out.println("砸了锅碗瓢盆");
}
}
//开心的爹
class HappyFather extends NormalFather {
protected int fight_num = 5;
static void say() {
System.out.println("十万够不够,儿砸别亏待自己,不够再问爹爹拿奥~~~");
}
void sing() {
System.out.println("甜甜地唱了首歌");
}
}
重新执行:
NormalFather father = new AngerFather();//向上转型
father.say();//不会调用子类对象的say方法
这里有三万,拿去吧 //并没有执行之类的say方法
到了这里其实可以对多态做一个总结:除了成员方法是编译看左边运行看右边,其余的比如构造器、成员变量、静态方法都是看左边。
OK,下面就从底层开始讲静态绑定还有绑定以及多态是如何实现,内容比较深,但会尽可能让你能明白的语言结合流程图来讲:
在之前最好了解下Class对象和对象创建过程,会对以下知识的理解有帮助,可以参考我的另外一篇文章《解析Java创建一个对象的过程》,这篇文章说在java虚拟机加载类的时候,会通过Class对象抽取生成一些元信息放到方法区中,类信息其中有一块叫方法表,是以数组的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址 ,我们以上面的类为例画一张结构图:
每个类都有自己的一个方法表,里面的一些共通方法比如 clone
、 equals
、 toString
等都指向object类型信息,因为Object是所有类的基类,这些方法都会被派生类重写,每个类自己独有的方法都指向了本身,而且有一点值得注意的是:相同的方法(即名称相同、入参也相同)在类方法表中的索引也是一样的,比如上面都是序号12,这个序号在动态绑定里很关键:
NormalFather father = new AngerFather();//向上转型
father.say();
重新看下这个例子,从底层方面应该是这么解析的:
- 类加载器加载
NormalFather
class文件到内存中,抽取元数据到方法区,方法区中的某一部分叫常量池(存放一些符号引用、字面值),JVM从常量池中的方法索引信息表(CONSTANT_Methodref_info)中查找say
方法的符号引用,找不到直接就直接编译出错。(上面已经说到了,这里底层解释一波) - 在堆内存中创建子类
AngerFather
的对象,并在栈内存中创建对该对象的引用 - JVM的invokevirtual指令(实例调用)会通过符号引用找到
NormalFather
父类的全限定名(包名+类名),这个过程同样是根据常量池中的类索引表(CONSTANT_Class_info)、字段/方法描述表(CONSTANT_NameAndType_info)等索引表来查找,从而得到NormalFather
类型,然后在NormalFather
类中寻找say()
方法,得到它的偏移量12(假设是12),这就是方法的直接引用。 - 实例方法调用的参数this得到的对象是
AngerFather
具体的对象,从而得到该子类对象对应的方法表,再根据第③步得到的偏移量12获得该方法表中指向的实现方法。
上面的说法比较抽象,看不懂没关系,直接看下面的图(不必太深究具体的执行顺序,只需知道是如何找到子类和执行具体的方法):
上面的图中可以看到,这个过程由两点特性决定,即方法指针偏移量是固定的以及指针总是指向实际类的方法域,多态下的动态绑定机制中有堆内存中的对象参与,也就是之前说的运行期间才确定方法和类的绑定关系,对比动态,我们看下静态绑定的流程:
NormalFather father = new NormalFather();
father.say();
上面的方法和类的绑定过程中是没有对象参与的,整个绑定过程在编译期就已经确定了(方法区里的内容在编译期确定)
接口的多态
跟继承关系不同,因为一个实现类可以实现多个接口,所以基类和派生类的同一个方法位置可能会不同,也就是上面说到的方法偏移量会不同。所以在不能通过方法偏移量找到对应派生类的方法位置时,只能在派生类的方法表中搜索,所以从效率上来说,接口方法的多态调用方法会会低一点。
上面的**#invokevirtual**指令是用于调用声明为类的方法(继承),除了invokevirtual指令,jvm还有
- 用于调用声明为接口的方法(实现)的**#invokeinterface**指令
- [静态绑定机制] 用于调用所有被private修饰的私有方法/被final修饰的禁止子类覆盖方法的**#invokestatic**指令
- [静态绑定机制] 用于调用所有类初始化方法的**#invokespecial**指令