首页 > 技术文章 > 深入解析多态和方法调用在JVM中的实现

WangXianSCU 2021-08-24 23:18 原文

深入解析多态和方法调用在JVM中的实现

1. 什么是多态

多态(polymorphism)是面向对象编程的三大特性之一,它建立在继承的基础之上。在《Java核心技术卷》中这样定义:

一个对象变量可以指示多种实际类型的现象称为多态。

在面向对象语言中,多态性允许你将一个子类型的实际对象赋予给一个父类型的变量。在这样的赋值完成之后,父类变量就可以根据实际赋予它的子类对象的不同,而以不同的方式工作。

在下面的示例中,Son类继承了Father类并重写了f()方法,又将Son类型的对象赋值给Father类型的变量,再用它调用f()方法,稍微有点Java基础的程序员都知道,此时会使用的是Son类中的f(),这种重写就是一种典型的多态的体现。

class Father{
    f(){ ... }
}

class Son extends Father{
    f(){ ... }
}

// 调用代码
Father object = new Son();
object.f();

在一些资料中,也把重载称为一种多态的表现形式,本文也将重载视为多态的一种进行讲解,但这种说法确实尚存争议。

2. 一些知识准备

2.1 运行时栈帧结构

Java虚拟机规范中,为所有的Java虚拟机字节码执行引擎规定了统一的输入输出:

  • 输入为字节码形式的二进制流。
  • 输出为执行结果。

在解释运行阶段,JVM以方法作为最基本的执行单元栈帧是用于支持虚拟机进行方法调用和执行的数据结构,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。处于栈顶的栈帧就是当前栈帧,对应的方法就是正在运行的当前方法

在这里我们以服务解释方法调用为前提,简单说明JVM的运行时栈帧结构

image-20210824125525386
  • 局部变量表。用于存放方法参数和方法内部定义的局部变量。
  • 操作数栈。一个后入先出的LIFO栈,辅助方法执行中的运算操作。
  • 动态连接。动态连接是一个指向运行时常量池中该栈帧所属方法的引用,指向的显然是一个符号引用。它的存在主要是支持方法调用过程中的动态连接。
    • 方法调用中,符号引用一部分在类加载或者第一次使用时被转化成直接引用,这种转化称为静态解析
    • 另外一部分符号引用在每一次运行期间都转化为直接引用,这种转化称为动态连接
  • 方法返回地址。
    • 正常退出方法时,方法返回地址指向主调方法的PC计数器。
    • 异常退出方法时,方法返回地址指向异常处理表。
  • 附加信息。服务于调试、性能收集等等。

2.2 方法调用字节码指令

针对不同类型的方法,Java虚拟机支持以下五种方法调用字节码指令

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
    • 在Java11以后,invokespecial已经常常不被用来调用私有方法,详见下文的实验和说明。
  • invokevirtual。用于调用所有的虚方法。
  • invokeinterface。用于调用接口方法。在运行时确定实现该接口的对象。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后执行该方法。
    • 详见《深入理解Java虚拟机》p321

非虚方法指那些能够在解析阶段确定唯一的调用版本的方法,即上面由invokestaticinvokespecial调用的那些方法。而其他那些属于类的,需要在运行时动态确定调用版本的方法,我们称之为虚方法,最常见的虚方法就是普通的实例方法。

下面我们用字节码的形式看看这些方法调用指令。

// Java代码
public class Test {
    public static void staticMethod() {
        System.out.println("static method");
    }

    private void privateMethod() {
        System.out.println("private method");
    }


    public static void main(String[] args) {
        Test.staticMethod();

        new Test().privateMethod();
    }
}

javac Test.java
javap -verbose Test
    
// javap工具得到的main部分的字节码文件
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: invokestatic  #23                 // Method staticMethod:()V
         3: new           #24                 // class Test
         6: dup
         7: invokespecial #28                 // Method "<init>":()V
        10: invokevirtual #29                 // Method privateMethod:()V
        13: return
      LineNumberTable:
        line 12: 0
        line 14: 3
        line 15: 13

在上面的代码中,我们显然可以看到,staticMethod使用invokestatic来进行调用,"<init>"构造方法使用了invokespecial来调用,这些都符合上面的约定。

