JVM

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

Posted by Charlie on 2019-09-06

[TOC]

概述

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

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

运行时栈帧结构

img

一个线程中,当线程调用某个方法时,JVM会相应的创建一个栈帧(Stack Frame),放入虚拟机栈中,用来表示某个方法的调用。

栈帧是用来存储数据,和存储部分过程结果的数据结构,同时也用来处理动态链接(Dynamic linking)、方法返回值、异常分派(Dispatch Exception)。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附 加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完 全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受 到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

线程对(某个对象的)方法的调用,就对应着一个栈帧的入栈、出栈(虚拟机栈)的过程。线程在运行过程中,只有一个栈帧是处于活跃状态,称为当前活动栈帧,当前活动栈帧,始终处于虚拟机栈的栈顶,当前活动栈帧对应的方法,也是当前线程正在执行的方法。

局部变量表

操作数栈

JVM底层字节码指令集是基于栈类型的,所有的操作码都是对操作数栈上的数据进行的操作。对每一个方法调用,JVM都会创建一个操作数栈,供计算使用。栈的深度,在编译期就已经确定了,其栈深度存储在方法的Code属性的max_stacks中。

32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2

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

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

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

image-20190906161217711

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。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: 调用实例构造器<init>方法、私有方法和父类方法。

  3. invokevirturl: 调用所有的虚方法。

    Java虚方法你可以理解为java里所有被overriding的方法都是virtual的,所有重写的方法都是override的。

    在JVM字节码执行引擎中,方法调用会使用invokevirtual字节码指令来调用所有的虚方法。

  4. invokeinterface: 调用接口方法,会在运行时确定一个实现此接口的对象。

  5. invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用指令,分派逻辑是固化在 Java 虚拟机内部的。而 invokedynamic 的分派逻辑由用户设定的引导方法决定。

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

1
2
3
4
5
6
7
8
public class Test {
public static void sayHello() {
System.out.println("hello");
}
public static void main(String[] args) {
sayHello();
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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 虚拟机之中是如何实现的。

静态分派

先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
}
}

程序运行结果为:

1
2
hello, guy
hello, guy

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

1
Human man = new Man();

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

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

动态分派

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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();
}
}

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

1
2
3
hello man
hello woman
hello woman

虚拟机是如何知道要调用哪个方法 的? 显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量man 和woman在调用sayHello()方法时执行了不同的行为。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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/meituan/mtrace/web/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method com/meituan/mtrace/web/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class com/meituan/mtrace/web/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method com/meituan/mtrace/web/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method com/meituan/mtrace/web/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method com/meituan/mtrace/web/DynamicDispatch$Human.sayHello:()V
24: new #4 // class com/meituan/mtrace/web/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method com/meituan/mtrace/web/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method com/meituan/mtrace/web/DynamicDispatch$Human.sayHello:()V
36: return

这两条调用指令,单从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是 Human.sayHello()的符号引用)完全一样的。但是这两句指令最终执行的目标方法并不相 同。原因就需要从invokevirtual指令的多态查找过程开始说起。

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 等就是静态类型语言。

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

1
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++ 里的常用做法是把谓词定义为函数,用函数指针把谓词传递到排序方法:

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

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

1
void sort(List list, Comparator comparator);

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 可以看做是对最终调用方法的一个引用,以此为基础就可以写出下面这样的函数声明:

1
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 指令,先看下面的代码:

1
2
3
4
5
6
7
public class InvokeDynamicTest {
public static void main(String[] args) {
Runnable x = () -> {
System.out.println("hello");
};
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 的方法。

1
2
3
4
5
CONSTANT_MethodHandle_info {
u1 tag;
u1 reference_kind;
u2 reference_index;
}

BootstrapMethods 属性格式如下所示:

1
2
3
4
5
6
7
8
9
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。

1
2
3
4
5
6
7
8
9
10
11
  #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/meituan/mtrace/web/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/meituan/mtrace/web/InvokeDynamicTest.lambda$main$0:()V
#27 ()V

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

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

1
2
3
4
5
6
7
8
9
// 通过加断点运行,可以看到传递给 metafactory 的参数,列在了注释里
public static CallSite metafactory(MethodHandles.Lookup caller, // caller: com.meituan.mtrace.web.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/meituan/mtrace/web/InvokeDynamicTest.lambda$main$0:()V 方法的方法句柄。

  6. MethodType instantiatedMethodType: 函数接口方法替换泛型为具体类型后的方法类型, 通常和 samMethodType 一样, 不同的情况为泛型。当函数接口带泛型参数时,samMethodType 不带泛型真实类型,而 instantiatedMethodType 带有泛型真实类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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(启动方法)

​ - 方法名和方法描述,表示动态调用的方法

​ - 其他提供给启动方法的参数

  1. 接着JVM调用启动方法,并把上一步提到的信息通过参数传给启动方法。

  2. 启动方法必须返回一个 CallSite 对象,并且,这个 CallSite 对象将永久和这个动态调用点关联。

  3. 调用跟 CallSite 关联的 MethodHandle 指向的方法。

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

LambdaMetafactory.metafactory 分析

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
CallSite buildCallSite() throws LambdaConversionException {
// 生成匿名内部类
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);
}
}
}

基于栈的字节码解释执行引擎