1. 程式人生 > >通過位元組碼分析JDK8中Lambda表示式編譯及執行機制

通過位元組碼分析JDK8中Lambda表示式編譯及執行機制

關於Lambda位元組碼相關的文章,很早之前就想寫了,通過Java8的lambda表示式的運用以及rxJava響應式程式設計框架,使程式碼更加簡潔易維護,呼叫方式更加便捷。本文將介紹JVM中的方法呼叫相關的位元組碼指令,重點解析JDK7(JSR-292)之後新增的invokedynamic指令給lambda表示式的動態呼叫特性提供的實現機制,最後再探討一下lambda效能方面的話題。

方法呼叫的位元組碼指令

在介紹invokedynamic指令前,先回顧一下JVM規範中的所有方法呼叫的位元組碼指令。其他關於位元組碼執行相關的也可以參考po主之前寫的JVM位元組碼執行模型及位元組碼指令集


在Class檔案中,方法呼叫即是對常量池(ConstantPool)屬性表中的一個符號引用,在類載入的解析期或者執行時才能確定直接引用。

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

lambda表示式執行機制

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



 public static CallSite metafactory(MethodHandles.Lookup caller,
           String invokedName,
           MethodType invokedType,
           MethodType samMethodType,
           MethodHandle implMethod,
           MethodType instantiatedMethodType)
            throws
LambdaConversionException { AbstractValidatingLambdaMetafactory mf; mf = new InnerClassLambdaMetafactory(caller, invokedType, invokedName, samMethodType, implMethod, instantiatedMethodType, false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY); mf.validateMetafactoryArgs(); return mf.buildCallSite(); }

在執行時期,虛擬機器會通過呼叫這個方法來返回一個CallSite(呼叫點)物件。簡述一下方法的執行過程,首先,初始化一個InnerClassLambdaMetafactory物件,這個物件的buildCallSite方法會將Lambda表示式先轉化成一個內部類,這個內部類是MethodHandles.Lookup caller的一個內部類,也即包含此Lambda表示式的類的內部類。這個內部類是通過位元組碼生成技術(jdk.internal.org.objectweb.asm)生成,再通過UNSAFE類載入到JVM。然後再返回繫結此內部類的CallSite物件,這個過程的原始碼也可以看一下:

CallSite buildCallSite() throws LambdaConversionException {
        // 通過位元組碼生成技術(jdk asm)生成代表lambda表示式體資訊的一個內部類的Class物件,因為是執行期生成,所以在編譯後的位元組碼資訊中並沒有這個內部類的位元組流資訊。
        final Class<?> innerClass = spinInnerClass();
        // incokedType即lambda表示式的呼叫方法型別,如下面示例中的Consumer方法
        if (invokedType.parameterCount() == 0) {
            final Constructor<?>[] ctrs = AccessController.doPrivileged(
                    new PrivilegedAction<Constructor<?>[]>() {
                @Override
                public Constructor<?>[] run() {
                    Constructor<?>[] ctrs = innerClass.getDeclaredConstructors();
                    if (ctrs.length == 1) {
                        // 表示lambda表示式的內部類是私有的,所以需要獲取這個內部類的訪問許可權。
                        ctrs[0].setAccessible(true);
                    }
                    return ctrs;
                }
                    });
            if (ctrs.length != 1) {
                throw new LambdaConversionException("Expected one lambda constructor for "
                        + innerClass.getCanonicalName() + ", got " + ctrs.length);
            }

            try {
            // 通過建構函式的newInstance方法,建立一個內部類物件
                Object inst = ctrs[0].newInstance();
                // MethodHandles.constant方法將這個內部類物件的資訊組裝並繫結到一個MethodHandle物件,作為ConstantCallSite的建構函式的引數“target”返回。後面對於Lambda表示式的呼叫,都會通過MethodHandle直接呼叫,不需再次生成CallSite.
                return new ConstantCallSite(MethodHandles.constant(samBase, inst));
            }
            catch (ReflectiveOperationException e) {
                throw new LambdaConversionException("Exception instantiating lambda object", e);
            }
        } else {
            try {
                UNSAFE.ensureClassInitialized(innerClass);
                return new ConstantCallSite(
                        MethodHandles.Lookup.IMPL_LOOKUP
                             .findStatic(innerClass, NAME_FACTORY, invokedType));
            }
            catch (ReflectiveOperationException e) {
                throw new LambdaConversionException("Exception finding constructor", e);
            }
        }
    }

這個過程將生成一個代表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,以及一個有參構造方法)


package eight;

import java.util.function.Consumer;