但是!作为私有方法的privateMethod方法,却在字节码中被编译为使用invokevirtrual指令来调用。这是为什么呢?

笔者查阅资料后,发现在JEP181中,对方法调用字节码指令进行了一定程度上的修改。在Java11版本及以后,嵌套类之间的私有方法的访问权限控制,就从编译期转移到了运行时,从而这样的私有方法也被使用invokevirtual指令来调用,

总而言之,在Java11及以后,类中的私有方法往往用invokevirtual来调用,接口中的私有方法往往用invokeinterface调用,invokespecial往往仅用于实例构造器方法和父类中的方法。

2.3 字节码方法解析过程

解析过程是JVM将常量池内的符号引用替换为直接引用的过程。

  • 符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用是可以直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。

《Java虚拟机规范》中明确要求在执行方法调用字节码指令之前,必须先对它们使用的符号引用进行解析。即所有invoke...指令之前。由于对同一个符号引用收到多次解析请求是很常见的事,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。(invokedynamic有一些特殊性质,这里不做解释)。

方法解析第一步需要解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,那么用C表示这个类,接下来虚拟机将按照以下步骤进行后续的方法搜索。

  • 如果我们在解析一个类方法,但C是一个接口,直接抛出java.lang.IncompatibleClassChangeError异常。

    • 如果我们在解析的是接口方法,但C是一个类,也抛出java.lang.IncompatibleClassChangeError异常。
  • 如果通过了第一步,在C中查找是否有简单名称和描述符都与目标匹配的方法,有则返回直接引用。

  • 否则,依次在C的父类、接口列表、父接口中进行查找。如果找到则根据情况返回直接引用或者抛出java.lang.AbstractMethodError异常。

  • 如果都找不到,说明方法查找失败。抛出java.lang.NoSuchMethodError

  • 最后,如果成功返回了直接引用,就对这个方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError异常。

2.4 静态类型和实际类型

已知有类FatherSon,且Son类继承了Father类。假设我们以以下方式初始化变量。

class Father{}
class Son extends Father{}

Father object = new Son();

那我们把上面代码中的Father称为变量object的静态类型外观类型,将Son称为object的实际类型运行时类型

当变量被定义的时候,它的静态类型就已经确定,而实际类型可能会在运行过程中不断变化,例如下面给出一个例子。

class Father{}
class Son extends Father{}
class Daughter extends Father{}

Father object = new Random().nextBoolean() ? new Son() : new Daughter();

这个例子中,object的静态类型始终是Father,而实际类型就只有到运行时才知道了。

3.方法调用

3.1 解析

非虚方法,即使用invokespecialinvokestatic指令调用的方法,由于无法被覆盖,不可能存在其他版本,所以可以在类加载的解析阶段直接进行方法解析,将符号引用全部转变为明确的直接引用,不必延迟到运行期完成。

解析调用一定是一个静态的过程,在编译期间就完全确定。

值得说明的一点是,《Java虚拟机规范》明确地将final方法定义为非虚方法,但final方法是使用invokevirtual调用的,故使用下面讲的分派机制,而非解析。

3.2 静态分派

静态分派用于解释重载的场景,下面给出一个简单的例子

public class Test {
    public void overLoad(Father father){
        System.out.println("get father method");
    }

    public void overLoad(Son father){
        System.out.println("get son method");
    }


    public static void main(String[] args) {
        Test test = new Test();

        Father object = new Son();

        test.overLoad(object);
    }
}

class Father{}
class Son extends Father{}

//运行结果
get father method

显然,JVM选择了参数类型为Father的重载方法。

在虚拟机处理重载的情况时,是通过参数的静态类型而不是实际类型作为判断依据的。由于静态类型在编译期可知,所以在编译阶段Javac编译器就根据参数的静态类型决定了会使用哪个重载版本。比如上面会选择overload(Father)作为调用目标,并把这个方法的符号引用写入到main()方法的invokevirtual指令的参数中,后续在解释阶段执行invokevirtual时,这个选好的方法就会直接被使用。这个操作是在Javac前端编译的语法分析阶段直接完成的。

