虚拟机字节码执行引擎 —— 方法调用

2年前 (2022) 程序员胖胖胖虎阿
119 0 0

本文部分摘自《深入理解 Java 虚拟机第三版》

概述

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)。之前讲过,一切方法调用在 Class 文件里面都是以符号引用的形式存储,而非方法在实际运行时内存布局中的入口地址(直接引用)。这个特性给 Java 带来强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用

解析

所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来了。这类方法的调用被称为解析(Resolution)

在 Java 中符合“编译期可知,运行期不可变”要求的方法,主要有静态方法和私有方法两大类,前者和类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此更适合在类加载阶段进行解析

调用不同类型的方法,字节码指令集里设计了不同的指令。Java 虚拟机支持以下 5 条方法调用字节码指令:

  • invokestatic

    用于调用静态方法

  • invokespecial

    用于调用实例构造器方法、私有方法和父类中的方法

  • invokevirtual

    用于调用所有虚方法

  • invokeinterface

    用于调用接口方法,会在运行时再确定一个实现该接口的对象

  • invokedynamic

    先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段确定唯一的调用版本,被 final 修饰的方法也是如此(使用 invokevirtual 指令调用)。能在类加载时就把符号引用解析为直接引用的方法统称为非虚方法(Non-Virtual Method),其他方法则称为虚方法(Virtual Method)

分派

解析调用是一个静态的过程,在编译期就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种方法调用形式:分派(Dispatch)调用则要复杂许多。分派调用也是多态实现的基础,比如重载和重写,就是依靠分派调用机制来确定正确的目标方法

分派调用可能是静态的也可能是动态的,按照分派依据的宗量数又可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派组合情况:

1. 静态类型与实际类型

为了了解分派,首先要清楚静态类型和动态类型这两个概念,代码如下:

// Human 是 Man 的父类
Human man = new Man();

我们把 Human 称为变量的静态类型(Static Type),后面的 Man 则被称为变量的实际类型(Actual Type)或者运行时类型(Runtime Type)。静态类型和实际类型在程序中都可能发生变化,区别在于静态类型的变化仅仅在编译期可知;而实际类型的变化的结果在运行期才可确定

// 实际类型变化,必须等到程序运行到这行才能确定
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化,编译期即可知
Man man = (Man)human;
Woman woman = (Woman)woman;

2. 静态分派

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派最典型的应用表现就是方法重载,代码如下:

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 sd = new StaticDispatch();
        sd.sayHello(man);
        sd.sayHello(woman);
    }
}

程序的运行结果是两次打印内容都是“hello guy”,因为使用哪个重载版本,完全取决于传入参数的数量和类型。代码中故意定义了两个静态类型相同,但实际类型不同的变量,但虚拟机在重载时只通过参数的静态类型而不是实际类型作为判定依据。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行,而由编译器来确定方法的重载版本。

3. 动态分派

动态分派与 Java 多态性的另外一个重要体现 —— 重写(Override)有着很密切的关系,我们还是用前面的代码为例:

public class StaticDispatch {
    
    static abstract class Human {
        protected abstract void sayHello();
    }
    
    static class Man extends Human {
        @Override
        protected abstract void sayHello() {
            System.out.println("hello gentleman");
        }
    }
    
    static class Woman extends Human {
        @Override
        protected abstract void sayHello() {
            System.out.println("hello lady");
        }
    }
    
    public static void main(String args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

运行结果分别是“hello gentleman”和“hello lady”,对于习惯了 Java 思想的我们来说是很正常的事,但虚拟机是符合判断应该调用哪个方法的呢?显然这里不可能再根据静态类型来决定了,而是两个变量的实际类型。动态分派是由虚拟机执行的,上述 Java 代码被编译成 class 字节码后,对应的 man.sayHello()woman.sayHello() 会被编译成 invokevirtual 方法调用指令,并且 man 和 woman 两个方法的所有者(接收者)的引用会被压到栈顶。invokevirtual 指令的运行时解析过程大致可分为以下几步:

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

invokevirtual 指令执行的第一步就是在运行期确定方法所有者的实际类型,这也是 Java 中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

4. 单分派与多分派

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

public class Dispatch {
    
    static class Rice {}
    static class Chocolate {}
    
    public static class Father {
        public void eat(Rice rice) {
            System.out.println("father eat rice");
        }
        
        public void eat(Chocolate chocolate) {
            System.out.println("father eat chocolate");
        }
    }
    
    public static class Son extends Father {
        public void eat(Rice rice) {
            System.out.println("son eat rice");
        }
        
        public void eat(Chocolate chocolate) {
            System.out.println("son eat chocolate");
        }
    }
    
    public static void main(String[] args) {
		Father father = new Father();
        Father son = new Son();
        father.eat(new Rice());
        son.eat(new Chocolate());
    }
}

打印结果分别是“father eat rice”和“son eat chocolate”,我们可以发现,这里的方法选择是基于方法接收者的不同和参数不同两个因素而造成的结果,也就是我们说的宗量。这里实际上涉及两个阶段,第一个阶段是静态分派的过程,方法接收者类型是 Father 还是 Son,方法参数是 Rice 还是 Chocolate,产生的两条 invokevirtual 指令的参数分别指向常量池中 Father::eat(Rice) 和 Father::eat(Chocolate) 方法的符号引用。因为是根据两个宗量进行选择,所以 Java 中的静态分派属于静态多分派。再看动态分派阶段,此时唯一可以影响虚拟机选择的因素只有方法接收者的实际类型了,即实际类型是 Father 还是 Son,因为只有一个宗量作为选择依据,所以 Java 的动态分派属于单分派类型

4. 虚拟机动态分派的实现

动态分派是执行非常频繁的动作,动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,比如实际类型是 Father,那么就要在 Father 类型的方法元数据中寻找 eat 方法。为了提高运行效率,Java 虚拟机为类型在方法区中建立了一个虚方法表,虚方法表存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表和父类的虚方法表中相同方法的地址入口是一致的,都指向父类的实现。如果子类重写了这个方法,子类虚方法表中的地址就会被替换为指向子类实现版本的方法入口地址

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一致的索引序号,这样当类型转换时,只需要变更要查找的虚方法表即可。虚方法表一般在类加载的连接阶段初始化完成。

版权声明:程序员胖胖胖虎阿 发表于 2022年9月5日 下午9:24。
转载请注明:虚拟机字节码执行引擎 —— 方法调用 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...