JVM:字节码执行引擎

823 阅读19分钟

本章内容概括自《深入了解 JVM 虚拟机》 第八章。大致可以分为三个部分:

  1. 了解 Java 是基于栈的字节码引擎,栈帧的概念。

  2. 了解 Java 是 "静态多分派,动态单分派" ,这影响了 Java 在发生重载,重写时如何分派方法。

  3. 了解 JVM 为了增加动态语言支持而引入的 java.lang.invoke 包,以及 invokedynamic 指令,包括简单了解 Java 是如何利用这个指令实现 Lambda 表达式的。

1. 基于栈的字节码引擎

Javac 编译器产生的字节码指令流,大体上是基于栈的指令集结构 ( Instruction Set Architecture,ISA ),指令流基本都是零地址指令,因此它们依赖于操作数栈。而另外一种则是基于寄存器的指令集架构,这些这令则依赖于寄存器。

比如,同样是 "1 + 1" 的算术问题,两种指令集架构会有不同的实现。第一种是基于栈的指令集:

iconst_1
iconst_1
iadd
istore_0

两条 iconst_1 指令将 1 压入栈之后,iadd 将栈顶两数加和,最后由 istrore_0 指令将栈顶的运算结果存到 0 号局部变量表当中。第二种是基于寄存器的指令集:

mov eax,1
add eax,1 

第一条指令,将值 1 存储到 eax 寄存器内。第二条指令,将 eax 寄存器内的值 + 1 并保存。在基于寄存器的指令集架构中,二地址指令是 x86 ( 典型的基于寄存器的指令集架构 ) 指令集的主流。

相较于基于寄存器的指令集架构,基于栈的指令集架构的优势是:可移植性高,符合 Java 当初的设计目的。当然,其代价是以少许性能做牺牲。第一,完成相同的功能,基于栈的指令集需要更多的步骤才能完成 ( 比如说操作数入栈,出栈等操作 )。第二,栈的实现在内存当中,频繁访问栈就意味着频繁访问内存,那么处理器和内存的处理速度不一致也导致了性能上的瓶颈。

2. 栈帧

一个函数 ( 也可以说是方法,在这里语义差不多 ) 的执行会包含以下动作:取值,运算,返回或抛出异常。而栈帧是虚拟机用于调用,执行方法背后的数据结构。栈帧大致上根据上述函数的动作划分了几片空间:

数据是运算的原料。函数的数据来源于参数列表,或者是内部定义的局部变量,栈帧需要一小块空间存储它们,这个空间为局部变量表。提到运算,自然也需要相应的操作空间。这个空间即操作数栈。可以将它理解成是一个工作台,每一个栈帧都有独立的工作台。至于每个栈帧的这两部分应该申请多大的空间,其实 javac 编译器在分析源代码时就已经提前计算好了。

那么,谁来负责执行字节码指令流?答案是 JVM 执行引擎 ( Execute Engine ),它负责将字节码指令解释为对应平台上的本地机器指令。每个栈帧都可定位到某个类的方法引用 ( 书中原话是:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用 ),执行引擎将依据一个动态链接定位到栈帧对应的方法引用,获取并执行 Code 属性存储的字节码指令,等等。而 Java 的动态链接特性引发了后文对方法调用章节的阐述,有关 "为何称动态" 的问题见后文。

函数执行完成之后就要返回,这需要一个方法返回地址。在一连串的调用链中,某栈帧 fn 在返回时相关线程会:恢复上一个栈帧 fn-1 的局部变量表,操作数栈 ( 如果 fn 有返回值,那么它也被压入此栈内 ),调整 PC 计数器到下一条指令等。说得更简单点,就是将 fn 调用完毕后弹出其栈帧,取值 ( 如果有 ),然后恢复 fn-1 的调用现场。

要是再生动点,可以将它类比 "厨子做菜" 的过程 ( 一道料理,一个砧板,一个菜筐,一个餐盘,很讲究 ):

stackframe.png

在一个时间片内,CPU 只会执行某一个线程栈的,且处在栈顶的栈帧,它又称之为当前栈帧 ( Current Stack Frame ),关联的方法称之为当前方法 ( Current Method )。

当然,JVM 做了少许优化,比如说相邻的两个栈帧,可能操作数栈和局部变量表存在少许叠加,这样的话既节省了空间,理论上又可以共享数据而减少一些值复制的过程。