值得注意的是Javac编译器确定的重载版本并非确定的某一个,而是在现有的选择中选择的“最合适的”一个。下面给出一个示例。

public class Overload {
	// 从上到下,优先级递减
    public static void sayHello(char arg) {
        System.out.println("hello char");
    }
    
    public static void sayHello(int arg) {
        System.out.println("hello int");
    }
    
    public static void sayHello(long arg) {
        System.out.println("hello long");
    }
    
    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }
    
    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }
    
    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

假如按照上面的代码运行,那么会被调用的是sayHello(char arg)方法,这就是Javac认为的最合适的方法。但假如我们将sayHello(char arg)注释掉,那么会被调用的是sayHello(int arg)方法,以此类推。

当然,一个脑子正常的程序员,不应该在自己的任何工程中写出上述这样的重载代码。

3.3 动态分派

静态分派用于解释重写的场景,下面给出一个简单的例子

public class Test {
    public static void main(String[] args) {
        Father object = new Son();

        object.override();
    }
}

class Father{
    public void override(){
        System.out.println("get father method");
    }
}

class Son extends Father{
    public void override(){
        System.out.println("get son method");
    }
}

//运行结果
get son method

显然,JVM选择了子类Son的重写方法。显然,在进行动态分派的时候,选择方法的依据是调用方法的变量的实际类型。为了解释清楚invokevirtual的作用方式,我们使用javap命令输出这段代码中main部分的字节码。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #7                  // class Son
         3: dup
         4: invokespecial #9                  // Method Son."<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #10                 // Method Father.override:()V
        12: return
      LineNumberTable:
        line 3: 0
        line 5: 8
        line 6: 12

0 ~ 7 行的字节码是一些准备工作。创建了用于存放变量object的内存空间,调用了对应的构造器,并将对象实例存放在了局部变量表的第一个槽中。实际上对应代码中下面这行。

Father object = new Son();

第 8 行 的aload_1指令将刚刚创建的object对象引用压到了操作数栈顶,这个对象即将调用override()方法。

第 9 行,正式使用了方法调用字节码指令invokevirtual。根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程分为以下几步。

  • 找到操作数栈顶第一个元素指向的对象的实际类型并记作C。
  • 在C中查找是否有简单名称和描述符都与目标匹配的方法,有则返回直接引用。
    • 这里所谓的“目标”,是目标方法的简单外观,在编译阶段就已经传递给invokevirtual作为参数
  • 否则,依次在C的父类、接口列表、父接口中进行查找。如果找到则根据情况返回直接引用或者抛出java.lang.AbstractMethodError异常。
  • 如果都找不到,说明方法查找失败。抛出java.lang.NoSuchMethodError
  • 最后,如果成功返回了直接引用,就对这个方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError异常。

你应该可以看出来,其实就是我们在2.3节中讲的字节码方法解析。重点就是我们从操作数栈顶找到了第一个元素指向的实际类型,并用它为基础来做接下来的方法查找。这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派

这里再给出一个示例,帮助读者更深入地了解动态分派。

public class FieldHasNoPolymorphic {

    static class Father {
        public int money = 1;

        public Father() {
            money = 2;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }

    static class Son extends Father {
        public int money = 3;

        public Son() {
            money = 4;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Son,  i have $" + money);
        }
    }

    public static void main(String[] args) {
        Father gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
}

// 输出结果
I am Son, i have $0
I am Son, i have $4
This gay has $2

应该不难理解,第一行的输出来自父类Father构造器调用子类的showmeTheMoney()方法,此时子类尚未初始化,所以结果为0。

第二行的输出来自子类调用showmeTheMoney()方法,此时子类已经初始化,结果为4。

第三行的输出,使用gay.money直接取值,注意这个时候通过静态类型访问变量,自然没有类似invokevirtual的东西来找所谓的实际类型。所以使用的是变量 gay 的静态类型,那么就从Father类中取值,取到money的值为2。

所以,动态分派仅限于方法!

4. 知识补充

4.1 单分派与多分派

方法的接收者和方法的参数统称为方法的宗量。选择方法时使用一种宗量称为单分派,使用多种宗量称为多分派。那么显而易见的,我们可以总结出Java是一种静态多分派,动态单分派的语言。

