【笔记】深入理解 Java 虚拟机:虚拟机字节码执行引擎

概述

执行引擎是 Java 虚拟机最核心的组成部分之一。虚拟机是相对于物理机的概念,物理机的执行引擎是直接建立在硬件、处理器、指令集和操作系统层面上的,而虚拟机的执行引擎是由自己实现的,因此可以执行指定指令集与执行引擎的结构体系,而且能够执行那些不被硬件直接支持的指令集格式。

在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观:输入的是字节码,处理过程是字节码解析的等效过程,输出的是执行结果。这里主要从概念模型的角度讲解虚拟机的方法调用和字节码执行。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到处栈的过程。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。但是对于执行引擎来说,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧关联的方法称为当前方法。典型的虚拟机栈帧结构如下图所示:

虚拟机栈帧概念结构

接下来详细讲解一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

局部变量表

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

局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中并没有定义一个 Slot 应该占多大的内存空间,只是说应该能存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据。这 8 种数据类型都可以使用 32 位或更小的物理内存来存放,但这种描述与明确指出“每个 slot 占用 32 位长度的内存空间”是由区别的,它允许 slot 的长度随着操作系统、处理器或虚拟机的不同而发生变化。

如果一个 slot 长度设定为 32 位,那么 64 位的 long、double 怎么办?虚拟机会以高位对齐的方式为其分配两个连续的 slot 空间。读写 64 位数据需要操作两个 slot 的话,就不是原子操作了,是否会有线程安全问题呢?由于局部变量表建立在线程堆栈上,是线程私有数据,无论读写两个连续的 slot 是否为原子操作,都不会引起数据安全问题。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围从 0 开始到局部变量表最大 slot 数量。如果访问的是 32 位数据,索引 n 代表使用了第 n 个 slot,如果访问的是 64 位数据,则会同时使用第 n 和 n+1 个 slot。

方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程。如果执行的是实例方法,那局部变量表中第 0 位索引的 slot 默认是用于传递所属实例的引用,也就是常见的 this。其余参数按照参数表顺序排列,占用从 1 开始的局部变量表 slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配剩余的 slot。

为了节省栈帧空间,局部变量表中的 slot 是可以重用的,因为方法体中定义的变量,其作用域不一定会覆盖整个方法体。如果当前 PC 计数器的值超出了某个变量的作用域,那么该变量占用的 slot 就可以被重用。不过,这样的设计除了节省栈空间外,还有一些额外的副作用,比如影响系统的垃圾收集行为。

在下面的程序里,执行程序时发现,虽然 gc 时 PC 计数器已经超出了 placeholder 的作用域,但是这 64 MB 内存没有被回收。原因是,作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。

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

在 System.gc() 之前插入一行 int a = 0,再运行程序发现 64 MB 内存被回收了。原因是,gc 时局部变量表里原先存储 placeholder 的 slot 已经被重用,存储了 a。

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

局部变量和类变量的区别是:类变量有两次赋初始值的过程,一次在准备阶段,赋系统初始值,一次在初始化阶段,赋程序定义的初始值。因此即使程序员没有为类变量赋初始值,类变量仍然有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值,是不能使用的。

操作数栈

操作数栈(Operand Stack)也称为操作栈,是一个先入后出的栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素,可以是任意的 Java 数据类型,包括 long 和 double。在方法执行的时候,操作数栈的深度不会超过 max_stacks 设定的值。

当一个方法刚刚开始执行的时候,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈、入栈操作。举例来说,整数加法的字节码指令 iadd 在运行的时候,需要操作数栈栈顶两个元素存入了 int 值,iadd 会把取出栈顶两个元素,相加之后把结果再存入操作数栈。

操作数栈中的数据类型必须与字节码指令序列匹配,在编译程序代码时,编译时必须严格保证这一点,在类校验阶段的数据流分析中还要在此验证这一点。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里,都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的操作数栈和上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时可以共用一部分数据,无需进行额外的参数复制传递,重叠过程如下图所示:

两个栈帧之间的数据共享

动态连接

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

方法返回地址

一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
  2. 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。

当方法返回时可能进行 3 个操作:

  1. 恢复上层方法的局部变量表和操作数栈。
  2. 把返回值压入调用者调用者栈帧的操作数栈。
  3. 调整 PC 计数器的值以指向方法调用指令后面的一条指令。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试有关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。

