探究 Java 多态底层原理

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

虚拟机运行角度解释多态实现原理 动态绑定、方法表

  1. 将一个方法调用同一个方法主体关联起来被称作绑定,JAVA中分为前期绑定和后期绑定(动态绑定)

  2. 在程序执行之前进行绑定(由编译器和连接程序实现)叫做前期绑定

    1. 因为在编译阶段被调用方法的直接地址就已经存储在方法所属类的常量池中了,程序执行时直接调用 (invokestatic指令) ,如,final,static,private,构造方法,成员变量(包括静态及非静态)
  3. 后期绑定含义就是在程序运行时根据对象的类型信息进行绑定 实例调用 (invokevirtual)

    1. 想实现后期绑定,需要在运行时能判断对象的类型,从而找到对应的方法,即必须在对象中安置某种“类型信息”,JAVA中除了static方法、final方法(private方法属于)之外,其他的方法都是后期绑定
    2. 后期绑定会涉及到JVM管理下【每个类里都有】的一个重要的数据结构——方法表,方法表以数组的形式记录当前类及其所有父类的可见方法字节码在内存中的直接地址

方法区-虚拟机已加载的类信息

当程序运行需要某个类的定义时,载入子系统 (class loader subsystem) 装入所需的 class 文件,并在内部建立该类的类型信息,这个类型信息就存贮在方法区

类型信息一般包括该类的方法代码、类变量、成员变量的定义等等

在JVM执行Java字节码时,“类型信息”被存放在方法区中,通常为了优化对象调用方法的速度,方法区的类型信息中增加一个指针,指向方法表:

一张记录该类的方法入口的表(称为方法表),表中的每一项都是指向相应方法的指针

方法表的构造如下:

  1. Java的单继承机制,一个类只能继承一个父类,而所有的类又都继承自Object类。
  2. 方法表中最先存放的是Object类的方法,接下来是该类的父类的方法,最后是该类本身的方法。
  3. 这里关键的地方在于,如果子类改写了父类的方法,那么子类和父类的那些同名方法共享一个方法表项,都被认作是父类的方法
  4. 由于以上方法的排列特性(Object——父类——子类),使得方法表的偏移量总是固定的

不包括静态方法

注意这里只有非私有的实例方法才会出现,并且静态方法也不会出现在这里,原因很容易理解:静态方法跟对象无关,可以将方法地址直接引用,而不像实例方法需要间接引用。

更深入地讲,静态方法是由虚拟机指令invoke static调用的,私有方法和构造函数则是由invoke special指令调用,只有被invoke virtual和invoke interface指令调用的方法才会在方法表中出现

由于以上方法的排列特性(Object——父类——子类),使得方法表的偏移量总是固定的。

Person 或 Object 的任意一个方法,在它们(父类)的方法表和其子类 Girl 和 Boy 的方法表中的位置 (index) 是一样的。

这样 JVM 在调用实例方法其实只需要指定调用方法表中的第几个方法即可

方法表中的表项,都是指向该类对应方法的指针,这里就开始了多态的实现:

假设Class A是Class B的子类,并且A改写了B的方法method()

那么在B的方法表中,method方法的指针指向的就是B的method方法入口。

而对于A来说,它的方法表中的method方法则会指向其自身的method方法而非其父类的(这在类加载器载入该类时已经保证,同时JVM会保证总是能从对象引用指向正确的类型信息)

class Party{ void happyHour(){ Person girl = new Girl(); girl.speak(); } }

符号引用解析为直接引用的过程

1.编译类,在常量池方法索引信息表(CONSTANT_Methodref_info)中查找,生成方法调用的符号引用 12

当编译 Party 类的时候,生成 girl.speak()的方法调用,寻找

Invokevirtual #12

设该调用代码对应着 girl.speak(); #12 是 Party 类的常量池的符号引用,存储在CONSTANT_Methodref_info 表。

2.进一步在各个表中查找,得出要调用的方法是 Person 的 speak 方法

