浅析 JVM invokedynamic 指令和 Java Lambda 语法|得物技术
一、导语
尽管近年来 JDK 的版本发布愈发敏捷,当前最新版本号已经 20+,但是日常使用中,JDK8 还是占据了统治地位。
++你发任你发,我用 Java8:【Jetbrains】2023 开发者生态系统现状 - https://www.jetbrains.com/zh-cn/lp/devecosystem-2023/java/++
JDK8 如此旺盛的生命力,与其优异的兼容性、稳定性和足够日常开发使用的语言特性有极大的关系,这其中最引人瞩目的语言特性莫过于 Lambda 表达式。
Lambda 表达式语言特性引入 Java 语言后,赋予了 Java 语言更便捷的函数式编程魔力(相对匿名内部类),同时也让其更简洁,毕竟 Java 代码写起来啰嗦这点一直被开发者们广泛诟病。
本文将从 JVM 和 Java 两个层面着手,和大家一起深入解析 Lambda 表达式。
二、Java 和 JVM 的关系
JVM 是 HLLVM(高级语言虚拟机),其参考物理计算机体系架构,设计、实现了一套特定领域虚拟指令集,即:字节码指令。利用上述虚拟指令集作为中间层,将上层高级语言和底层体系架构解耦以规避繁琐、复杂的平台兼容性问题,以实现【一次编译,处处运行】。
Java 是基于 JVM 提供的虚拟指令集,设计、实现的一种供开发者使用的高级语言。通过配套的编译器和标准库,将文本格式的 Java 代码编译成符合 JVM 指令集规范的二进制文件,交付到 JVM 执行。
Java 是一种运行在 JVM 平台上的高级语言,但是 JVM 平台绝不是只能运行 Java 语言。任何人都可以设计自己的语言语法,只要能按 JVM 规范编译成合法的 JVM 字节码,即可在 JVM 上运行(用 Java 命令)。
++计算机科学领域的任何问题,都可以通过增加一个中间层来解决。++
没有无源之水,Java 语言层面的特性,除非是纯语法糖,不然一定离不开特定 JVM 特性的支撑。Lambda 是 Java8 语言特性,那支撑它的便是 JVM invokedynamic 指令。
三、JVM 指令:invokedynamic
在 Java7 之前,JVM 提供了如下 4 种【方法调用】指令:
上述 4 种字节码指令各自有不同的使用场景,但是有一个共同的特点:目标方法一定需要在【编译期】确定。如下图,编译后 4 种指令的参数都指定了目标方法所在的类和签名以供运行时链接、动态分派。
这个特点一方面保证了 JVM 语言类型安全,另一方面也限制了 JVM 平台对动态类型高级语言的支持。比如想让 JavaScript、Python 等动态语言代码编译成 JVM 字节码运行在 JVM 平台上的开销会比较大,性能也会比较差。
为了解决上述问题, Java7 引入了一条新的虚拟机指令:invokedynamic。这是自 JVM 1.0 以来第一次引入新的虚拟机指令,invokedynamic 与其他 invoke*指令不同的是它允许由应用级的代码来决定方法解析(链接、分派)。
所谓的【应用级的代码来决定方法解析】需要对照之前的 invoke 指令来理解。之前的 4 种 invoke 指令,在编译期就必须要明确目标方法并 hardcode 到字节码中,JVM 在运行时直接解析、链接、动态分派硬编码指定的目标方法。而 invokedynamic 指令通过回调机制来获取需要调用的目标方法。即先调用业务自定义回调方法做方法决策(解析、链接),再调用其返回的目标方法。笔者称之为**【两阶段调用】**。
伪代码对比如下:
MethdoHandle 为示意,后文有详述。
伪字节码
++invokevirtual 指令直接调用目标方法,invokedynamic 直接调用回调方法,再调用回调方法返回的方法句柄。++
传统的 invoke*指令直接调用字节码中指定的目标方法,如 Son.testMethod1,invokedynamic 指令在调用时,先调用字节码中指定的回调方法,如 Son.dynamicMethodCallback,然后再调用回调方法(hook)返回的方法引用。
而上述 dynamicMethodCallback 即为【应用级的代码或者我们常说的业务代码】,可以在不影响性能的前提下,灵活的干预 JVM 方法解析、链接的过程。
总结来说,所谓应用级的代码其实也是一个方法,在这里这个方法被称为引导方法(Bootstrap Method),简称 BSM。invokedynamic 执行时,BSM 先被调用并返回一个 CallSite(调用点)对象,这个对象就和 invokedynamic 链接在一起。以后再执行这条 invokedynamic 指令都不会创建新的 CallSite 对象。CallSite 就是一个 MethodHandle(方法句柄)的 holder,方法句柄指向一个调用点真正执行的方法。
一阶段:调用引导方法确定并缓存 CallSite(MethodHandle)
二阶段:调用 CallSite(MethodHandle)
字节码指令比较 low level,除字节码业务插桩场景外,字节码指令序列的构造、编排一般都由【高级语言编译器】来根据语言语法规则自动完成,如 javac。
某种意义上有点类似 Java【动态代理】机制,都是通过调用横切来动态桥接、灵活决策目标方法。
四、方法句柄:MethodHandle
前面我们知道 invokedynamic 指令支持通过业务层面自定义的 BSM 来灵活的决策被调用的目标方法,也就是上述的【一阶段】。BSM 方法的返回值就是【二阶段】调用的方法。
但是和 C、Python 等语言不同,Java 中方法/函数不是一等公民,也就是在 Java 中无法将【方法变量】作为方法返回值。
为了解决这个问题,Java 标准库提供了一个新的类型 MethodHandle,用于实现类似 C 语言中的方法指针、JavaScript/Python 中方法变量的能力。该 API 和反射 API 呈现的能力相似,但是性能更好。
上述为 MethodHandle API 的基本使用,该课题展开又是一篇长文。总之,我们可以用 MethodHandle 来作为【方法变量】,变相的将【Java 方法】提升为【一等公民】,从而可以在 BSM 中用 Java 代码实现动态编排、决策,返回合适的方法指针。这也是上述 invokedynamic+BSM 机制能够成立的一个基础。
详见:++秒懂 Java 之方法句柄(MethodHandle) (https://blog.csdn.net/ShuSheng0007/article/details/107066856)++
段落引用上述【一阶段】调用的本质就是得到一个特定的 MethodHandle(方法指针/方法引用),【二阶段】调用就是调用这个 MethodHandle。
五、Lambda 表达式简介
Java 的 Lambda 表达式,是传统的【匿名内部类】特性在特定场景下的平替特性。所谓的特定场景,即我们熟知的 FunctionalInterface。
当【匿名内部类】匿名实现的是一个 FunctionalInterface 时,可以用 Lambda 表达式平替。
示例如下:
函数式接口(Functional Interface)就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。
Java 不会强制要求你使用 @FunctionalInterface 注解来标记你的接口是函数式接口,然而,作为 API 作者,你可能倾向使用 @FunctionalInterface 指明特定的接口为函数式接口,这只是一个设计上的考虑,可以让用户很明显的知道一个接口是函数式接口。
Java Lambda 表达式在语法层面有两种形式:行内代码块、方法引用。
但是在编译产物中,行内 Lambda 最终会被提取到独立的静态方法中。也就是说,在字节码层面只有【方法引用】一种 Lambda 形式。
如上图反编译结果,两个行内 Lambda 中的代码在编译后被提取到两个自动生成的方法 lambda0、lambda1,后续 Lambda 表达式的处理流程都可以收敛,无需区分对待。
六、Lambda 表达式实现
Lambda 表达式具体的实现涉及类文件结构、字节码指令结构、标准库等多个方面的内容,千头万绪。也想不出来什么通俗易懂的叙述角度,只能是枯燥的对照着字节码分析了。
如上图,mian 方法中声明了 3 个 Lambda 表达式,反编译字节码可以看到字节码指令流如下:
3 个 lambda 表达式对应 3 条 invokedynamic 指令:
第一个 lambda 表达式比较简单且典型,后续我们以其为抓手展开分析。
invokedynamic 指令参数
invokedynamic 指令参数结构如下:
++jvms-6.5.invokedynamic++ (https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.invokedynamic)
invokedynamic 指令需要指定其期待 BSM 返回的方法特征(出入参类型)和 BSM 方法引用。该参数以 CONSTANT_InvokeDynamic_info 结构存放在类文件的常量池结构中,invokedynamic 用两个 byte 宽度的常量池索引号指定。
对照字节码我们可知,Lambda1 相关的 invokedynamic 指定的 CONSTANT_InvokeDynamic_info 序号为 3,得到如下内容:
期望的方法名称和描述符
该 invokedynamic 指令期望 BSM0 方法返回一个如下特征的方法引用:
没有入参,返回值类型为 IntUnaryOperator 的 MethodHandle。
为什么是返回 IntUnaryOperator 类型呢?因为 IntStream 的 map 方法需要的参数是 IntUnaryOperator 类型。
换句话说,该 invokedynamic 指令希望相应的 BSM 返回一个 IntUnaryOperator 的工厂方法句柄,然后 invokedynamic 指令再调用这个方法句柄,创建出一个 map 方法需要的 IntUnaryOperator 类型的参数。
BSM 方法序号
BSM 方法序号指定了当前 invokedynamic 指令使用的 BSM 方法在 BSM 方法表中的索引。
通俗来说,类文件中有一个数组,数组名称叫 BootstrapMethods。其结构如下:
该 invokedynamic 指令指定的 BSM 为 BSM 数组中的第一个 BSM。
BSM 方法
BSM 方法参数
该 BSM 数据结构指定了 3 个编译期固定的、静态的 BSM 方法参数:
第一、第三个参数指定了预期的函数式接口(FunctionInterface)的特征:入参为 int、出参为 int。即上述 IntUnaryOperator。
第二个参数是一个静态方法引用。如上述,Lambda 表达式在编译时会被提取到一个自动生成的方法中。
至此,invokedynamic 指令具有的发起【一阶段调用】的上下文如下:
具体的一阶段调用的 BSM 方法:java.lang.invoke.LambdaMetafactory#metafactory
IntStream.map 方法需要的参数类型:IntUnaryOperator
编译器(javac)编译产生的包含 Lambda 表达式代码内容的静态方法:lambda0(I)I
接下来就是调用 java.lang.invoke.LambdaMetafactory#metafactory 方法,传递上述必要的上下文参数,接受 metafactory 方法返回的 IntUnaryOperator applyAsInt()类型的 MethodHandle 并调用该 MethodHandle,继而得到 IntStream.map 方法需要的参数:IntUnaryOperator。
LambdaMetafactory# metafactory
如上述,invokedynamic 指令调用上述 metafactory 方法,对照字节码信息,可以得到如下具体参数表格:
LambdaMetafactory 根据上述上下文,使用 ASM 库,动态生成了一个如下所示的 IntUnaryOperator 适配类,用于桥接 Lambda 表达式代码块到 IntUnaryOperator 类型。
添加-Djdk.internal.lambda.dumpProxyClasses=.启动参数,JDK 会将生成的适配函数式接口的类源码输出到工作目录中。
构造 CallSite
java.lang.invoke.InnerClassLambdaMetafactory#buildCallSite
生成 FunctionalInterface 适配类后,基于适配类创建 MethodHandle。该 MethodHandle 体现的代码逻辑类似如下 Java 代码:
至此,invokedynamic【一阶段】调用已经完成,invokedynamic 指令获取到了由 LambdaMetafactory#metafactory 作为 BSM 动态决策、动态生成的 IntUnaryOperator 适配类的【工厂方法】(以 CallSite 包装的 MethodHandle 的形式)。
二阶段调用
【一阶段调用】已经完成,返回了动态决策产生的 CallSite 对象,getTarget 方法可以获取上述的 IntUnaryOperator 适配类的【工厂方法】。
至此,invokedynamic 指令可以通过如下伪代码,创建 IntStream.map 方法需要的 IntUnaryOperator 实例。
Lambda1 的整个运行时解析、链接流程完成。
七、Lambda 表达式性能
经过上述分析我们可以知道,Lambda1 这种无状态的、没有捕获外部变量(闭包)的 Lambda 表达式的开销是很小的,只会在第一次调用时动态生成桥接的适配类,实例化后就通过 ConstantCallSite 缓存。后续所有的调用都不会再重新生成适配类、实例化适配类。
但是,Lambda2 则不同,因为 Lambda 捕获、依赖了(闭包)外部变量 num,那么这个表达式就是有状态的。虽然同样只是会在第一次调用时动态生成桥接的适配类,但是每一次调用都会使用 num 变量重新实例化一个新的适配类实例。这种场景下,其在性能和形式上就已经和传统的【匿名内部类】没有太大差别了。
Lambda3 本质上和 Lambda1 一样,只不过不需要 Java 编译器在编译时将 Lambda 代码语句抽取成独立的方法。
八、Lambda 表达式和 final 变量
当 Lambda 表达式闭包捕获的局部变量 num 在方法内可变时,编译器会提示编译错误。这不是 JVM 的限制,而是 Java 语言层面的限制。笔者认为,这种限制没有技术上的原因,而是 Java 语言设计者刻意的借助编译器在阻止你犯错。
假设没有这个限制,那么 Lambda 表达式就变成了重构不友好的【位置相关】的代码块。
换句话说,下面两种代码执行结果是不一样的:
Lambda 捕获的 num 的值为 5;
Lambda 捕获的 num 的值为 3;
如果没有类似的编译约束,当我们有心或无意的在复杂的业务逻辑中进行了类似的代码调整时,极易出错且难以排查。
笔者个人见解,欢迎指正。
九、总结
提笔的时候立意高远,想着要尽可能通俗详尽的写清楚所有涉及的技术点,但是越写越觉得事情不简单,最后只能是把博客标题从【深入剖析】修改为【浅析】。这块内容牵涉的面太广,笔者没有能力也没有精力介绍到事无巨细、面面俱到,只能为大家抛砖引玉,大家可以配合后文【参考资料】多梳理、多实验,同时在评论区批评指正。
invokedynamic 指令不是业务开发者使用的。invokedynamic 指令可以用来实现 Lambda 语法,但是它不是只能用来实现 Lambda 语法。这个指令对于 JVM 语言开发者比如 Kotlin、Groovy、JRuby、Jython 等会比较重要。
没有捕获外部变量(闭包)的 Lambda 表达式性能和直接调用没有差别。
捕获外部变量(闭包)的 Lambda 表达式性能理论上和【匿名内部类】范式一样,每次调用都会创建一个对象(最坏情况)。
本文使用的反编译工具为:jclasslib Bytecode Viewer
(https://plugins.jetbrains.com/plugin/9248-jclasslib-bytecode-viewer)
十、附录
自动生成的 Lambda2 适配类
自动生成的 Lambda3 适配类
参考:
Oracle-Java 虚拟机规范(JDK8)--https://docs.oracle.com/javase/specs/jvms/se8/html/
Oracle-Java 语言规范(JDK8)-https://docs.oracle.com/javase/specs/jls/se8/html/index.html
JVM 系列之:JVM 是怎么实现 invokedynamic 的? | HeapDump 性能社区-https://heapdump.cn/article/3573623
Java 虚拟机:JVM 是怎么实现 invokedynamic 的?(上)-https://cloud.tencent.com/developer/article/1787369
Java 虚拟机:JVM 是怎么实现 invokedynamic 的?(下)-https://cloud.tencent.com/developer/article/1787371
【stackoverflow】What is a bootstrap method?-https://stackoverflow.com/questions/30733557/what-is-a-bootstrap-method
Java 中普通 lambda 表达式和方法引用本质上有什么区别?-https://www.zhihu.com/question/51491241/answer/126232275
理解 invokedynamic-https://juejin.cn/post/6844903503236710414
https://www.cnblogs.com/wade-luffy/p/6058087.html
09 | JVM 是怎么实现 invokedynamic 的?(下)-深入拆解 Java 虚拟机-极客时间-https://time.geekbang.org/column/article/12574
*文/ 羊羽
本文属得物技术原创,更多精彩文章请看:得物技术
未经得物技术许可严禁转载,否则依法追究法律责任!
版权声明: 本文为 InfoQ 作者【得物技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/94c7db97ac7a8d555c2335518】。文章转载请联系作者。
评论