方法调用

方法调用并非指方法执行,方法调用阶段的唯一任务就是确定方法调用的版本(即调用哪一个方法),暂时还不设计方法内部的具体执行过程。在程序运行时,进行方法调用是最频繁、最普遍的操作,但前面已经讲过,Class 文件的编译过程不包含传统编译中的连接步骤,一切方法调用在 Class 里存储的都是符号引用,而不是方法实际运行时内存布局的入口地址(直接引用)。这个特性给 Java 带来了强大的动态扩展能力,但也使得方法调用过程变得复杂起来,需要在类加载期间甚至是运行期间才能确定目标方法的直接引用。

解析

在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间不可变。换句话说,调用目标在程序写好,编译器编译时就可以确定下来了。这类方法调用称为解析。

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

Java 虚拟机规范里提供了 5 条方法调用字节码指令:

  1. invokestatic: 调用静态方法。
  2. invokespecial: 调用实例构造器方法、私有方法和父类方法。
  3. invokevirturl: 调用所有的虚方法。
  4. invokeinterface: 调用接口方法,会在运行时确定一个实现此接口的对象。
  5. invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用指令,分派逻辑是固化在 Java 虚拟机内部的。而 invokedynamic 的分派逻辑由用户设定的引导方法决定。

只要是被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法,他们在类加载的时候就会把符号引用转化为直接引用。这一类方法被称为非虚方法,相对的其他方法就是虚方法(final 方法除外)。

