Java是如何实现多态的?

229 阅读8分钟

1. 如何理解多态?

1.1 多态的定义

多态指的是一个类型可以表现出不同的形态,在面向对象编程语言中,父类作为一个抽象,而子类就是父类不同形态的具体实现。

//多态性,子类向上转型
Animal animal = new Dog();

向上转型指的是将子类对象赋值给父类的引用,可以用父类的引用访问Dog对象的共有属性和方法,在运行时还会根据子类方法的具体实现调用相应的方法(如果是重写的)

1.2 如何实现多态

多态分为编译时多态运行时多态

运行时多态的实现方式:

  • 子类继承父类
  • 类实现接口

本质上还是对父类方法的改写和对接口方法的实现,以取得运行时多态的效果

要使用多态时,需要遵循以下原则:

  1. 让声明的对象是父类或接口类型,创建的是实际类型
//正确的声明方式
List<Integer> list = new ArrayList<>();
//错误的声明方式
ArrayList<Integer> list = new ArrayList<>();
  1. 定义方法参数也应该是父类类型或接口类型
//正确
pubilc void doSomething(List list);
//错误
public void doSomething(ArrayList list);

这样声明的好处就是灵活,传入的参数可以是这个父类的任意子类或接口的任意实现类,如定义方法时的参数为List,那调用方法时传入的参数既可以是ArrayList也可以是LinkedList

编译时多态

编译时多态重要通过方法重载实现。

方法重载:指的是同一个类中定义相同方法名但是参数不同(参数类型、数量或顺序不同)。 方法重写:指的是继承类或实现类对父类或接口方法的另一种实现方式,方法名、返回类型、参数类型、数量和顺序都要和父类或接口方法中的一致。

class Example {
    void show(int x) {
        System.out.println("Integer: " + x);
    }

    void show(String x) {
        System.out.println("String: " + x);
    }
}

public class Main {
    public static void main(String[] args) {
        Example example = new Example();
        example.show(10);       // 调用 show(int x)
        example.show("Hello");  // 调用 show(String x)
    }
}

在编译时,Java会根据方法调用时提供的参数类型决定使用哪个具体方法,这种决定发生在编译阶段

2. 多态底层是如何实现的?

Java的多态性在JVM的执行过程中得到了实现,涉及到方法调用时的动态绑定

2.1 方法表(Method Table)

在JVM中,类的实例方法和接口方法是通过方法表(方法区)查找的,方法表是一个结构化的数据表,其中包含了每个类的实例方法的地址。在Java类的加载过程中,JVM会为每个类创建一个方法表,表中存储该类的所有实例方法入口地址。

在这里插入图片描述

  • 父类和子类的关系:Java是单继承模型,一个类只能继承一个父类,因此在方法表中,子类继承父类方法时,父类方法表象直接指父类对应方法,如果子类重写父类方法,在子类的表中,对应表项指向子类实现。

每个类都有自己的方法表,方法表中存储的是该类实例方法的指针,如果子类重写了父类的方法,则子类方法的指针会覆盖父类的方法表项。

方法表中只会存储非私有的实例方法,静态方法不会出现在这里,静态方法和私有方法都是静态绑定的,不需要方法表间接指向。

2.2 动态绑定

动态绑定指的是方法调用在运行时根据对象的实际类型来决定调用哪个方法,对于JVM方法调用,JVM使用以下机制来实现动态绑定:

  1. 符号引用解析:当程序调用一个方法时,JVM会首先查找方法的符号引用(方法在类中的名称和描述),然后JVM通过符号引用查找该方法的偏移量并解析为对应的实际方法入口。
  2. 方法表的偏移量:每个类都有自己的方法表,方法表中存储了所有实例方法的偏移量。
  3. 实际调用:当调用一个实例方法时,JVM通过对象的引用,查找该对象的类的类型信息,通过该类型信息中的方法表,根据方法的偏移量找到具体方法实现并执行方法。

类方法符号引用的偏移量是怎么解析为对应的实际方法入口的?

在JVM中,偏移量解析为实际方法入口包括以下关键步骤:类加载、链接和方法调用

  1. 类加载: JVM中类加载过程包括加载、验证、准备和初始化四个阶段,在加载阶段,类的二进制数据被读入JVM,并创建一个java.lang.Class对象。

  2. 常量池: 类文件中常量池存储了各种字面量和符号引用,包括类名、字段名和方法名等。

  3. 解析阶段:- 在链接阶段的解析步骤中,JVM将符号引用转化为直接引用,直接引用就是指向目标的指针,可以是一个内存地址,也可以是和内存地址有关的偏移量

  • 对于方法引用,解析过程会查找方法表或接口表的入口地址,这些包含了类或接口的所有实例方法的直接引用。
  1. 偏移量的计算:
  • JVM使用类加载器将类的二进制数据读入内存,并分配一个基地址,方法的偏移量就是基于这个基地址的
  • JVM通过查找常量池中的方法符号引用,然后在类的方法表中找到对应的方法,并获取该类基地址的偏移量。
  1. 调用方法:
  • 当一个方法被调用时,JVM使用这个偏移量来定位方法的实际入口点,这个入口点包含了方法的执行代码,通常是指字节码指令的起始位置
  • JVM的调用栈会设置为指向这个方法的入口点,然后执行该方法的字节码
  1. 即时编译(JIT): 当方法被频繁调用时,JVM能通过JIT将热点代码编译成本地机器代码,以提高执行效率。

2.3 调用指令

对于类方法调用使用的是invokestatic指令,实例方法调用的是invokevirtual,对于invokestatic,操作数是方法参数。对于invokevirtual,操作数是实例对象引用和方法参数:从调用栈中弹出,并为该调用方法创建一个新的栈帧,然后亚茹新栈帧的局部变量表中,新栈帧压入虚拟栈中,作为当前活动栈帧

对于构造函数,使用的指令是invokeSpecial进行调用(私有方法、在子类中调用父类被覆盖方法也是使用的是invokeSpecial

invokespeical和invokevirtual指令的区别

  • inovkespeicial调用时,虚拟机将会按照引用类型来选择调用的方法(静态绑定)
  • invokevirtual指令执行时,会根据对象实际所属类型选择调用哪个方法

invokespecialsuper方法的调用,会动态搜寻当前类的父类,并找到最近的类中该方法的实现,因此super方法调用依赖的是动态绑定

2.4 接口的多态

Java中一个类可以实现多个接口,一个接口也可以被多个类实现,对于接口的多态调用,使用了invokeinterface指令,这个指令需要再运行时查找该接口方法的实现。

在执行invokevirtual指令调用实例方法时,符号引用是懒解析的,将实例方法的符号引用解析为直接引用,所生成的直接引用就是方法表中的一个偏移量,而且之后偏移量都是相同的。

而对于invokeinterface而言,虚拟机每次遇到invokeinterface指令时,都要重新搜一遍方法表,因为虚拟机不能假设这一次偏移量与第一次相同。

总结

Java中多态依赖于动态绑定机制实现,使用invokevirtualinvokeinterface指令,当指令执行时,JVM会根据方法的符号引用找到该方法的偏移量并解析为实际的方法入口,当调用一个实例方法时,JVM会通过实例方法所属对象的引用找到该类型的变量表,并通过偏移量找到执行方法的具体实现并执行代码。

参考:

  1. java多态理解和底层实现原理剖析-腾讯云开发者社区-腾讯云
  2. Java多态的实现机制是什么,写得非常好!-腾讯云开发者社区-腾讯云