还有其它的细节:局部变量表的数据存储单位是槽 Slot,而非通常的字节。Java 规定了一些 "短" 数据类型使用 1 Slot,而 Long,Double 使用 2 Slot 空间。然而《Java 虚拟机规范》没有明确定义一个槽 Slot 究竟是多少字节。此外,由于变量槽复用技术,局部变量表的总空间也不是简单地加和计算 ( 实际空间会略低 )。

3. 方法调用

整篇文章只需要了解一个重点:Java 是一门静态多分派,动态单分派的语言。

由于存在多态,重载,重写等概念,实际上方法调用可能会出现 "歧义",因此方法调用的过程并不是想象中那样简单地 "指哪打哪" ,这些引发 "歧义" 的方法称之为 "虚" 方法,从名字来看,这个概念应该舶来自 C++。对应的,负责调用虚方法的字节码指令为 invokevirtual

一个方法的实际调用可能要具体情况具体分析,分析的时机或许是在编译期,但是大部分都被推迟到了运行期 ( 当然,分析的时机越早越好 ) 。正是因为这种不确定性,栈帧使用的才是 "动态链接",而不是 "静态链接"。它势必为 Java 的方法调用带来了复杂性,但好处则是带来了灵活性。

不过,有些方法的确是在运行期间不可变的。在编译期间就可以确定下来的方法分别有:静态方法 ( invokestatic 指令 ),私有方法,实例构造器,父类方法 ( 这三者对应 invokespecial 指令 )。除此之外,还有一个特殊的方法,那就是被 final 关键字修饰的方法 ( 尽管它仍然使用 invokevirtual 指令调用 )。这五类 "明确的方法" 被称之为 "非虚方法" ( Non-Virtual Method )。处理这五类方法的过程称之为解析 ( Resolution )。

其它使用 invokevirtual 抑或是 invokeinterface 指令的方法需要通过分派 ( Dispatch ) 来完成。是的,其实 Java 中定义的那些 "普通方法" 其实都是虚函数,只不过 Java 的分派是一种默认行为,我们在编程时也不在乎它们是不是 "虚" 的。分派本身可以根据静态/动态分派,单/多分派而划分出四个区间出来:

method_dispatcher.png

其中,蓝色区域表示 Java 语言占据的区间。在其它资料当中,静态分派的说法是 Method Overload Resolution,即静态分派的概念有时会被归纳到解析的范畴。在本文中,遵循《深入了解 JVM 虚拟机》 的传统,放到分派那里介绍。

3.1 静态类型和动态类型

这里还有一些概念要提及。假设 FatherSon 的父类,下面有一个上转型对象:

// son 是一个上转型对象。
Father son = new Son();

在学习 Java 动态绑定 ( 即本文的动态分派 ) 中,上转型对象是一个典型的用例。在这个赋值语句中,Father 被称之为 "静态类型",它一定是编译期间就可以被确定的。但是显然 son 指向一个 Son 类型的实例, Son 又被称之为该变量的 "实际类型"。静态类型可以通过强制转换进行上下转型。

如果调用 son 的某个方法 hi(),该方法准确来说是不可知的 "虚方法"。

// son 是一个上转型对象
Father son = new Son();

// 假设 hi() 是一个父类实现且继承给子类的方法。
son.hi();

这个语境太简单了,习惯 Java 思考方式的我们可能会认为这个 hi() 似乎就应该是 Son 类的实例方法,天经地义。但是依编译器来看,直到真正运行前它都无法下此断言。下面的代码可能看起来会更加明显:

Father son;
son = new Random().nextInt(100)%2 == 0 ? new Son() : new Father();

// 在一个循环中运行,会发现运行结果未必每次相同。
for(int i = 0; i<=100; i++)
{
	// 调用的应该是 Father 的方法,还是 Son() 的方法?
    // 除非运行这段代码,否则谁也不会知道结果是什么样的。
	son.hi();
}

此时,谁也无法定论这个 hi() 的接收者究竟是 Father 还是 Son 了。hi() 似乎陷入了一个 "叠加态" ...... 除非这段程序被真正运行。

3.2 单分派和多分派

方法的接收者 ( Go 语言中,谁被绑定了函数,谁就是这个函数的接收者,相当于表明 "谁的方法",在这里同理 ),方法的参数统称为方法的 "宗量"。这个称呼来源于那本书,如果觉得不习惯,也可以称之 "变量"。在方法调用出现 "歧义" 的情形 —— 即发生重载,重写的情况,如果只打算根据一个宗量选择 "合适的方法",那么这种分派方式称之为 "单分派"。反之,如果从多个宗量考虑并选择最合适的方法,这种分派方式则称之为 "多分派"。

