深入分析 Java 多态底层原理

 2022-09-10
原文地址:https://blog.csdn.net/weixin_42178281/article/details/101635534

一.什么是多态?对于多态的理解?

多态polymorphism即多种形态,允许具有继承关系的不同类的对象去调用同一函数方法,并且会根据对象的不同产生多种状态的行为方式。或者说是一个接口的不同实现方式。在java里,继承一个类和实现一个接口本质上都是一种继承行为,因此都应该理解为多态的体现。

二.多态的两种表现形式:

1.编译时多态(静多态):

  • 编译期间决定目标方法
  • 通过overloading重载实现
  • 方法名相同,参数不同

2.运行时多态(动多态):

  • 运行期间决定目标方法
  • 同名同参
  • overriding和继承实现
  • JVM决定目标方法

三.静多态分析:

由于静多态主要是依据于方法的重载,编译器只需要查看方法签名就能决定在编译时为特定方法调用调用哪个方法。
注:在Java中,方法签名 - 方法的名称和参数类型

四.动多态的实现原理:

相比于静多态,动多态的底层过程就会复杂许多,首先通过了解要明确动多态的三个前提条件: 1.继承 2.重写 3.向上转型(即父类型的引用指向子类型的实例)
要解释多态在底层是如何实现的,只要去发掘程序实例是如何确定出自己真正的实例就好了。多态的底层实现是依靠 动态绑定 ,即在运行时才把方法调用与方法实现联系起来。以一个例子为例:

    class Father {
        public void test(){
            System.out.println("This is Father");
        }
    }
    
     class Son extends Father {
        @Override
        public void test(){
            System.out.println("This is Son");
        }
    }
    
    public class TestDemo {
            public static void main(String[] args) {
                Father s = new Son();
                s.test();
            }
        }

大家肯定都知道最后的的打印结果是“This is Son”,我们在这里要讨论的是这个过程是如何实现的

Java 的方法调用方式

Java 的方法调用有两类,动态方法调用与静态方法调用。静态方法调用是指对于类的静态方法的调用方式,是静态绑定的;而动态方法调用需要有方法调用所作用的对象,是动态绑定的。类调用 (invokestatic) 是在编译时刻就已经确定好具体调用方法的情况,而实例调用 (invokevirtual) 则是在调用的时候才确定具体的调用方法,这就是动态绑定,也是多态要解决的核心问题。

JVM 的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的。

方法区

当程序运行需要某个类的定义时,载入子系统 (class loader subsystem) 就装入所需的 class 文件,并在内部建立该类的类型信息,这个类型信息就存贮在方法区。类型信息一般包括该类的方法代码、类变量、成员变量的定义等等。而该类的class 对象是 JVM 在载入后于堆 (heap) 中创建的代表该类的对象,可以通过该 class 对象访问到该类型信息。比如最典型的应用,在 Java 反射中应用 class 对象访问到该类支持的所有方法,定义的成员变量等等。可以想象,JVM 在类型信息和 class 对象中维护着它们彼此的引用以便互相访问。两者的关系可以类比于进程对象与真正的进程之间的关系。并且为了优化对象调用方法的速度,方法区中的类型信息中会增加一个指针,该指针会去指向一张记录该类方法入口的表,即方法表,并且方法表中的每一项都是指向相应方法的指针。
JAVA语言是单继承机制,一个类只能继承一个父类,而所有的类又都继承自Object类。方法表有自己的存储机制:方法表中最先存放的是Object类的方法,接下来是父类的方法,最后才是自身特有的方法。这里的关键点在于如果子类重写了父类的方法,那么子类和父类的同名方法共享一个方法表项,都被认作是父类的方法(仅仅只有非私有的实例方法才行,静态方法是不行的),即同名(子类重写的)方法在相对应类的方法表中的偏移量是相同的。

常量池

常量池是方法区中的一个区域,其中保存 Java 类引用的一些常量信息,包含一些字符串常量及对于类的符号引用信息等。Java 代码编译生成的类文件中的常量池是静态常量池,当类被载入到虚拟机内部的时候,在内存中产生类的常量池叫运行时常量池。

常量池在逻辑上可以分成多个表,每个表包含一类的常量信息,本文只探讨对于 Java 调用相关的常量池表。

