方法调用就是确定被调用方法的版本,即具体哪一个方法。

解析

JVM 中支持了五种调用不同类型方法的方法调用字节码指令:

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器<init>()方法、私有方法(private 关键字修饰的方法)和父类中的方法(super 关键字调用的方法)。
  • invokevirtual。用于调用所有的虚方法,也就是其他指令剩余的所有方法。
  • invokeinterface。用于调用接口方法。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前4条调用指令分派逻辑都固化在 Java虚拟机内部,而该指令的分派逻辑是由用户设定的引导方法来决定的。

解析调用是一个静态的过程,在编译阶段就确定。在类加载的解析阶段,虚拟机会将一部分符号引用替换为直接引用,前提是方法在运行前就可以确定版本且运行时不能改变,称为“编译器可知,运行期不可变”。这些方法有:

  1. 被 invokestatic 和 invokespecial 指令调用的方法:静态方法、私有方法、实例构造器、父类方法。

    public class StaticResolution {
        public static void sayHello() {
            System.out.println("hello world");
        }
        public static void main(String[] args) {
            StaticResolution.sayHello();
        }
    }
    //这里通过 invokestatic 指令调用 sayHello() 方法。
  2. 被 final 修饰的方法(但是被 invokevirtual 指令调用)。

分派

静态分派

静态分派的一个典型应用就是重载。

public class StaticDispatch {
    static abstract class Human {}
    static class Man extends Human {}
    static class Woman extends Human {}

    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}
//运行结果:
hello,guy!
hello,guy!
Human man = new Man();

上面代码中的Human称为变量的静态类型外观类型,后面的Man称为变量的实际类型运行时类型。静态类型和实际类型在程序中都可能会发生变化,实际类型只有运行到对应位置时才能确定。静态类型使用时可以通过强制转型改变,当这种改变可以在编译期就确定,所以静态类型在编译期是可知的。

编译器在确定重载时通过参数的静态类型而不是实际类型。在编译期静态类型可知,编译器直接确定了选择的重载版本,并将对应的方法的引用写入指令的参数。

下面是main方法中代码对应的字节码:

 0 new #7 <StaticDispatch$Man>
 3 dup
 4 invokespecial #8 <StaticDispatch$Man.<init> : ()V>
 7 astore_1
 8 new #9 <StaticDispatch$Woman>
11 dup
12 invokespecial #10 <StaticDispatch$Woman.<init> : ()V>
15 astore_2
16 new #11 <StaticDispatch>
19 dup
20 invokespecial #12 <StaticDispatch.<init> : ()V>
23 astore_3
24 aload_3
25 aload_1
26 invokevirtual #13 <StaticDispatch.sayHello : (LStaticDispatch$Human;)V>
29 aload_3
30 aload_2
31 invokevirtual #13 <StaticDispatch.sayHello : (LStaticDispatch$Human;)V>
34 return

可以看出编译得到的字节码中 invokevirtual 指令中直接确定了使用参数为Human的方法版本。

编译器能确定一个方法调用的重载版本,但是这个版本可能只看代码观察不出来,需要编译以后才能知道确定的是哪一个版本。例如如果静态类型和方法的形参不完全相同时,编译器可能通过自动类型转换、自动装箱等方式匹配分派的目标。

解析和分派不排斥,静态方法也可以拥有重载版本,既有解析也有静态分派。

动态分派

动态分派的一个典型应用就是重写(Override)。

public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}
//运行结果
man say hello
woman say hello
woman say hello

这里 JVM 是根据实际类型来分派方法执行版本的,关键在于 invokevirtual 指令运行时的解析过程:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C。
  2. 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回 java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

所以 invokevirtual 指令不会把方法的符号引用解析到直接引用上,而是根据方法接收者的实际类型选择方法版本。

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();
        }

        @Override
        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

上述运行结果的解释:Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出的是“I am Son”。而这时候虽然父类的money字段已经被初始化成 2了,但Son::showMeTheMoney()方法中访问的却是子类的money字段,这时候结果自然还是 0,因为它要到子类的构造函数执行时才会被初始化。 main()的最后一句通过静态类型访问到了父类中的money,输出了 2。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

下面的代码中既有静态分派又有动态分派:

public class NewDispatch {
    static class QQ {
    }

    static class _360 {
    }

    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }

    public static class Son extends Father {
        @Override
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }

        @Override
        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}
//运行结果
father choose 360
son choose qq

对应字节码:

 0 new #2 <Dispatch$Father>
 3 dup
 4 invokespecial #3 <Dispatch$Father.<init> : ()V>
 7 astore_1
 8 new #4 <Dispatch$Son>
11 dup
12 invokespecial #5 <Dispatch$Son.<init> : ()V>
15 astore_2
16 aload_1
17 new #6 <Dispatch$_360>
20 dup
21 invokespecial #7 <Dispatch$_360.<init> : ()V>
24 invokevirtual #8 <Dispatch$Father.hardChoice : (LDispatch$_360;)V>
27 aload_2
28 new #9 <Dispatch$QQ>
31 dup
32 invokespecial #10 <Dispatch$QQ.<init> : ()V>
35 invokevirtual #11 <Dispatch$Father.hardChoice : (LDispatch$QQ;)V>
38 return

静态分派的过程

选择方法版本的依据有两点:

  1. 静态类型是 Father 还是 Son。
  2. 方法参数是 QQ 还是 360。

编译阶段,通过字节码能看出编译得出了两条 invokevirtual 指令,参数为指向Father::hardChoice(360)Father::hardChoice(QQ)的符号引用。因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型

动态分派的过程

运行阶段,执行到son.hardChoice(new QQ())代码时,静态分派已经决定了目标方法必须为hardChoice(QQ),因此方法的参数已经确定,动态分派只根据方法的接收者的实际类型决定方法的版本。因为只根据一个宗量,所以 Java 语言的动态分派属于单分派类型

如今的 Java 语言是一门静态多分派,动态单分派的语言。

虚拟机动态分派的实现

动态分派在进行方法版本选择时需要在方法接收者类型的方法元数据中搜索合适的方法,为了避免反复的搜索,一种基础的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也叫做 vtable,对应的在 invokeinterface 指令执行时也会用到接口方法表——Interface Method Table,简称 itable)。

方法表结构

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。

在上图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按相同的索引查找出所需的入口地址。虚方法表一般在类加载的连接阶段(验证、准备、解析)进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

字节码解释

35 invokevirtual #11 <Dispatch$Father.hardChoice : (LDispatch$QQ;)V>

这条指令里Father.hardChoice表示方法的名称,(LDispatch$QQ;)表示方法的参数,既然出现在字节码表示两者都是编译的静态分派的结果,但是其中Father代表的方法接收者在程序运行过程中可能会变化,但是其他不会再变化。

最后修改:2023 年 06 月 05 日
如果觉得我的文章对你有用,请随意赞赏