3.3 静态分派和动态分派

通俗来说,静态分派指编译期能确定下来的分派,动态分派指运行期间才能确定下来的分派。

这两个概念和前文的静态类型/动态类型有很大的关联。这两个术语的典型应用其实分别对应着重载 Overload ( 静态分派 ) 和重写 Override ( 动态分派 )

首先说静态分派。静态分派就是表示方法的分派仅取决于静态类型。而又由于静态类型是编译期可知的,因此静态分派也是编译期可知的。从描述中可得知,静态分派的任务实际上并不是 JVM 负责,而是 javac 编译器。

那么动态分派则表示方法的分派取决于方法接收者的实际类型。实际类型仅在运行期可知,因此动态分派也只能在运行期进行。从这段描述中可知,动态分派是 JVM 在运行期做的工作。

这些概念,是理解 " Java 静态多分派,动态单分派" 这句话的前提。它理解起来可能十分抽象,但我们可以通过一些实验或者现象来得到这一条结论。

3.4 静态多分派

首先,Java 的静态分派发生在重载 Overload 的过程。

public class StaticDispatcher {

    static private class Father{}
    static private class Son extends Father{}
    
    // 只被重载 overload 的 receive 方法。
    public void receive(Son a){
        System.out.println("receive(Son)");
    }

    public void receive(Father a){
        System.out.println("receive(Father)");
    }

    public static void main(String[] args) {
        StaticDispatcher staticDispatcher = new StaticDispatcher();

        Father a = new Son();
        // a 的静态类型就是 Father,调用 receive(Father) 方法
        staticDispatcher.receive(a);

       	Son b = new Son();
        // b 的静态类型就是 Son,调用 receive(Son) 方法
        staticDispatcher.receive(b);
    }
}

而 Java 称之 "静态多分派" 的原因在于,编译器基于两个宗量进行检查:

  1. 确定传入参数的静态类型。
  2. 方法接收者是否有处理对应静态类型的方法。否则,则检查是否有可接收此静态类型的父类型的方法。否则,则向接收者的上一级类型检查是否有合适的方法,依此类推。

依照这两个宗量,编译器可在编译过程中,仅凭借静态类型在所有重载方法中尽可能选择最贴切的那个方法,因此又被称之为 "静态多分派"。比如说,假设将 StaticDispatcher::receive(Son) 方法注释掉,这段代码仍然可以通过编译并成功运行,结果是:

receive(Father)
receive(Father)

显然,当没有 receive(Son) 方法时,编译器仍可以选择将第二个 staticDispatcher.receive(b) 调用分派给 receive(Father) 方法。

3.5 动态单分派

首先,Java 的动态单分派典型的出现场景:触发了一个上转型对象的重写 Override 。

public class BaseDynamicDispatcher {
    // 1
    public void receive(Object a){
        System.out.println("BaseDynamicDispatcher::receive(Object)");
    }
}

class DynamicDispatcher extends BaseDynamicDispatcher {
    // 2
    public void receive(Object a){
        System.out.println("DynamicDispatcher::receive(Object)");
    }

    // 3 ,注意,这个方法不是继承来的,是 DynamicDispatcher 额外拓展的方法。
    public void receive(String a){
        System.out.println("DynamicDispatcher::receive(String)");
    }

    public static void main(String[] args) {
        BaseDynamicDispatcher baseDynamicDispatcher = new DynamicDispatcher();
        baseDynamicDispatcher.receive("aString");
    }
}

由于 baseDynamicDispatcher 的静态类型是 BaseDynamicDispatcher,因此运行时 JVM 仅会在方法 1 和方法 2 中做出选择,此时仅取决于方法接收者的实际类型 。而 receive(String) 不是父类 BaseDynamicDispatcher 的方法,在动态分派时,该方法会被忽略。

换句话说,Java 的这个行为就好像 "忽略掉" 了参数的类型,尽管在我们看来方法 3 才是最合适的选择。因此,Java 的动态分派又称之为动态单分派。

而动态多分派的一个例子是同样运行在 JVM 上的 Groovy 语言。和 Java 不同,Groovy 在同样的代码语义下能够 "精准定位" 到方法 3。动态多分派使得 Groovy 总是能够选择最 "合适" 的方法去调用,这符合 Groovy 作为 "灵活的动态语言" 的行事风格,而代价是让函数调用过程变得更加复杂了。有关这一部分的探讨内容曾在笔者的 Groovy 专栏中出现过,详情可见:通过 Groovy 了解动态语言 (juejin.cn) 3.5 节:有关 Groovy 的方法多态。

