本章内容概括自《深入了解 JVM 虚拟机》 第八章。大致可以分为三个部分:
- 了解 Java 是基于栈的字节码引擎,栈帧的概念。
- 了解 Java 是 "静态多分派,动态单分派" ,这影响了 Java 在发生重载,重写时如何分派方法。
- 了解 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 的调用现场。
要是再生动点,可以将它类比 "厨子做菜" 的过程 ( 一道料理,一个砧板,一个菜筐,一个餐盘,很讲究 ):
在一个时间片内,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 的分派是一种默认行为,我们在编程时也不在乎它们是不是 "虚" 的。分派本身可以根据静态/动态分派,单/多分派而划分出四个区间出来:
其中,蓝色区域表示 Java 语言占据的区间。在其它资料当中,静态分派的说法是 Method Overload Resolution,即静态分派的概念有时会被归纳到解析的范畴。在本文中,遵循《深入了解 JVM 虚拟机》 的传统,放到分派那里介绍。
3.1 静态类型和动态类型
这里还有一些概念要提及。假设 Father
是 Son
的父类,下面有一个上转型对象:
// 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 称之 "静态多分派" 的原因在于,编译器基于两个宗量进行检查:
- 确定传入参数的静态类型。
- 方法接收者是否有处理对应静态类型的方法。否则,则检查是否有可接收此静态类型的父类型的方法。否则,则向接收者的上一级类型检查是否有合适的方法,依此类推。
依照这 两个宗量 ,编译器可在编译过程中,仅凭借静态类型在所有重载方法中尽可能选择最贴切的那个方法,因此又被称之为 "静态多分派"。比如说,假设将 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)
方法呢?有两种思路:
- 若
baseDynamicDispatcher
的静态类型不变,则将receive(String)
方法迁移到父类BaseDynamicDispatcher
,思路参考前文的静态多分派。 - 将
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
同样可以解决。两者之间的主要区别是:
Reflection
模拟 Java 代码的调用,MethodHandle
模拟字节码层面的调用。Reflection
能够提取出与方法相关的各种信息 ( 重量级 ),而MethodHandle
只注重于调用方法本身 ( 轻量级 )。MethodHandle
理论上能够享受到 JVM 对方法指令调用进行的优化,但是反射不行。Reflection
可以无视权限修饰符,导致安全性可能更差,比如越界访问,强制调用等。
一言以蔽之,Reflection
好用,但稍慢。MethodHandle
难用 ( 指获取不了与方法有关的更多信息 ),但是跑起来更快。抛开 "站在 Java 角度" 的立场,MethodHandle
被设计为可以服务于所有 JVM 的语言,Java 只是其中的一部分。
同时,这样的代码风格也可以称之为 "能力式" 设计:不管 obj
是 PrintStream
还是由用户设计出来的其它类型,只要程序能够找到名为 void println(String)
的方法,methodHandle
总是能够正确地运行,并且没有事先定义任何接口去约束。而 obj
也可以称作是 "鸭子类型" —— 显而易见,这种设计风格非常贴合动态语言的要求 —— "一切尽在不言中"。
4.2 invokedynamic 与 Lambda 表达式
有关 Lambda 和 invokedynamic 的深层次了解可以参考以下优秀文章:
理解 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 编号 0
和 1
。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 表达式绝非是在函数式接口基础上简单地 "加糖" 。
不妨对整个流程进行非常简短的概括:
- 编译器将 Lambda 脱糖编译为私有方法,并且将 Lambda 调用的地方替换为
invokedynamic
链接到LambdaMetaFactory
BSM 的 CallSite。 - 在运行期,其 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
二进制代码,最终得到正确结果。
依照这个思路,核心类 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 表内的 0
和 1
号指令是由 ASM 生成的,并且二进制流末尾标注出了由我们自己实现的 BSM 方法。随后,运行 RunApp
方法,控制台将显示:
horse is running...
显然,我们没有在源码层面的任何一处地方显式调用 obj.race()
方法,这段方法的调用就是利用 invokedynamic
关联 BSM 来实现的。