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

562 阅读14分钟

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

执行引擎

执行引擎是 Java 虚拟机核心的组成部分之一,作用就是用来执行字节码。在 Java 虚拟机规范中执行引擎只是一个概念模型,不同的虚拟机可以有不同的实现,通常会有解释执行(通过编译器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,或者二者兼备。但无论是何种实现,从外观上看,所有 Java 虚拟机的执行引擎的输入、输出都是一致的:输入的是字节码的二进制流,处理过程是解析并执行字节码,输出是执行结果

运行时栈帧结构

Java 虚拟机以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。一个方法从调用开始至执行结束的过程,都对应一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧到包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息,下面将逐一做详细介绍:

1. 局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot)为最小单位,Java 虚拟机规范中并没有明确指出一个变量槽应占用的内存空间大小。对于 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,都可以使用 32 位或更小的物理内存来存储。而对于 64 位的数据类型,如 long 和 double,Java 虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。对于两个相邻的共同存放一个 64 位数据的变量槽,虚拟机不允许采用任何方式单独访问其中的某一个。

当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法,那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,通过关键字 this 来访问这个隐含参数,其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽。

为了尽可能节省栈帧所耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超过了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量重用。不过这种设计会伴随有额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为

public static void main(String[] args) {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    // int a = 0;
    System.gc();
}

当执行到 System.gc() 时,虽然已经脱离 placeholder 的作用域,但由于变量槽还存在关于 placeholder 数组对象的引用,所以不会被回收。如果我们把 int a = 0 这段注释打开,那么原本 placeholder 对应的变量槽就会被其他变量复用,自然也就可以回收了。有时我们会看到手动将不再使用的变量置为 null 的操作,这并不见得是毫无意义的操作,可以将变量对应的局部变量槽清空。但在实际情况中,赋 null 值的操作在经过即时编译优化后几乎一定会被当成无效操作而被抹除,因此以恰当的变量作用域来控制变量的回收时间才是最优雅的解决手段。

2. 操作数栈

操作数栈(Operation Stack)是一个后进先出的栈结构,其最大深度也在编译时就已确定。当一个方法刚开始执行时,这个方法的操作数栈是空的。在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈的操作。

3. 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属的方法引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。我们知道,在 Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池里指向的方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用时就被转化为直接引用,称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,称为动态连接。

4. 方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法,要么正常结束,要么发生异常。无论采用何种退出方法的方式,都必须返回最初方法调用的位置,程序才能继续执行。一般来说,方法正常退出时,主调方法的 PC 计数器的值就可以作为返回地址,在栈帧中保存。而方法异常退出,返回地址是通过异常处理器表来确定的,栈帧一般不保存这个信息。

5. 附加信息

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 虚拟机为类型在方法区中建立了一个虚方法表,虚方法表存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表和父类的虚方法表中相同方法的地址入口是一致的,都指向父类的实现。如果子类重写了这个方法,子类虚方法表中的地址就会被替换为指向子类实现版本的方法入口地址

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