那么,有什么办法能够让 baseDynamicDispatcher 分派到 receive(String) 方法呢?有两种思路:

  1. baseDynamicDispatcher 的静态类型不变,则将 receive(String) 方法迁移到父类 BaseDynamicDispatcher ,思路参考前文的静态多分派。
  2. baseDynamicDispatcher 的静态类型声明为 DynamicDispatcher,或者在调用时进行强制类型转换,使其满足动态单分派的前提。

参考资料可见:Java多态(详解重载和重写,静态分派和动态分派)_凉柒-lq的博客-CSDN博客

JVM第四篇 程序计数器(PC寄存器) - 盲目的拾荒者 - 博客园 (cnblogs.com)

栈帧中动态连接的理解 - Tom猫小齐 - 博客园 (cnblogs.com)

4. 关于动态语言

所谓动态类型语言,一大关键的特征是:类型检查的主体过程推迟到运行期确定,而不是运行期。满足这个特性的语言有 Python,JavaScript,Ruby 等等。显然,像 Java,C++ 都是典型的在编译期就进行类型检查的静态语言。

动态语言灵活性比静态语言更高。相对的,它需要开发人员通过一系列 "自律的约束" 来保证程序不会在运行期间发生错误。而静态语言虽然让代码变得更加冗余一些,但好处是编译器可以代替开发人员对潜在问题进行检查,以事先防范一些运行事故。

回归到 Java 的话题。《Java 虚拟机规范》在第一版中就做出了这样的承诺:“在未来,我们会对 Java 虚拟机进行适当的拓展,以便于更好地支持其它语言运行在 Java 虚拟机之上。” 事实上,确实有相当多语言都能够跑在 JVM 之上了,包括静态语言和动态语言。

然而,JVM 一开始只是为 Java 这样的静态语言提供平台,很多任务都被固化到了编译期去完成,这导致在早期 ( JDK 7 及之前 ) ,JVM 对动态语言的支持性很差。因此,JDK 7 在 JSR-292 提案中出现了 java.lang.invoke 包,并且 JVM 在二十余年中终于引入了一条全新的,用于底层支持动态语言 ( 或者说用于自主分派 ) 的字节码指令 invokedynamic

4.1 java.lang.invoke & MethodHandle

java.lang.invoke 的设计目的是除了单靠符号引用进行方法分派以外,提供一种全新的,源代码层面上的,能够动态确定目标方法的机制。

比如,Java 将对象视作是一等公民,没有办法直接传递定义的函数 ( 表达式 ),直到 Lambda 表达式的引入才算缓解了这一窘境,当然,两者从底层概念上来讲仍然是存在差别的,只是 "用起来像那么回事了"。 java.lang.invoke 包则为弥补了 Java 缺失 "函数指针" 这一概念的遗憾,提供了一个 MethodHandle 类型。

下面的代码演示了利用 MethodHandle 类型,通过运行期搜索 obj 的实际类型,获取并调用 println() 方法 "句柄" 的例子:

public class MethodHandleTest {

    static private class FakePrintStream {
        public void println(String any){
            System.out.println(any);
        }
    }

    public static void main(String[] args) throws Throwable {
        
        // 无论 obj 实际类型是哪个方法,只要能找到 "void println(String)" 方法,这段代码就能正常运行。
        Object obj =new Random().nextInt(100)%2 == 0 ? System.out : new FakePrintStream();

        MethodHandle methodHandle = bindPrintln(obj);
        // 等价于调用System.out.println(...)
        methodHandle.invoke("invoke System.out::println");
    }

    /**
     * 这个方法可以为对象 receiver 中搜索并提取出 println 方法的 "句柄"。
     * @param receiver 搜索对象。
     * @return 返回绑定 receiver 为接收者的方法 "句柄"。
     * @throws NoSuchMethodException
     * @throws IllegalAccessException
     */
    static MethodHandle bindPrintln(Object receiver) throws NoSuchMethodException, IllegalAccessException {

        // 通过它定义目标方法的签名,第一个参数为返回值类型,之后均为参数列表对应类型。
        MethodType mt = MethodType.methodType(void.class,String.class);

        /*
         *  findVirtual(...) 寻找虚方法,命名和字节码指令对应,类似的还有 findStatic,findSpecial 等等。
         *  bindTo(...) 可以理解为将这个类定义的方法绑定给了实例,相当于绑定一个 "this".
         *
         */
        return lookup().findVirtual(receiver.getClass(),"println",mt).bindTo(receiver);
    }
}

