通過字節碼分析JDK8中Lambda表達式編譯及執行機制【面試+工作】

通過字節碼分析JDK8中Lambda表達式編譯及執行機制【面試+工作】

方法調用的字節碼指令

在Class文件中,方法調用即是對常量池(ConstantPool)屬性表中的一個符號引用,在類加載的解析期或者運行時才能確定直接引用。

  1. invokestatic 主要用於調用static關鍵字標記的靜態方法
  2. invokespecial 主要用於調用私有方法,構造器,父類方法。
  3. invokevirtual 虛方法,不確定調用那一個實現類,比如Java中的重寫的方法調用。
  4. invokeinterface 接口方法,運行時才能確定實現接口的對象,也就是運行時確定方法的直接引用,而不是解析期間。
  5. invokedynamic 這個操作碼的執行方法會關聯到一個動態調用點對象(Call Site object),這個call site 對象會指向一個具體的bootstrap 方法(方法的二進制字節流信息在BootstrapMethods屬性表中)的執行,invokedynamic指令的調用會有一個獨特的調用鏈,不像其他四個指令會直接調用方法,在實際的運行過程也相對前四個更加複雜。結合後面的例子,應該會比較直觀的理解這個指令。

關於方法調用的其他詳細的解釋可以參考官方文檔《The Java® Virtual Machine Specification Java8 Edition》-2.11.8 Method Invocation and Return Instructions。

lambda表達式運行機制

在看字節碼細節之前,先來了解一下lambda表達式如何脫糖(desugar)。lambda的語法糖在編譯後的字節流Class文件中,會通過invokedynamic指令指向一個bootstrap方法(下文中部分會稱作“引導方法”),這個方法就是java.lang.invoke.LambdaMetafactory中的一個靜態方法。通過debug的方式,就可以看到該方法的執行,此方法源碼如下:

在運行時期,虛擬機會通過調用這個方法來返回一個CallSite(調用點)對象。簡述一下方法的執行過程,首先,初始化一個InnerClassLambdaMetafactory對象,這個對象的buildCallSite方法會將Lambda表達式先轉化成一個內部類,這個內部類是MethodHandles.Lookup caller的一個內部類,也即包含此Lambda表達式的類的內部類。這個內部類是通過字節碼生成技術(jdk.internal.org.objectweb.asm)生成,再通過UNSAFE類加載到JVM。然後再返回綁定此內部類的CallSite對象,這個過程的源碼也可以看一下:

這個過程將生成一個代表lambda表達式信息的內部類(也就是方法第一行的innerClass,這個類是一個 functional 類型接口的實現類),這個內部類的Class字節流是通過jdk asm 的ClassWriter,MethodVisitor,生成,然後再通過調用Constructor.newInstance方法生成這個內部類的對象,並將這個內部類對象綁定給一個MethodHandle對象,然後這個MethodHandle對象傳給CallSite對象(通過CallSite的構造函數賦值)。所以這樣就完成了一個將lambda表達式轉化成一個內部類對象,然後將內部類通過MethodHandle綁定到一個CallSite對象。CallSite對象就相當於lambda表達式的一個勾子。而invokedynamic指令就鏈接到這個CallSite對象來實現運行時綁定,也即invokedynamic指令在調用時,會通過這個勾子找到lambda所代表的一個functional接口對象(也即MethodHandle對象)。所以lambda的脫糖也就是在運行期通過bootstrap method的字節碼信息,轉化成一個MethodHandle的過程。 通過打印consumer對象的className(greeter.getClass().getName())可以得到結果是eight.Functionnal$$Lambda$1/659748578前面字符是Lambda表達式的ClassName,後面的659748578是剛纔所述內部類的hashcode值。 下面通過具體的字節碼指令詳細分析一下lambda的脫糖機制,並且看一下invokedynamic指令是怎麼給lambda在JVM中的實現帶來可能。如果前面所述過程還有不清晰,還可以參考下Oracle工程師在設計java8 Lambda表達式時候的一些思考:Translation of Lambda Expressions

lambda表達式字節碼指令示例分析

先看一個簡單的示例,示例使用了java.util.function包下面的Consumer。 示例代碼:(下面的Person對象只有一個String類型屬性:name,以及一個有參構造方法)

用verbose命令看一下方法主體的字節碼信息,這裏暫時省略常量池信息,後面會在符號引用到常量池信息的地方具體展示。