public class Tes {

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

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

上述代码里 sayHello 是一个静态方法,编译后使用 javap -verbose 看字节码,会发现的确是通过 invokestatic 方法来调用的。

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: invokestatic  #5                  // Method sayHello:()V
         3: return
      LineNumberTable:
        line 14: 0
        line 15: 3
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  args   [Ljava/lang/String;

除了使用 invokestatic 和 invokespecial 调用的方法之外,还有一种就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 来调用的,但由于它无法被覆盖,又没有其他版本,所以无需对方法接收者进行多态选择。在 Java 虚拟机规范中,明确说明了 final 方法是非虚方法。

解析调用是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期才去完成。而分派 Dispatch 调用则可能是静态的,也可能是动态的,根据分派的宗量数又可以分为单分派、多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。下面我们看看虚拟机中的方法分派是如何进行的。

分派

Java 是一门面向对象的程序语言,因为 Java 具备面向对象的基本特征:继承、封装、多态。这里讲解的分派调用将会揭示多态性特征的一些最基本的体现,比如“重载”和“重写”在 Java 虚拟机之中是如何实现的。

静态分派

先看一个例子:

public class StatisticDispatch {
    static abstract class Human {}

    static class Man extends Human {}

    static class Woman extends Human {}

    public void sayHello(Human human) {
        System.out.println("hello, guy");
    }

    public void sayHello(Man man) {
        System.out.println("hello, gentleman");
    }

    public void sayHello(Woman woman) {
        System.out.println("hello, lady");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StatisticDispatch dispatch = new StatisticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
    }
}

该程序运行结果如下:

hello, guy
hello, guy

为什么会选择参数类型为 Human 的重载方法呢?在解决这个问题前,我们先看两个重要的概念。

Human man = new Man();

对于上面的代码,Human 是变量的静态类型,而 Man 是变量的实际类型。变量的静态类型是编译期就可以确定的,而实际类型需要等到运行时才能确定。虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而非实际类型来作为判定依据的。因为静态类型是编译期可知的,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到了 invokevirtual 的参数中,利用 javap -verbose 查看字节码文件可以验证这一点。

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: new           #7                  // class com/test/StatisticDispatch$Man
         3: dup
         4: invokespecial #8                  // Method com/test/StatisticDispatch$Man."<init>":()V
         7: astore_1
         8: new           #9                  // class com/test/StatisticDispatch$Woman
        11: dup
        12: invokespecial #10                 // Method com/test/StatisticDispatch$Woman."<init>":()V
        15: astore_2
        16: new           #11                 // class com/test/StatisticDispatch
        19: dup
        20: invokespecial #12                 // Method "<init>":()V
        23: astore_3
        24: aload_3
        25: aload_1
        26: invokevirtual #13                 // Method sayHello:(Lcom/test/StatisticDispatch$Human;)V
        29: aload_3
        30: aload_2
        31: invokevirtual #13                 // Method sayHello:(Lcom/test/StatisticDispatch$Human;)V
        34: return

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译期间,因此确定静态分派的动作实际上不是由虚拟机来执行的。

动态分派

动态分派和多态性的另一个重要体现:重写 Override 有着密切的关联。先看一下动态分派的例子:

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

执行程序,输出如下所示:

hello man
hello woman
hello woman

下面通过 main 方法字节码来看一下动态分派的过程:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class com/test/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method com/test/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class com/test/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method com/test/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method com/test/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method com/test/DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class com/test/DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method com/test/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method com/test/DynamicDispatch$Human.sayHello:()V
        36: return

可以看到,字节码中执行 DynamicDispatch$Human.sayHello 的是 invokevirtual 指令,执行之前通过 aload_1 和 aload_2 把相关对象从局部变量表复制到了操作栈栈顶。invokevirtual 指令的运行时解析过程大致分为以下几个步骤:

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

由于 invokevirtual 指令的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把相同的类符号引用解析到了不同的直接引用上,这个过程就是 Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态分派。

单分派与多分派

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

在 Java 语言中静态分派要同时考虑实际类型和方法参数,所以 Java 语言中的静态分派属于多分派类型。而在执行 invokevirtual 指令时,唯一影响虚拟机选择的只有实际类型,所以 Java 语言中的动态分派属于单分派类型。

虚拟机动态分派的实现

由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法原数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能考虑,大部分实现不会真正进行如此频繁的搜索。面对这种情况,最常用的稳定优化手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引代替原数据以提高查找性能。虚方法表中存放了各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类在虚方法表中的地址入口和父类是一致的,都指向父类的入口地址。如果子类重写了某个方法,则地址会替换成子类实现版本的入口地址。

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

方法表是分派调用的稳定优化手段,虚拟机除了使用方法表之外,还会使用内联缓存和基于“类型继承关系分析”技术的守护内联两种非稳定的“激进优化”手段来获得更高的性能。

动态类型语言支持

JDK7 与动态类型

从 JDK7 开始,字节码指令集增加了一个新成员:invokedynamic 指令,这是为了实现“动态类型语言”支持而做的改进之一,也是为 JDK8 顺利实现 LAMBDA 表达式做技术储备。

什么是“动态类型语言”呢?动态类型语言的关键特征是他的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,比如 JavaScript、Python、Lua 等。相对的,在编译期进行类型检查的语言,比如 C++、Java 等就是静态类型语言。

举个例子来解释类型检查,比如下面这行简单的代码:

org.println("hello world");

假设这是一行 Java 代码,obj 的静态类型是 java.io.PrintStream,那变量的实际类型必须是 PrintStream 的子类才是合法的。否则哪怕 obj 实际类型有一个 println(String) 的方法,但与 PrintStream 没有继承关系,因为类型检查不合法,代码依然不能运行。

但是相同的代码在 ECMAScript(JavaScript)中则不一样,无论 obj 是何种类型,只要这种类型的定义中确实包含有 println(String) 方法,代码就可以执行成功。

静态类型语言的优点是编译器可以提供严格的类型检查,一些与类型相关的问题可以在编码时就及时发现,有利于稳定性以及代码达到更大规模。而动态类型语言在运行期确定类型,可以为开发人员提供更大的灵活性,某些在静态语言中需要大量“臃肿”代码来实现的功能,由动态类型语言实现会更加清晰和简洁,清晰简洁也就意味着开发效率的提升。

20 年前,《Java 虚拟机规范》里就规划了这样一个愿景“在未来,我们会对 Java 虚拟机进行适当的扩展,以便更好地支持其他语言运行于 Java 虚拟机之上”。目前已经有很多动态类型语言运行于 Java 虚拟机之上了,比如 Clojure、Groovy、Jython、JRuby 等,能够在同一个虚拟机上实现静态语言的严谨性和动态语言的灵活性,是一件很美妙的事情。

但遗憾的是,Java 虚拟机层面对动态类型语言的支持一直有所欠缺,主要表现在方法调用上面:JDK1.7 之前的指令集中,4 条方法调用指令(invokestatic、invokespecial、invokevirtual、invokeinterface)的第一个参数都是被调用方法的符号引用(CONSTANT_Methodref_info 或 CONSTANT_InterfaceMethodref_info 常量)。方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接受者类型。这样 Java 虚拟机上实现动态类型语言就不得不使用其他方式(编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或内存开销。因此在 JDK1.7 里新增了 invokedynamic 指令以及 java.lang.invoke 包,在虚拟机层面提供了对动态类型的直接支持。

java.lang.invoke 包

java.lang.invoke 包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为 MethodHandle。

如果要实现一个带谓词的排序函数,在 C/C++ 里的常用做法是把谓词定义为函数,用函数指针把谓词传递到排序方法:

void sort(int list[], const int size, int (*compare)(int, int))

而 Java 里是没有办法把函数作为参数进行传递的,普遍的做法是设计一个带有 compare() 方法的 Comparator 接口,以实现了这个接口的对象作为参数进行传递:

void sort(List list, Comparator comparator);

在拥有了 MethodHandle 之后,Java 语言也拥有了类似于函数指针或委托方法别名的工具了,下面的方法演示了 MethodHandle 的用法,无论 obj 是何种类型,都可以正确调用到 pringln 方法。

public class MethodHandleTest {
    static class ClassA {
        public void println(String str) {
            System.out.println("From ClassA: " + str);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = new ClassA();
        getPrintlnMH(obj).invokeExact("hello");
        getPrintlnMH(System.out).invokeExact("hello");
    }

    private static MethodHandle getPrintlnMH(Object receiver) throws Exception {
        MethodType methodType = MethodType.methodType(void.class, String.class);
        return MethodHandles.lookup()
                .findVirtual(receiver.getClass(), "println", methodType)
                .bindTo(receiver);
    }
}

实际上,方法 getPrintlnMH 里模拟了 invokevirtual 指令的执行过程,只不过它的分派逻辑并非固化在 Class 文件字节码上,而是通过一个具体的方法来实现。这个方法最终的返回值 MethodHandle 可以看做是对最终调用方法的一个引用,以此为基础就可以写出下面这样的函数声明:

void sort(List list, MethodHandle MethodHandle);

看完 MethodHandle 的用法之后,大家可能会有疑问,相同的事情用反射不也可以实现吗?MethodHandle 与 Reflection 确实很像,不过它们还是有以下区别:

  1. 从本质上讲,MethodHandle 与 Reflection 都是在模拟方法调用,但 Reflection 是在模拟 Java 层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用,在 MethodHandle.loolup() 中的三个方法 findVirtual()、findStatic()、findSpecial() 正是对应了 invokevirtual & invokeinterface、invokestatic、invokespecial 这几条字节码指令的行为。
  2. Reflection 中的 java.lang.reflect.Method 包含的信息比 MethodHandle 多很多。前者是方法在 Java 端的全面映像,包含方法签名、描述符以及方法属性表在 Java 端的表示方式,还包含执行权限等运行期信息。而后者仅仅包含于执行该方法相关的信息。通俗地讲,前者是重量级(执行慢),后者是轻量级(执行快)。
  3. 由于 MethodHandle 是对字节码的方法调用指令的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在 MethodHandle 上也可以采用类似的思路去支持(JDK1.7 里还不完善)。而通过反射去调用的方法则不行。
  4. Reflection 的设计目标是为 Java 语言服务的,MethodHandle 则设计成可服务于所有 Java 虚拟机之上的语言,当然也包括 Java 语言。

invokedynamic 指令

在某种程度上,invokedynamic 指令与 MethodHandle 是类似的,都是为了解决原有的 4 条 invoke* 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中,让用户有更高的自由度。

在 JDK1.7 里无法通过 Java 代码编译生成 invokedynamic 指令,JDK1.8 里引入了 Lambda 表达式,其实现就利用了 invokedynamic 指令,先看下面的代码:

public class InvokeDynamicTest {
    public static void main(String[] args) {
        Runnable x = () -> {
            System.out.println("hello");
        };
    }
}

编译成字节码后,再反编译,可以看到 main 方法里第一条指令就是 invokedynamic,该指令第一个参数是常量池数据的索引,第二个参数是保留的占位符(目前必须为 0)。

Constant pool:
   #1 = Methodref          #7.#24         // java/lang/Object."<init>":()V
   #2 = InvokeDynamic      #0:#29         // #0:run:()Ljava/lang/Runnable;
  #29 = NameAndType        #39:#40        // run:()Ljava/lang/Runnable;
  #39 = Utf8               run
  #40 = Utf8               ()Ljava/lang/Runnable;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         5: astore_1
         6: return
      LineNumberTable:
        line 9: 0
        line 12: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  args   [Ljava/lang/String;
            6       1     1     x   Ljava/lang/Runnable;

常量池索引为 2 的数据是 CONSTANT_InvokeDynamic_info 类型的常量,其本身又包含了 2 个索引:bootstrap_method_attr_index 是指向 BootstrapMethods 属性表里 BootstrapMethod 方法的索引,这里 #0 表示指向第 0 个 BootstrapMethod。name_and_type_index 索引类常量池里的 CONSTANT_NameAndType_info 类型数据,描述了一个方法名为 run,返回值类型为 java/lang/Runnable 的方法。

CONSTANT_MethodHandle_info {
    u1 tag;
    u1 reference_kind;
    u2 reference_index;
}

BootstrapMethods 属性格式如下所示:

BootstrapMethods_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 num_bootstrap_methods;
    {   u2 bootstrap_method_ref;
        u2 num_bootstrap_arguments;
        u2 bootstrap_arguments[num_bootstrap_arguments];
    } bootstrap_methods[num_bootstrap_methods];
}

看字节码可知,该类里只有一个 BootstrapMethod,bootstrap_method_ref 指向常量池 #26,bootstrap_arguments 指向常量池 #27、#28。

  #26 = MethodHandle       #6:#37         // invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
  #27 = MethodType         #9             //  ()V
  #28 = MethodHandle       #6:#38         // invokestatic com/test/InvokeDynamicTest.lambda$main$0:()V
  #37 = Methodref          #47.#48        // java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;

BootstrapMethods:
  0: #26 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #27 ()V
      #28 invokestatic com/test/InvokeDynamicTest.lambda$main$0:()V
      #27 ()V

常量 #23 是一个 CONSTANT_MethodHandle_info 类型数据,其结构如下所示:

reference_kind 是个数字,表示某方法调用指令,这里 #6 表示 REF_invokeStatic,reference_index 是 #29,是个 CONSTANT_Methodref_info,指向 LambdaMetafactory 类的 metafactory 方法:

	// 通过加断点运行,可以看到传递给 metafactory 的参数,列在了注释里
    public static CallSite metafactory(MethodHandles.Lookup caller,		// caller: com.test.InvokeDynamicTest
                                       String invokedName, 					// run
                                       MethodType invokedType,				// ()Runnable
                                       MethodType samMethodType,			// ()void
                                       MethodHandle implMethod,				// ()void
                                       MethodType instantiatedMethodType)	// ()void
            throws LambdaConversionException {
    }

LambdaMetafactory.metafactory 方法一共有六个参数:

  1. MethodHandles.Lookup caller: 代表查找上下文与调用者的访问权限, 使用invokedynamic指令时, JVM会自动自动填充这个参数。
  2. String invokedName: 要实现的方法的名字, 使用 invokedynamic 时, JVM 自动帮我们填充(填充内容来自常量池 InvokeDynamic.NameAndType.Name), 在这里 JVM 为我们填充为 run, 即 Runnable 接口的唯一方法名 run。
  3. MethodType invokedType: 调用点期望的方法参数的类型和返回值的类型(方法signature). 使用 invokedynamic 指令时, JVM 会自动自动填充这个参数(填充内容来自常量池 InvokeDynamic.NameAndType.Type), 在这里是 ()Runnable, 表示这个调用点的目标方法没有参数, 然后 invokedynamic 执行完后会返回一个即 Runnable 实例。
  4. MethodType samMethodType: 函数对象将要实现的接口方法类型, 这里运行时, 值为 ()void,即 Runnable.run 方法的类型 #27 ()V。
  5. MethodHandle implMethod : 一个直接方法句柄(DirectMethodHandle), 描述在调用时将被执行的具体实现方法 (包含适当的参数适配, 返回类型适配, 和在调用参数前附加上捕获的参数), 在这里为 #28 invokestatic com/test/InvokeDynamicTest.lambda$main$0:()V 方法的方法句柄。
  6. MethodType instantiatedMethodType: 函数接口方法替换泛型为具体类型后的方法类型, 通常和 samMethodType 一样, 不同的情况为泛型。当函数接口带泛型参数时,samMethodType 不带泛型真实类型,而 instantiatedMethodType 带有泛型真实类型。
  // implMethod 所指向的静态私有方法,也就是 Runnable.run 的内容
  private static void lambda$main$0();
    descriptor: ()V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #4                  // String hello
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8

总结一下,invokedynamic 指令是如何执行的:

  1. 每一个 invokedynamic 指令出现的地方,都叫做一个 dynamic call site(动态调用点)。根据 invokedynamic 指令的操作数可以找到调用点说明符,即 CONSTANT_InvokeDynamic_info 常量。
  2. 调用点说明符里包含三种信息:
    • 一个 MethodHandle,指向一个 bootstrap method(启动方法)
    • 方法名和方法描述,表示动态调用的方法
    • 其他提供给启动方法的参数
  3. 接着JVM调用启动方法,并把上一步提到的信息通过参数传给启动方法。
  4. 启动方法必须返回一个 CallSite 对象,并且,这个 CallSite 对象将永久和这个动态调用点关联。
  5. 调用跟 CallSite 关联的 MethodHandle 指向的方法。

到这里 invokedynamic 的执行流程就讲完了,下面我们再深入了解一下 LambdaMetafactory.metafactory 这个方法,看看它都做了哪些工作?

LambdaMetafactory.metafactory 分析

LambdaMetafactory.metafactory 方法主要是创建了动态调用点 CallSite,看下面的代码可知,主要是调用了 InnerClassLambdaMetafactory.buildCallSite 来创建的。

    public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                             invokedName, samMethodType,
                                             implMethod, instantiatedMethodType,
                                             false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
        // 参数校验
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
    }

InnerClassLambdaMetafactory.buildCallSite 方法做了三件事:

  1. 首先,调用 spinInnerClass 方法创建了一个匿名内部类,也就是 Runnable 接口的一个实现类。
  2. 然后,调用其构造函数,创建了一个实例。
  3. 最后,创建指向 ()Ljava/lang/Runnable 方法的 MethodHandle,放入 ConstantCallSite 返回。
    CallSite buildCallSite() throws LambdaConversionException {
    	// 使用 ASM 技术创建了一个内部类
        final Class<?> innerClass = spinInnerClass();
        if (invokedType.parameterCount() == 0) {
            final Constructor<?>[] ctrs = AccessController.doPrivileged(
                    new PrivilegedAction<Constructor<?>[]>() {
                @Override
                public Constructor<?>[] run() {
                    Constructor<?>[] ctrs = innerClass.getDeclaredConstructors();
                    if (ctrs.length == 1) {
                        // The lambda implementing inner class constructor is private, set
                        // it accessible (by us) before creating the constant sole instance
                        ctrs[0].setAccessible(true);
                    }
                    return ctrs;
                }
                    });
            // 如果不止一个构造函数,则抛异常
            if (ctrs.length != 1) {
                throw new LambdaConversionException("Expected one lambda constructor for "
                        + innerClass.getCanonicalName() + ", got " + ctrs.length);
            }

            try {
            	// 创建该内部类的一个实例
                Object inst = ctrs[0].newInstance();
                // MethodHandles.constant 返回一个 MethodHandle,指向一个方法,该方法的返回结果就是 Runnable 实例 inst
                return new ConstantCallSite(MethodHandles.constant(samBase, inst));
            }
            catch (ReflectiveOperationException e) {
                throw new LambdaConversionException("Exception instantiating lambda object", e);
            }
        } else {
            try {
                UNSAFE.ensureClassInitialized(innerClass);
                return new ConstantCallSite(
                        MethodHandles.Lookup.IMPL_LOOKUP
                             .findStatic(innerClass, NAME_FACTORY, invokedType));
            }
            catch (ReflectiveOperationException e) {
                throw new LambdaConversionException("Exception finding constructor", e);
            }
        }
    }

一些资料

  1. How can I improve performance of Field.set (perhap using MethodHandles)
发布了232 篇原创文章 · 获赞 347 · 访问量 79万+

猜你喜欢

转载自blog.csdn.net/hustspy1990/article/details/86545897