这段代码相当于手动实现了一遍 invokevirtual 指令,只不过这一次是在用户代码的层面中解决的。如果仅站在 Java 角度而言,这段代码利用反射 Reflection 同样可以解决。两者之间的主要区别是:

  1. Reflection 模拟 Java 代码的调用,MethodHandle 模拟字节码层面的调用。
  2. Reflection 能够提取出与方法相关的各种信息 ( 重量级 ),而 MethodHandle 只注重于调用方法本身 ( 轻量级 )。
  3. MethodHandle 理论上能够享受到 JVM 对方法指令调用进行的优化,但是反射不行。
  4. Reflection 可以无视权限修饰符,导致安全性可能更差,比如越界访问,强制调用等。

一言以蔽之,Reflection 好用,但稍慢。MethodHandle 难用 ( 指获取不了与方法有关的更多信息 ),但是跑起来更快。抛开 "站在 Java 角度" 的立场,MethodHandle 被设计为可以服务于所有 JVM 的语言,Java 只是其中的一部分。

同时,这样的代码风格也可以称之为 "能力式" 设计:不管 objPrintStream 还是由用户设计出来的其它类型,只要程序能够找到名为 void println(String) 的方法,methodHandle 总是能够正确地运行,并且没有事先定义任何接口去约束。而 obj 也可以称作是 "鸭子类型" —— 显而易见,这种设计风格非常贴合动态语言的要求 —— "一切尽在不言中"。

4.2 invokedynamic 与 Lambda 表达式

有关 Lambda 和 invokedynamic 的深层次了解可以参考以下优秀文章:

(1条消息) JVM指令之invokestatic,invokespecial,invokeinterface,invokevirtual,invokedynamic_helloworld的专栏-CSDN博客

理解 invokedynamic | DouO's Blog (dourok.info)

Invokedynamic:Java的秘密武器 - 知乎 (zhihu.com)

Java语言的动态性-invokedynamic_程序猿开发日志【学习永无止境】-CSDN博客_invokedynamic

invokedynamic 字节码的提出也是为了解决之前四条 invoke* 指令带来的 "固化的方法分派策略" 问题。它和 MethodHandle 一样,旨在将方法分派的工作从字节码层面移动到代码层面,大体的理念可以概括为:invokedynamic 和一个 Bootstrap Method ( 后文简称为 BSM ) 关联起来,该指令如何动态分派取决于绑定的 BSM 采取什么动作,而 BSM 本身则是用户可以在代码层面进行设计的。

这样,查找方法的决定权从虚拟机交互给了用户,包括一些优秀的语言设计者。比如:对于 Groovy 而言,invokedynamic 指令可以说是实现其 "动态多分派" 特性的基石。而在 Java 中,这条指令应用于 Lambda 表达式。和原书中有所不同,这里主要介绍 Lambda 是如何使用 invokedynamic 进行分派的。下面是一段示例代码:

public static void main(String[] args) {

    Function<Integer, Integer> f;
    Function<Integer, Integer> _x2 = i -> 2 * i;
    Function<Integer, Integer> _x3 = i -> 3 * i;

    f = new Random().nextInt(100) % 2 == 0 ? _x2 : _x3;
}

JDK 8 之后,Java 代码在编译时会为源码内的每一个 Lambda 表达式 "脱糖" ( 如果说 Lambda 表达式是对冗长代码的 "包糖",那么 "脱糖" 故名思意就是逆操作 )—— 将其逻辑编译到当前类的一个私有方法 ( 后文可能会简称这样的方法为脱糖方法 ),命名方式为 lambda$<func>$<x>,其中 func 代表 Lambda 表达式的定义域, x 代表了 Lambda 表达式的编号。使用 javap -c -v -p <.class> 命令对上述代码进行解析,能够得到 _x2_x3 表达式脱糖之后得到的私有方法:

  private static java.lang.Integer lambda$main$1(java.lang.Integer);
    descriptor: (Ljava/lang/Integer;)Ljava/lang/Integer;
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=2, locals=1, args_size=1
         0: iconst_3
         1: aload_0
         2: invokevirtual #8                  // Method java/lang/Integer.intValue:()I
         5: imul
         6: invokestatic  #5                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         9: areturn
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0     i   Ljava/lang/Integer;

  private static java.lang.Integer lambda$main$0(java.lang.Integer);
    descriptor: (Ljava/lang/Integer;)Ljava/lang/Integer;
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=2, locals=1, args_size=1
         0: iconst_2
         1: aload_0
         2: invokevirtual #8                  // Method java/lang/Integer.intValue:()I
         5: imul
         6: invokestatic  #5                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         9: areturn
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0     i   Ljava/lang/Integer;
}