JVM 首先查看 Party 的常量池索引为 12 的条目( CONSTANT_Methodref_info 表中),进一步查看常量池(CONSTANT_Class_info,CONSTANT_NameAndType_info ,CONSTANT_Utf8_info)可得出方法的类型为: Person 类型(注意引用 girl 是其基类 Person 类型)

3.获取直接引用,在父类方法表中查找位置

查看 Person 的方法表,得出 speak 方法在该方法表中的偏移量 15(offset),这就是该方法调用的直接引用。

当解析出方法调用的直接引用后(方法表偏移量 15)

4.JVM 执行真正的方法调用,在子类方法表中调用

JVM 执行真正的方法调用:根据实例方法调用的参数 this 得到具体的对象(即 girl 所指向的位于堆中的对象),据此得到该对象对应的方法表 (Girl 的方法表 ),进而调用方法表中的某个偏移量所指向的方法(Girl 的 speak() 方法的实现)

结合方法指针偏移量是固定的以及指针总是指向实际类的方法域,我们不难发现多态的机制就在这里:

方法调用过程:

  • 当某个方法被调用时,JVM 首先要查找相应的常量池,得到方法的符号引用,并查找调用类的方法表以确定该方法的直接引用,结果是该符号引用被解析为直接引用即【方法表的偏移量】

根据类型信息的多态实现:

虚拟机通过对象引用得到方法区中类型信息的入口,查询类的方法表,当将子类对象声明为父类类型时,形式上调用的是父类方法,此时虚拟机会从实际类的方法表中获得该方法名对应的指针进而就能指向实际类的方法了

我们的故事还没有结束,事实上上面的过程仅仅是利用继承实现多态的内部机制,多态的另外一种实现方式:实现接口相比而言就更加复杂,原因在于,Java的单继承保证了类的线性关系,而接口可以同时实现多个,这样光凭偏移量就很难准确获得方法的指针。所以在JVM中,多态的实例方法调用实际上有两种指令:

接口调用

因为 Java 类是可以同时实现多个接口的,而当用接口引用调用某个方法的时候,情况就有所不同了

Java 允许一个类实现多个接口,从某种意义上来说相当于多继承,这样同样的方法在基类和派生类的方法表的位置就可能不一样了

为什么区分指令

invokevirtual指令用于调用声明为类的方法;

invokeinterface指令用于调用声明为接口的方法;

可以看到,由于接口的介入,继承自于接口 IDance 的方法 dance()在类 Dancer 和 Snake 的方法表中的位置已经不一样了

显然我们无法通过给出方法表的偏移量来正确调用 Dancer 和 Snake 的这个方法。这也是 Java 中调用接口方法有其专有的调用指令(invokeinterface)的原因

Java 对于接口方法的调用是采用搜索方法表的方式,对如下的方法调用:

  1. invokeinterface #13
  2. JVM 首先查看常量池,获取方法调用的符号引用(名称、返回值等等),然后利用 this 指向的实例,得到该实例的方法表,进而搜索方法表来找到合适的方法地址。
  3. 因为每次接口调用都要搜索方法表,所以从效率上来说,接口方法的调用总是慢于类方法的调用的

类加载

当程序运行需要某个类的定义时,载入子系统 (class loader subsystem) 装入所需的 class 文件,并在内部建立该类的类型信息,这个类型信息就存贮在方法区。类型信息一般包括该类的方法代码、类变量、成员变量的定义等等。可以说,类型信息就是类的 Java 文件在运行时的内部结构,包含了改类的所有在 Java 文件中定义的信息。

注意到,该类型信息和 class 对象是不同的。class 对象是 JVM 在载入某个类后于堆 (heap) 中创建的代表该类的对象,可以通过该 class 对象访问到该类型信息。比如最典型的应用,在 Java 反射中应用 class 对象访问到该类支持的所有方法,定义的成员变量等等。可以想象,JVM 在类型信息和 class 对象中维护着它们彼此的引用以便互相访问。两者的关系可以类比于进程对象与真正的进程之间的关系。

Java 的方法调用方式

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

JVM 的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的。本文也可以说是对于 JVM 后两种调用实现的考察。

常量池(constant pool)

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