淺析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中的代碼在編譯后被提取到兩個自動生成的方法lambda$main$0、lambda$main$1,后續Lambda表達式的處理流程都可以收斂,無需區分對待。
六、Lambda表達式實現
Lambda表達式具體的實現涉及類文件結構、字節碼指令結構、標準庫等多個方面的內容,千頭萬緒。也想不出來什么通俗易懂的敘述角度,只能是枯燥的對照著字節碼分析了。
圖片
如上圖,mian方法中聲明了3個Lambda表達式,反編譯字節碼可以看到字節碼指令流如下:
圖片
0 iconst_3
1 istore_1
2 iconst_3
3 newarray 10 (int)
5 dup
6 iconst_0
7 iconst_1
8 iastore
9 dup
10 iconst_1
11 iconst_2
12 iastore
13 dup
14 iconst_2
15 iconst_3
16 iastore
17 invokestatic #2 <java/util/stream/IntStream.of : ([I)Ljava/util/stream/IntStream;>
20 invokedynamic #3 <applyAsInt, BootstrapMethods #0>
25 invokeinterface #4 <java/util/stream/IntStream.map : (Ljava/util/function/IntUnaryOperator;)Ljava/util/stream/IntStream;> count 2
30 iload_1
31 invokedynamic #5 <applyAsInt, BootstrapMethods #1>
36 invokeinterface #4 <java/util/stream/IntStream.map : (Ljava/util/function/IntUnaryOperator;)Ljava/util/stream/IntStream;> count 2
41 invokedynamic #6 <applyAsInt, BootstrapMethods #2>
46 invokeinterface #4 <java/util/stream/IntStream.map : (Ljava/util/function/IntUnaryOperator;)Ljava/util/stream/IntStream;> count 2
51 invokeinterface #7 <java/util/stream/IntStream.sum : ()I> count 1
56 istore_2
57 return
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寬度的常量池索引號指定。
CONSTANT_InvokeDynamic_info {
u1 tag;
u2 bootstrap_method_attr_index;
u2 name_and_type_index;
}
圖片
對照字節碼我們可知,Lambda1相關的invokedynamic指定的CONSTANT_InvokeDynamic_info序號為3,得到如下內容:
圖片
圖片
期望的方法名稱和描述符
該invokedynamic指令期望BSM0方法返回一個如下特征的方法引用:
IntUnaryOperator anyName();
沒有入參,返回值類型為IntUnaryOperator的MethodHandle。
為什么是返回IntUnaryOperator類型呢?因為IntStream的map方法需要的參數是IntUnaryOperator類型。
圖片
換句話說,該invokedynamic指令希望相應的BSM返回一個IntUnaryOperator的工廠方法句柄,然后invokedynamic指令再調用這個方法句柄,創建出一個map方法需要的IntUnaryOperator類型的參數。
BSM方法序號
BSM方法序號指定了當前invokedynamic指令使用的BSM方法在BSM方法表中的索引。
通俗來說,類文件中有一個數組,數組名稱叫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];
}
圖片
圖片
圖片
該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表達式代碼內容的靜態方法:lambda$main$0(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實例。
IntUnaryOperator intUnaryOperator = (IntUnaryOperator)callSite.getTarget().invoke()
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適配類
// $FF: synthetic class
final class LambdaTest$$Lambda$2 implements IntUnaryOperator {
private final int arg$1;
private LambdaTest$$Lambda$2(int var1) {
this.arg$1 = var1;
}
private static IntUnaryOperator get$Lambda(int var0) {
return new LambdaTest$$Lambda$2(var0);
}
@Hidden
public int applyAsInt(int var1) {
return LambdaTest.lambda$main$1(this.arg$1, var1);
}
}
自動生成的Lambda3適配類
// $FF: synthetic class
final class LambdaTest$$Lambda$3 implements IntUnaryOperator {
private LambdaTest$$Lambda$3() {
}
@Hidden
public int applyAsInt(int var1) {
return LambdaTest.add(var1);
}
}
參考:
- 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