除此之外,Lambda 表达式的调用位置都会被 invokedynamic 指令代替,这些位置也称之为动态调用点。留意第 2,8 行的字节码指令:

     0: aconst_null
     1: astore_1
     2: invokedynamic #2,  0              // InvokeDynamic #0:apply:()Ljava/util/function/Function;
     7: astore_2
     8: invokedynamic #3,  0              // InvokeDynamic #1:apply:()Ljava/util/function/Function;
    13: astore_3

前文提到每条 invokedynamic 指令都会与一个引导方法 ( BSM,Bootstrap Method,简称 BSM ) 关联,在 javap 的分析结果最下方能够看到分别负责引导 _x2_x3 的 BSM 编号 01。Bootstrap Methods 实质上是一个属性表。

BootstrapMethods:
  0: #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;
    Method arguments:
      #38 (Ljava/lang/Object;)Ljava/lang/Object;
      #39 invokestatic forJava/InvokeDynamicTest.lambda$main$0:(Ljava/lang/Integer;)Ljava/lang/Integer;
      #40 (Ljava/lang/Integer;)Ljava/lang/Integer;
  1: #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;
    Method arguments:
      #38 (Ljava/lang/Object;)Ljava/lang/Object;
      #42 invokestatic forJava/InvokeDynamicTest.lambda$main$1:(Ljava/lang/Integer;)Ljava/lang/Integer;
      #40 (Ljava/lang/Integer;)Ljava/lang/Integer;

在运行期,当执行引擎遇到 invokedynamic 指令的时候,BSM 才会被调用。从上述内容中,我们可大致推测出引导 Lambda 表达式的工作是 java.lang.invoke.LambdaMetafactory::metafactory 方法负责的。 该方法需要六个参数,其中前三个参数是固定的,而后三个参数来自于下方的 Method arguments。

首先,它会构造一个匿名类,它装载 Lambda 脱糖后的私有方法。这个类是通过 ASM 编织字节码在内存中生成的,然后直接通过 unsafe 直接加载而不会写到文件里。不过可以通过下面的虚拟机参数让它运行的时候输出到文件:

-Djdk.internal.lambda.dumpProxyClasses=<path>

以其中一个输出结果为例子:

import java.lang.invoke.LambdaForm.Hidden;
import java.util.function.Function;

// $FF: synthetic class
final class InvokeDynamicTest$$Lambda$1 implements Function {
    // 私有化
    private InvokeDynamicTest$$Lambda$1() {}
    @Hidden
    public Object apply(Object var1) {
        // 关联并实际执行原来类里生成的那个脱糖方法,返回运行结果。
        return InvokeDynamicTest.lambda$main$0((Integer)var1);
    }
}

最终,BSM 返回一个 CallSite 对象,它内部封装了指向这个 InvokeDynamicTest$$Lambda$1::apply 方法。如此看来,Java 的 Lambda 表达式绝非是在函数式接口基础上简单地 "加糖"

不妨对整个流程进行非常简短的概括:

  1. 编译器将 Lambda 脱糖编译为私有方法,并且将 Lambda 调用的地方替换为 invokedynamic 链接到 LambdaMetaFactory BSM 的 CallSite。
  2. 在运行期,其 CallSite 被替换成对应脱糖方法的调用。

以至于 Java 为什么选择用 invokedynamic以如此 “大费周章” 的方式实现?一个理由是:在未来,如果 Java 推出了一个针对于 Lambda 表达式脱糖调用的更优解 BSM,那么在底层只需简单地将 invokedynamic 的 "钩子" 替代即可,或者说,我们只尽可能在 BSM 的层面做优化就可以了。这避免了逻辑固化导致后续更新要在底层逻辑 "推倒重来" 的情况。更多信息,可以参考这一篇知乎链接:Java 8的Lambda表达式为什么要基于invokedynamic? - 知乎 (zhihu.com)

4.3 *invokedynamic 指令实战

了解 invokedynamic 字节码指令的最好方式就是使用它。注!这部分的代码内容摘自 CSDN 的博客: JVM invokedynamic调用指令_feather(猎羽)-CSDN博客,笔者在原文基础上对大体流程做了简要分析。