invokedynamic指令特性

可以看到第一條指令就是代表了lambda表達式的實現指令,invokedynamic指令,這個指令是JSR-292開始應用的規範,而鑑於兼容和擴展的考慮(可以參考Oracle工程師對於使用invokedynamic指令的原因),JSR-337通過這個指令來實現了lambda表達式。也就是說,只要有一個lambda表達式,就會對應一個invokedynamic指令。 先看一下第一行字節碼指令信息

0: invokedynamic #2, 0

  1. 0: 代表了在方法中這條字節碼指令操作碼(Opcode)的偏移索引。
  2. invokedynamic就是該條指令的操作碼助記符。
  3. #2, 0 是指令的操作數(Operand),這裏的#2表示操作數是一個對於Class常量池信息的一個符號引用。逗號後面的0 是invokedynamic指令的默認值參數,到目前的JSR-337規範版本一直而且只能等於0。所以直接看一下常量池中#2的信息。 invokedynamic在常量是有專屬的描述結構的(不像其他方法調用指令,關聯的是CONSTANT_MethodType_info結構)。 invokedynamic 在常量池中關聯一個CONSTANT_InvokeDynamic_info結構,這個結構可以明確invokedynamic指令的一個引導方法(bootstrap method),以及動態的調用方法名和返回信息。

常量池索引位置#2的信息如下:

結合CONSTANT_InvokeDynamic_info的結構信息來看一下這個常量池表項包含的信息。 CONSTANT_InvokeDynamic_info結構如下:

簡單解釋下這個CONSTANT_InvokeDynamic_info的結構:

  • tag: 佔用一個字節(u1)的tag,也即InvokeDynamic的一個標記值,其會轉化成一個字節的tag值。可以看一下jvm spec中,常量池的tag值轉化表(這裏tag值對應=18):
  • bootstrap_method_attr_index:指向bootstrap_methods的一個有效索引值,其結構在屬性表的 bootstrap method 結構中,也描述在Class文件的二進制字節流信息裏。下面是對應索引 0 的bootstrap method 屬性表的內容:

這段字節碼信息展示了,引導方法就是LambdaMetafactory.metafactory方法。對照着前面LambdaMetafactory.metafactory的源碼一起閱讀。通過debug先看一下這個方法在運行時的參數值:

這個方法的前三個參數都是由JVM自動鏈接Call Site生成。方法最後返回一個CallSite對象,對應invokedynamic指令的操作數。 - name_and_type_index:代表常量池表信息的一個有效索引值,其指向的常量池屬性表結構一定是一個CONSTANT_NameAndType_info屬性,代表了方法名稱和方法描述符信息。再沿着 #44 索引看一下常量池相關項的描述內容:

通過以上幾項,可以很清楚得到invokedynamic的方法描述信息。

其餘字節碼指令解析

綜上,已經介紹了lombda表達式在字節碼上的實現方式。其他指令,如果對字節碼指令感興趣可以繼續閱讀,已經瞭解的可以略過,本小節和lambda本身沒有太大關聯。

  1. 第二條指令:5: astore_1 指令起始偏移位置是5,主要取決於前面一個指令(invokedynamic)有兩個操作數,每個操作數佔兩個字節(u2)空間,所以第二條指令就是從字節偏移位置5開始(後續的偏移地址將不再解釋)。此指令執行後,當前方法的棧幀結構如下(注:此圖沒有畫出當前棧幀的動態鏈接以及返回地址的數據結構,圖中:左側局部變量表,右側操作數棧):

這裏爲了畫圖方便,所以按照局部變量表和操作數棧的實際分配空間先畫出了幾個格子。因爲字節碼信息中已經告知了[stack=4, locals=2, args_size=1]。也就是局部變量表的實際運行時空間最大佔用兩個Slot(一個Slot一個字節,long,double類型變量需佔用兩個slot),操作數棧是4個slot,參數佔一個slot。這裏的args是main方法的String[] args參數。因爲是個static方法,所以也沒有this變量的aload_0 指令。 2. 第三條: 6: aload_1將greeter 彈出局部變量表,壓入操作數棧。

3. 第四條:7: new #3初始化person對象指令,這裏並不等同於new關鍵字,new操作碼只是找到常量池的符號引用,執行到此行命令時,運行時堆區會創建一個有默認值的對象,如果是Object類型,那麼默認值是null,然後將這個對於默認值的引用地址壓入到操作數棧。其中#3 操作數指向的常量池Class屬性表的一個引用,可以看到這個常量池項爲:#3 = Class #45 // eight/Person 。此時的運行時棧幀結構如下:

4. 第五條:10: dup 複製操作數棧棧頂的值,並且將該值入操作數棧棧頂。dup指令是一種對於初始化過程的編譯期優化。因前面的new操作碼並不會真正的創建對象,而是push一個引用到操作數棧,所以dup之後,這個棧頂的複製引用就可以用來給調用初始化方法(構造函數)的invokespecial提供操作數時消耗掉,同時原有的引用值就可以給其他比如對象引用的操作碼使用。此時棧幀結構如下圖:

5. 第六條:11: ldc #4 將運行時常量池的值入操作數棧,這裏的值是Lambda字符串。#4 在常量池屬性表中結構信息如下:

此時運行時棧幀結構如下:

6. 第七條:13: invokespecial #5 初始化Person對象的指令(#5指向了常量池Person的初始化方法eight/Person.””:(Ljava/lang/String;)V),也即調用Person構造函數的指令。此時”Lambda”常量池的引用以及 dup 複製的person引用地址出操作數棧。這條指令執行之後,纔在堆中真正創建了一個Person對象。此時棧幀結構如下:

7.第八條:16: invokeinterface #6, 2 調用了Consumer的accept接口方法{greeter.accept(person)}。#6 逗號後面的參數2 是invokeinterface指令的參數,含義是接口方法的參數的個數加1,因爲accpet方法只有一個參數,所以這裏是1+1=2。接着再看一下常量池項 #6屬性表信息:

以上可以看出Consumer接口的泛型被擦除(編譯期間進行,所以字節碼信息中並不會包含泛型信息),所以這裏並不知道實際的參數操作數類型。但是這裏可以得到實際對象的引用值,這裏accept方法執行,greeter和person引用出棧,如下圖:

8. 第九條:21: return 方法返回,因爲是void方法,所以就是opcode就是return。此時操作數棧和局部變量表都是空,方法返回。最後再畫上一筆:

結語

本文只是通過Consumer接口分析lambda表達式的字節碼指令,以及運行時的脫糖過程。也是把操作碼忘得差不多了,也順便再回顧一下。 從字節碼看lambda可以追溯到源頭,所以也就能理解運行時的內存模型。 lambda表達式對應一個incokedynamic 指令,通過指令在常量池的符號引用,可以得到BootstrapMethods 屬性表對應的引導方法。在運行時,JVM會通過調用這個引導方法生成一個含有MethodHandle(CallSite的target屬性)對象的CallSite作爲一個Lambda的回調點。Lambda的表達式信息在JVM中通過字節碼生成技術轉換成一個內部類,這個內部類被綁定到MethodHandle對象中。每次執行lambda的時候,都會找到表達式對應的回調點CallSite執行。一個CallSite可以被多次執行(在多次調用的時候)。如下面這種情況,只會有一個invokedynamic指令,在comparator調用comparator.comparecomparator.reversed方法時,都會通過CallSite找到其內部的MethodHandle,並通過MethodHandle調用Lambda的內部表示形式LambdaForm

Lambda不僅用起來很方便,性能表現在多數情況也比匿名內部類好,性能方面可以參考一下Oracle的Sergey Kuksenko發佈的 Lambda 性能報告。由上文可知,雖然在運行時需要轉化Lambda Form(見MethodHandle的form屬性生成過程),並且生成CallSite,但是隨着調用點被頻繁調用,通過JIT編譯優化等,性能會有明顯提升。並且,運行時脫糖也增強了編譯期的靈活性(其實在看字節碼之前,一直以爲Lambda可能是在編譯期脫糖成一個匿名內部類的Class,而不是通過提供一個boortrap方法,在運行時鏈接到調用點)。運行時生成調用點的方式實際的內存使用率在多數情況也是低於匿名內部類(java8 之前版本的寫法)的方式。所以,在能使用lambda表達式的地方,我們儘量結合實際的性能測試情況,寫簡潔的表達式,儘量減少Lambda表達式內部捕獲變量(因爲這樣會創建額外的變量對象),如果需要在表達式內部捕獲變量,可以考慮是否可以將變量寫成類的成員變量,也即儘量少給Lambda傳多餘的參數。希望本文能給Lambda的使用者一些參考。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章