  • 静态多分派:在静态分派的过程中,即重载的过程中,我们同时将方法的接收者和方法的参数作为选择方法的依据,所以是多分派。
  • 动态单分派:在动态分派的过程中,方法的参数模式在编译阶段就已经确定,唯一动态决定的是方法接收者的实际类型,所以是单分派。

注:方法的接收者指调用方法的对象。如object.f(),那么object就是方法的接收者。

4.2 虚拟机动态分派的优化实现

我们可以想见的是,在代码运行过程中,一个虚方法可能会被大量多次地调用。所以一种在现代JVM中常见的优化手段是创建一个虚方法表,同理对于invokeinterface指令,也有接口方法表,它们的结构如下所示。

image-20210824222018120

虚方法表中存放的是各种方法的实际入口地址。如果父类的方法在子类中没有重写,那么子类虚方法表中的地址入口和父类虚方法表中的入口地址是一致的,都指向父类的实现。否则子类的地址入口就会指向自己的实现。这样可以节省大量的,动态分派过程中搜索方法的开销。

同时要求在父类和子类的虚方法表中,具有相同签名的方法应该具有相同的索引序号,这样当类型动态发生变化的时候,只需要动态改变要查找的虚方法表,而不需要重新考虑在表中的位置。

虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机就会为该类的虚方法表进行初始化。

4.2 虚方法的方法内联

方法内联是编译器最重要的优化手段!简单说就是把目标代码以类似复制的方式替换到调用方法的位置,避免发生真实的方法调用。下面是一个示例。

// 内联前的代码
static class C {
    int val;
    final int get(){
        return val;
    }
}
  
public void f(){
    C c = new C();
    int x = c.get();
    int y = c.get();
    int sum = x + y;
}

// 内联后的代码
public void f(){
    C c = new C();
    int x = c.val;
    int y = c.val;
    int sum = x + y;
}

方法内联有两个重要功能

  • 去除方法调用的成本,包括查找方法版本和建立栈帧等。
  • 为建立其他优化打好基础。

所以我们称方法内联为最重要的优化手段。然而在Java虚拟机中,方法内联却有着一些天生的问题存在。对于Java中的虚方法,在将Java代码翻译为字节码的编译阶段,很多情况下编译器根本不可能确定该使用哪个方法版本。而Java作为面向对象的语言,在Java编程中绝大多数的方法都是虚方法,绝大多数的方法调用都是invokevirtualinvokeinterface负责的。

但是方法内联对于优化来说又过于重要,所以Java虚拟机的设计者们想了很多办法来尽量解决问题。

Java虚拟机引入了一种名为类型继承关系分析(CHA)的技术,它用于确定在目前已经加载的类中,那些虚方法是否存在多个版本。根据分析结果的不同,Java虚拟机可以采取不同的处理方法。

  • 假如只有一个方法,那么就可以直接进行内联,即假设整个应用程序也只有这一个版本。这种内联被称为守护内联。当然我们知道,并不是所有的类都被加载,保不齐未来就会有这个方法的新版本出现,所以我们预留好了逃生门,当假设不成立时就通过逃生门抛弃掉已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
  • 假如有多个方法版本可供选择,那么编译器会尝试使用内联缓存的方式来减少方法调用的开销。内联缓存的基本原理很好理解,就是当方法第一次调用发生后,缓存下方法接收者的版本信息和对应的方法调用点。
    • 每次方法调用时都比较接收者的版本,如果版本不变,那么就是一种单态内联缓存。通过该缓存进行调用就解除了方法搜索带来的开销,而仅仅多了一个比较版本的微小开销。
    • 如果版本发生改变,说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存,这里说是一种内联缓存,其实就是不要缓存了,直接正常进行动态分派操作。
    • 当缓存未命中的时候,大多数JVM的实现时退化成超多态内联缓存,也有一些JVM选择重写单态内联缓存,就是更新缓存为新的版本。这样做的好处是以后还可能会命中,坏处是可能白白浪费一个写的开销。
image-20210824231434777

推荐阅读