不过目前来说,用户 Java 代码不能在底层构造 invokedynamic 字节码,如果我们自己想要使用 invokedynamic 字节码进行动态分配,需要借助 ASM 工具为其绑定 BSM。这要求开发人员将来如果要专门从事这一方面的研究,至少需要熟练掌握 JVM 字节码指令,以及由 Java 提供的可操纵字节码的 ASM 框架。

在这里,我们的目的仅仅是进一步了解 invokedynamic 的工作机理,因此下文不会详细介绍有关于 ASM 的细节。下面使用一个例子来演示:

准备一个 Horse POJO,它具备 race() 方法。

public class Horse {
    public void race(){
        System.out.println("horse is running...");
    }
}

第二部,准备 Match 类,它内部具有 startRace 方法,预期接收一个对象 o,并调用该对象的 race() 方法。不过目前为止源码层次上没有任何实现。

package forJava.inDyT;

import java.lang.invoke.*;
public class Match {
    // o 在此作为 race() 方法的接收者,它应当具备 race() 方法。
    public static void startRace(Object o){
        // 通过 ASM 在该方法中植入以下字节码:
        // 相当于 Java 代码的 obj.race(); 字节码中,要先使用 aload 指令将其入栈。
        // 注意,invokedynamic 实际上调用的是 BSM,由 BSM 返回的对 race() 的动态调用。
        
        // aload obj
        // invokedynamic race()
    }
}

准备 RunApp 类,它只用于测试程序,调用 Match::startRace() 方法,因此不用过分关注它。

public class RunApp {
    public static void main(String[] args) {
        Match.startRace(new Horse());
    }
}

我们的目的是利用 invokedynamic 指令使得程序动态调用到 Horse::race() 方法。为了让示例更加简单,程序的一部分逻辑被写死了。

由于现在 Match::startRace 方法内部什么都没有,现在的运行 RunApp 什么都不会显示。可以类比一下:Lambda 表达式的 BSM 由 java.lang.invoke.LambdaMetafactory 提供,并在编译期中直接将其和 invokedynamic 指令相关联。显然,我们若要使用 invokedynamic 指令,也得有个自己的 BSM 才行。这一部分内容选择放入到 Match 内部去实现 ( 只要后续的 ASM 能够找到就可以,BSM 放到哪里没有强制要求 )。

为了简单起见,BSM 内部直接从 Horse.class 那里寻找虚方法并作为调用点返回,没有做更多的灵活处理。完整的 Match 类定义如下:

package forJava.inDyT;

import java.lang.invoke.*;
public class Match {
    public static void startRace(Object o){}

    /**===================================================
     * BootStrap
     * @param lookup Lookup实例
     * @param targetMethodName 目标方法名
     * @param methodType 该调用点链接的方法句柄的类型
     * @return 调用点
     *===============================================*/
    public static CallSite bootstrap(MethodHandles.Lookup lookup, 
                                     String targetMethodName, 
                                     MethodType methodType) 
        throws NoSuchMethodException, IllegalAccessException {
        // 1、创建方法句柄
        // 为了简单起见这里将 Horse.class 写死了。
        MethodHandle methodHandle = lookup.findVirtual(Horse.class, targetMethodName, MethodType.methodType(void.class));

        // 2、创建调用点。通过旧方法句柄生成方法句柄的适配器,依次创建调用点。
        return new ConstantCallSite(methodHandle.asType(methodType));
    }
}

常规的用户代码是无法令 javac 构建出 invokedynamic 指令的,这必须引入 ASM 框架工具来完成。整体的程序运行其实分为两步:首先运行核心类 ASMHelper 通过 ASM 插入字节码指令 ( 并绑定 BSM ),然后将此字节码二进制流输出到类文件存放路径当中 ( 比如 IDEA 默认输出路径是 target/classes/.. 文件夹 ) 。随后再启动 RunApp 主程序,令其加载经 ASM 修改后的 Match 二进制代码,最终得到正确结果。

invokedynamic_BSM.png

依照这个思路,核心类 ASMHelper 的实现逻辑如下。当然,如果仅出于了解 invokedynamic 指令的目的,那么无需过多纠结一些具体实现细节,能看懂大体意思即可,因为绝大部分都是 ASM 相关的操作:

package forJava.inDyT;
// !!! 笔者的运行环境是 JDK 1.8。在更高的 JDK 版本下, 需要引入 java.base 模块。
import jdk.internal.org.objectweb.asm.*;
import java.io.IOException;
import java.lang.invoke.CallSite;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Paths;
public class ASMHelper implements Opcodes {
    /**==================================
     * 1、自定义的类访问者:MyClassVisitor
     *  1. visitMethod()获取到方法的访问请求,根据判断可以替换成自定义的MethodVisitor。
     *===========================================*/
    static class MyClassVisitor extends ClassVisitor {
        public MyClassVisitor(int api, ClassVisitor cv) {
            super(api, cv);
        }
        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);