CONSTANT_Utf8_info

字符串常量表,该表包含该类所使用的所有字符串常量,比如代码中的字符串引用、引用的类名、方法的名字、其他引用的类与方法的字符串描述等等。其余常量池表中所涉及到的任何常量字符串都被索引至该表。

CONSTANT_Class_info

类信息表,包含任何被引用的类或接口的符号引用,每一个条目主要包含一个索引,指向 CONSTANT_Utf8_info 表,表示该类或接口的全限定名。

CONSTANT_NameAndType_info

名字类型表,包含引用的任意方法或字段的名称和描述符信息在字符串常量表中的索引。

CONSTANT_InterfaceMethodref_info

接口方法引用表,包含引用的任何接口方法的描述信息,主要包括类信息索引和名字类型索引。

CONSTANT_Methodref_info

类方法引用表,包含引用的任何类型方法的描述信息,主要包括类信息索引和名字类型索引。

1.通过aload_1指令将创建在堆中的对象的引用压入操作数栈,然后 invoke 指令会根据这个对象的引用找到该对象以及该对象所属类型的方法表
2.通过相应的 invoke 指令得到其调用方法的常量池表索引,该常量表会记录该调用方法的符号引用(包括调用方法所在的类全限定名,方法名和返回类型)
3.JVM根据该调用方法的类的全限定名加载、链接、初始化该类,并且在该类所在方法区中找到该调用方法的直接地址,并且将该地址记录到当前类的常量表中(常量解析过程)
4.随后JVM就可以确定出调用方法在内存上的具体位置,并可以随时调用该方法

对于所有私有方法、静态方法、final方法在程序编译后该信息就可以保存到静态常量池中了,JVM运行的时候只需要进行一次常量池解析即可。
而对于我示例的这种动态绑定,其不一样的是向上转型的语法使得其引用类型是父类型,实例对象是子类型,在常量池解析的过程中 invoke 指令解析出test方法对应的类全限定名为Father,因为该实例的引用类型为Father,在Father类的方法表中找到test方法的地址记录到TestDemo的常量表中。但是之前的aload_1指令则会将Son对象压入操作数栈,invoke指令会通过该操作数找到Son对象并且找到Son类型的方法表的入口,如若是静态绑定这样的过程是合理的,因为引用类型与实例类型对应,我去当前引用类的常量表中拿到方法的偏移量地址再去我实例对象对应的方法表中定位该方法,但对于动态绑定看似引用和实例对不上,但由于方法表的默认规则,即同名(子类重写的)方法在相对应类的方法表中的偏移量是相同的,用父类方法的偏移量在实例类型的方法表中依然可以定位到该子类重写覆盖后的方法

202209102319471001.png

简单来说:

如上图所示,当我们在执行代码的时候,首先根据我们所写的语法在栈内存上会创建相对应的引用变量s,相应地在堆内存上开辟空间创建Son的实例对象,并且引用s指向它的实例Son,由 类的加载过程 我们可知道我们所编写的Class文件会在JVM方法区上建立储存它所含有的类型信息(成员变量、类变量、方法等)并且还会得到一个Class对象(通过 反射 机制)建立在堆区上,该Class对象会作为方法区访问数据的入口。
结合同名方法偏移量相同且是固定的,则在调用方法时,首先会对实例方法的符号引用进行解析,解析的结果就是方法表的偏移量。当我们把子类对象声明为父类类型时,明面上虚拟机通过 对象引用的类型 得到该类型方法区中类型信息的入口,去查询该类型的方法表(即例中的Father),得到的是父类型的方法表中的test方法的偏移量,但实际上编译器通过 类加载过程 获取到 Class对象 知道了实例对象s的真正类型,转而进入到了真正的子类类型(例中的Son)的方法表中用偏移量寻找方法,恰好两者偏移量是相等的,我们就顺利成章的拿到了Son类型方法表中的test方法进而去指向test方法入口。嘻嘻。

五.多态的好处:

1.应用程序不必为每一个派生类编写功能调用,只需要对抽象基类进行处理即可。大大提高程序的可复用性。(继承保证)
2.派生类的功能可以被基类的方法或引用变量所调用,这叫向后兼容,
可以提高可扩充性和可维护性。(多态保证)