/**
 * Created by lijingyao on 15/11/2 19:13.
 */
public class Functionnal {

    public static void main(String[] args) {
        Consumer<Person> greeter = (p) -> System.out.println("Hello, " + p.getName());
        greeter.accept(new Person("Lambda"));
    }
}

用verbose命令看一下方法主體的位元組碼資訊,這裡暫時省略常量池資訊,後面會在符號引用到常量池資訊的地方具體展示。



public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=2, args_size=1
         0: invokedynamic #2,  0              // InvokeDynamic #0:accept:()Ljava/util/function/Consumer;
         5: astore_1
         6: aload_1
         7: new           #3                  // class eight/Person
        10: dup
        11: ldc           #4                  // String Lambda
        13: invokespecial #5                  // Method eight/Person."<init>":(Ljava/lang/String;)V
        16: invokeinterface #6,  2            // InterfaceMethod java/util/function/Consumer.accept:(Ljava/lang/Object;)V
        21: return
      LineNumberTable:
        line 11: 0
        line 12: 6
        line 13: 21
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  args   [Ljava/lang/String;
            6      16     1 greeter   Ljava/util/function/Consumer;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            6      16     1 greeter   Ljava/util/function/Consumer<Leight/Person;>;

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的資訊如下:

   #2 = InvokeDyn amic      #0:#44         // #0:accept:()Ljava/util/function/Consumer;   

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

CONSTANT_InvokeDynamic_info {
 u1 tag;
 u2 bootstrap_method_attr_index;
 u2 name_and_type_index;
}      

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

  • tag: 佔用一個位元組(u1)的tag,也即InvokeDynamic的一個標記值,其會轉化成一個位元組的tag值。可以看一下jvm spec中,常量池的tag值轉化表(這裡tag值對應=18):
     Constant pool tags
  • bootstrap_method_attr_index:指向bootstrap_methods的一個有效索引值,其結構在屬性表的 bootstrap method 結構中,也描述在Class檔案的二進位制位元組流資訊裡。下面是對應索引 0 的bootstrap method 屬性表的內容:

BootstrapMethods:    
  0: #40 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:    
      #41 (Ljava/lang/Object;)V   
      #42 invokestatic eight/Functionnal.lambda$main$0:(Leight/Person;)V   
      #43 (Leight/Person;)V      

這段位元組碼資訊展示了,引導方法就是LambdaMetafactory.metafactory方法。對照著前面LambdaMetafactory.metafactory的原始碼一起閱讀。通過debug先看一下這個方法在執行時的引數值:
LambdaMetafactory.metafactory arguments
這個方法的前三個引數都是由JVM自動連結Call Site生成。方法最後返回一個CallSite物件,對應invokedynamic指令的運算元。
- name_and_type_index:代表常量池表資訊的一個有效索引值,其指向的常量池屬性表結構一定是一個CONSTANT_NameAndType_info屬性,代表了方法名稱和方法描述符資訊。再沿著 #44 索引看一下常量池相關項的描述內容:


 #44 = NameAndType        #64:#65        // accept:()Ljava/util/function/Consumer;
 #64 = Utf8               accept
 #65 = Utf8               ()Ljava/util/function/Consumer;

通過以上幾項,可以很清楚得到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 在常量池屬性表中結構資訊如下:


  #4 = String             #46            // Lambda
  #46 = Utf8               Lambda

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

  1. 第八條:16: invokeinterface #6, 2 呼叫了Consumer的accept介面方法{greeter.accept(person)}。#6 逗號後面的引數2 是invokeinterface指令的引數,含義是介面方法的引數的個數加1,因為accpet方法只有一個引數,所以這裡是1+1=2。接著再看一下常量池項 #6 屬性表資訊:

   #6 = InterfaceMethodref #48.#49        // java/util/function/Consumer.accept:(Ljava/lang/Object;)V
   #48 = Class              #67            // java/util/function/Consumer
   #49 = NameAndType        #64:#62        // accept:(Ljava/lang/Object;)V
   #67 = Utf8               java/util/function/Consumer
   #62 = Utf8               (Ljava/lang/Object;)V
   #64 = Utf8               accept

以上可以看出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



   public static void main(String[] args) {

        Comparator<Person> comparator = (p1, p2) -> p1.getFirstName().compareTo(p2.getFirstName());

        Person p1 = new Person("John", "Doe");
        Person p2 = new Person("Alice", "Wonderland");

        comparator.compare(p1, p2);            // > 0
        comparator.reversed().compare(p1, p2);  // < 0
    }

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

資源