            // 在所有方法中搜索名为 startRace 的方法,并返回它的 MethodVisitor。
            if ("startRace".equals(name)) {
                return new MyMethodVisitor(ASM5, visitor);
            }
            return visitor;
        }
    }
    /**====================================
     * 2、自定义的方法访问者
     *================================*/
    static class MyMethodVisitor extends MethodVisitor {

        // BootStrapMethod-启动方法
        private static final String BOOTSTRAP_CLASS_NAME = Match.class.getName().replace('.', '/');
        private static final String BOOTSTRAP_METHOD_NAME = "bootstrap";
        private static final String BOOTSTRAP_METHOD_DESC = MethodType
                .methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class)
                .toMethodDescriptorString();

        // 目标方法
        private static final String TARGET_METHOD_NAME = "race";
        // 注意,实际上 race 方法没有任何参数,这里的 Ljava/lang/Object; 指代方法的接收者 "this"。
        private static final String TARGET_METHOD_DESC = "(Ljava/lang/Object;)V";

        private MethodVisitor mv;
        public MyMethodVisitor(int api, MethodVisitor mv) {
            super(api, null);
            this.mv = mv;
        }

        @Override
        public void visitCode() {
            mv.visitCode();
            // 1、在startRace()中生成字节码: aload obj
            mv.visitVarInsn(ALOAD, 0); //局部变量指令

            // 2、Match 类 的bootstrap 方法
            Handle handle = new Handle(H_INVOKESTATIC,
                    BOOTSTRAP_CLASS_NAME,
                    BOOTSTRAP_METHOD_NAME,
                    BOOTSTRAP_METHOD_DESC);
            /**=========================================================
             * 3、生成invokedynamic指令
             *   1. 将Match类的bootstrap()生成的调用点,绑定到invokedynamic指令上。
             *   2. 还会将目标方法的方法句柄链接到调用点上。方便后续直接调用。
             *=========================================================*/
            mv.visitInvokeDynamicInsn(TARGET_METHOD_NAME, TARGET_METHOD_DESC, handle);

            //4、return指令
            mv.visitInsn(RETURN);
            mv.visitMaxs(1, 1);
            mv.visitEnd();
        }
    }

    /**==================================================================
     *  运行能将Match的class文件中的字节码进行修改,增加invokedynamic指令
     *===================================================================*/
    public static void main(String[] args) throws IOException {
        // 1、Class的读取者。去加载 Student 的原始字节,并且翻译成访问请求。
        // forJava.inDyT 是包名,这里是类的全限定名。
        ClassReader cr = new ClassReader("forJava.inDyT.Match");

        // 2、Class的写入者。
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

        // 3、Reader和Writer的中间层,对访问操作进行拦截和处理。如果找到目标方法,就替换成自定义的MethodVisitor。再交给Writer
        ClassVisitor cv = new ASMHelper.MyClassVisitor(ASM5, cw);
        cr.accept(cv, ClassReader.SKIP_FRAMES);
        // 4、将Write中的数据转为字节数组,写入到class文件中。
        // IDEA 的编译结果输出在项目的 target/classes/... 对应的目录下。使用 ASM 编译的 Match.class 类文件替代 IDE 默认编译的内容。
        Files.write(Paths.get("C:\\Users\\i\\IdeaProjects\\groovyInJdk11\\target\\classes\\forJava\\inDyT\\Match.class"), cw.toByteArray());

    }
}

在项目文件编译完毕之后,率先执行 ASMHelper 的主程序来覆盖 Match.class。如果将其单独提取出来,并使用 javap -v 指令分析时能够发现下面的代码片段,则说明 ASM 工作成功完成了:

  public static void startRace(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokedynamic #26,  0             // InvokeDynamic #0:race:(Ljava/lang/Object;)V
         6: return
.....
BootstrapMethods:
  0: #23 invokestatic forJava/inDyT/Match.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;

其中,startRace 的 Code 表内的 01 号指令是由 ASM 生成的,并且二进制流末尾标注出了由我们自己实现的 BSM 方法。随后,运行 RunApp 方法,控制台将显示:

horse is running...

显然,我们没有在源码层面的任何一处地方显式调用 obj.race() 方法,这段方法的调用就是利用 invokedynamic 关联 BSM 来实现的。