java8 探討與分析匿名內部類、lambda表達式、方法引用的底層實現

問題解決思路:查看編譯生成的字節碼文件
本文本來我是發佈在博客園,現在移植到CSDN;原文鏈接

思路一:

  1. 編譯 javac fileName.java
  2. 反編譯 javap -v -p fileName.class ; 這一步可以看到字節碼。

思路二:
運行階段保留jvm生成的類
java -Djdk.internal.lambda.dumpProxyClasses fileName.class

不錯的博客:https://blog.csdn.net/zxhoo/article/category/1800245


本人旨在探討匿名內部類、lambda表達式(lambda expression),方法引用(method references )的底層實現,包括實現的階段(第一次編譯期還是第二次編譯)和實現的原理。

測試匿名內部類的實現

建議去對照着完整的代碼來看 源碼鏈接
基於strategy類,使用匿名內部類,main函數的代碼如下,稱作test1

    Strategy strategy = new Strategy() {
        @Override
        public String approach(String msg) {
            return "strategy changed : "+msg.toUpperCase() + "!";
        }
    };
    Strategize s = new Strategize("Hello there");
    s.communicate();
    s.changeStrategy(strategy);
    s.communicate();

第一步:現在對其使用javac編譯,在Strategize.java的目錄裏,命令行運行javac Strategize.java,結果我們可以看到生成了5個.class文件,我們預先定義的只有4個class,而現在卻多出了一個,說明編譯期幫我們生成了一個class,其內容如下:

class Strategize$1 implements Strategy {
    Strategize$1() {
    }

    public String approach(String var1) {
        return var1.toUpperCase();
    }
}

第二部:對生成的 Strategize.class 進行反編譯,運行javap -v -c Strategize.class,在輸出的結尾可以看到下面信息:

NestMembers:
  com/langdon/java/onjava8/functional/Strategize$1
InnerClasses:
  #9;                                     // class com/langdon/java/onjava8/functional/Strategize$1

說明,這個Strategize$1的確是Strategize的內部類。
這個類是命名是有規範的,作爲Strategize的第一個內部類,所以命名爲Strategize$1。如果我們在測試的時候多寫一個匿名內部類,結果會怎樣?
我們修改main()方法,多寫一個匿名內部類,稱做test2

    Strategy strategy1 = new Strategy() {
        @Override
        public String approach(String msg) {
            return "strategy1  : "+msg.toUpperCase() + "!";
        }
    };
    Strategy strategy2 = new Strategy() {
        @Override
        public String approach(String msg) {
            return "strategy2  : "+msg.toUpperCase() + "!";
        }
    };
    Strategize s = new Strategize("Hello there");
    s.communicate();
    s.changeStrategy(strategy1);
    s.communicate();
    s.changeStrategy(strategy2);
    s.communicate();

繼續使用javac編譯一下;結果與預想的意義,多生成了2個類,分別是Strategize$1Strategize$2,兩者是實現方式是相同的,都是實現了Strategy接口的class

小結

到此,可以說明匿名內部類的實現:第一次編譯的時候通過字節碼工具多生成一個class來實現的。

測試lambda表達式

第一步:修改test2的代碼,把strategy1改用lambda表達式實現,稱作test3

    Strategy strategy1 = msg -> "strategy1  : "+msg.toUpperCase() + "!";
    Strategy strategy2 = new Strategy() {
        @Override
        public String approach(String msg) {
            return "strategy2  : "+msg.toUpperCase() + "!";
        }
    };
    Strategize s = new Strategize("Hello there");
    s.communicate();
    s.changeStrategy(strategy1);
    s.communicate();
    s.changeStrategy(strategy2);
    s.communicate();

第二步:繼續使用javac編譯,結果只多出了一個class,名爲Strategize$1,這是用匿名內部類產生的,但是lambda表達式的實現還看不到。但此時發現main()函數的代碼在NetBeans中已經無法反編譯出來,是NetBeans的反編譯器不夠強大?嘗試使用在線反編譯器,結果的部分如下

   public static void main(String[] param0) {
      // $FF: Couldn't be decompiled
   }

   // $FF: synthetic method
   private static String lambda$main$0(String var0) {
      return var0.toUpperCase();
   }

第三步:使用javap反編譯,可以看到在main()方法的後面多出了一個函數,如下描述

  private static java.lang.String lambda$main$0(java.lang.String);
    descriptor: (Ljava/lang/String;)Ljava/lang/String;
    flags: (0x100a) ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokevirtual #17                 // Method java/lang/String.toUpperCase:()Ljava/lang/String;
         4: invokedynamic #18,  0             // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
         9: areturn
      LineNumberTable:
        line 48: 0

到此,我們只能見到,在第一次編譯後僅僅是編譯期多生成了一個函數,並沒有爲lambda表達式多生成一個class。
關於這個方法lambda$main$0的命名:以lambda開頭,因爲是在main()函數裏使用了lambda表達式,所以帶有$main表示,因爲是第一個,所以$0。

第四步:運行Strategize,回到src目錄,使用java 完整報名.Strategize,比如我使用的是java com.langdon.java.onjava8.functional.test3.Strategize,結果是直接運行的mian函數,類文件並沒有發生任何變化。

第五步:加jvm啓動屬性,如果我們在啓動JVM的時候設置系統屬性"jdk.internal.lambda.dumpProxyClasses"的話,那麼在啓動的時候生成的class會保存下來。使用java命令如下

java -Djdk.internal.lambda.dumpProxyClasses com.langdon.java.onjava8.functional.test3.Strategize

此時,我看到了一個新的類,如下:

import java.lang.invoke.LambdaForm.Hidden;

// $FF: synthetic class
final class Strategize$$Lambda$1 implements Strategy {
    private Strategize$$Lambda$1() {
    }

    @Hidden
    public String approach(String var1) {
        return Strategize.lambda$main$0(var1);
    }
}

synthetic class說明這個類是通過字節碼工具自動生成的,注意到,這個類是final,實現了Strategy接口,接口是實現很簡單,就是調用了第一次編譯時候生產的Strategize.lambda$main$0()方法。從命名上可以看出這個類是實現lambda表達式的類和以及Strategize的內部類。

小結

lambda表達式與普通的匿名內部類的實現方式不一樣,在第一次編譯階段只是多增了一個lambda方法,並通過invoke dynamic 指令指明瞭在第二次編譯(運行)的時候需要執行的額外操作——第二次編譯時通過java/lang/invoke/LambdaMetafactory.metafactory 這個工廠方法來生成一個class(其中參數傳入的方法就是第一次編譯時生成的lambda方法。)
這個操作最終還是會生成一個實現lambda表達式的內部類。

測試方法引用

爲了測試方法引用(method reference),對上面的例子做了一些修改,具體看test4.

第一步:運行javac Strategize.java,並沒有生產額外的.class文件,都是預定義的。這點與lambda表達式是一致的。但NetBeans對Strategize.class的mian()方法反編譯失敗,嘗試使用上文提到的反編譯器,結果也是一樣。

第二步:嘗試使用javap -v -p 反編譯Strategize.class,發現與lambda表達式相似的地方

InnerClasses:
  public static final #82= #81 of #87;    // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
  0: #45 REF_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:
      #46 (Ljava/lang/String;)Ljava/lang/String;
      #47 REF_invokeStatic com/langdon/java/onjava8/functional/test4/Unrelated.twice:(Ljava/lang/String;)Ljava/lang/String;
      #46 (Ljava/lang/String;)Ljava/lang/String;
  1: #45 REF_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:
      #46 (Ljava/lang/String;)Ljava/lang/String;
      #52 REF_invokeVirtual com/langdon/java/onjava8/functional/test4/Unrelated.third:(Ljava/lang/String;)Ljava/lang/String;
      #46 (Ljava/lang/String;)Ljava/lang/String;

從這裏可以看出,方法引用的實現方式與lambda表達式是非常相似的,都是在第二次編譯(運行)的時候調用java/lang/invoke/LambdaMetafactory.metafactory 這個工廠方法來生成一個class,其中方法引用不需要在第一次編譯時生成額外的lambda方法。

第三步:使用jdk.internal.lambda.dumpProxyClasses參數運行。如下

java -Djdk.internal.lambda.dumpProxyClasses com.langdon.java.onjava8.functional.test4.Strategize

結果jvm額外生成了2個.class文件,StrategizeKaTeX parse error: Can't use function '$' in math mode at position 7: Lambda$̲1 與 StrategizeLambda$2。從這點可以看出方法引用在第二次編譯時的實現方式與lambda表達式是一樣的,都是藉助字節碼工具生成相應的class。兩個類的代碼如下 (由NetBeans反編譯得到)


//for Strategize$$Lambda$1
package com.langdon.java.onjava8.functional.test4;

import java.lang.invoke.LambdaForm.Hidden;

// $FF: synthetic class
final class Strategize$$Lambda$1 implements Strategy {
    private Strategize$$Lambda$1() {
    }

    @Hidden
    public String approach(String var1) {
        return Unrelated.twice(var1);
    }
}

// for Strategize$$Lambda$2
package com.langdon.java.onjava8.functional.test4;

import java.lang.invoke.LambdaForm.Hidden;

// $FF: synthetic class
final class Strategize$$Lambda$2 implements StrategyDev {
    private final Unrelated arg$1;

    private Strategize$$Lambda$2(Unrelated var1) {
        this.arg$1 = var1;
    }

    private static StrategyDev get$Lambda(Unrelated var0) {
        return new Strategize$$Lambda$2(var0);
    }

    @Hidden
    public String approach(String var1) {
        return this.arg$1.third(var1);
    }
}

小結

方法引用在第一次編譯的時候並沒有生產額外的class,也沒有像lambda表達式那樣生成一個static方法,而只是使用invoke dynamic標記了(這點與lambda表達式一樣),在第二次編譯(運行)時會調用java/lang/invoke/LambdaMetafactory.metafactory 這個工廠方法來生成一個class,其中參數傳入的方法就是方法引用的實際方法。這個操作與lambda表達式一樣都會生成一個匿名內部類。

三種實現方式的總結

方式 javac編譯 javap反編譯 jvm調參並第二次編譯 (運行)
匿名內部類 額外生成class 未見invoke dynamic指令 無變化
lambda表達式 未生成class,但額外生成了一個static的方法 發現invoke dynamic 發現額外的class
方法引用 未額外生成 發現invoke dynamic 發現額外的class

對於lambda表達式,爲什麼java8要這樣做?

下面的譯本,原文Java-8-Lambdas-A-Peek-Under-the-Hood

匿名內部類具有可能影響應用程序性能的不受歡迎的特性。

  1. 編譯器爲每個匿名內部類生成一個新的類文件。生成許多類文件是不可取的,因爲每個類文件在使用之前都需要加載和驗證,這會影響應用程序的啓動性能。加載可能是一個昂貴的操作,包括磁盤I/O和解壓縮JAR文件本身。
  2. 如果lambdas被轉換爲匿名內部類,那麼每個lambda都有一個新的類文件。由於每個匿名內部類都將被加載,它將佔用JVM的元空間(這是Java 8對永久生成的替代)。如果JVM將每個此類匿名內部類中的代碼編譯爲機器碼,那麼它將存儲在代碼緩存中。此外,這些匿名內部類將被實例化爲單獨的對象。因此,匿名內部類會增加應用程序的內存消耗。爲了減少所有這些內存開銷,引入一種緩存機制可能是有幫助的,這將促使引入某種抽象層。
  3. 最重要的是,從第一天開始就選擇使用匿名內部類來實現lambdas,這將限制未來lambda實現更改的範圍,以及它們根據未來JVM改進而演進的能力。
  4. 將lambda表達式轉換爲匿名內部類將限制未來可能的優化(例如緩存),因爲它們將綁定到匿名內部類字節碼生成機制。

基於以上4點,lambda表達式的實現不能直接在編譯階段就用匿名內部類實現
,而是需要一個穩定的二進制表示,它提供足夠的信息,同時允許JVM在未來採用其他可能的實現策略。
解決上述解釋的問題,Java語言和JVM工程師決定將翻譯策略的選擇推遲到運行時。Java 7 中引入的新的 invokedynamic 字節碼指令爲他們提供了一種高效實現這一目標的機制。將lambda表達式轉換爲字節碼需要兩個步驟:

  1. 生成 invokedynamic 調用站點 ( 稱爲lambda工廠 ),當調用該站點時,返回一個函數接口實例,lambda將被轉換到該接口;
  2. 將lambda表達式的主體轉換爲將通過invokedynamic指令調用的方法。

爲了演示第一步,讓我們檢查編譯一個包含lambda表達式的簡單類時生成的字節碼,例如:

import java.util.function.Function;

public class Lambda {
    Function<String, Integer> f = s -> Integer.parseInt(s);
}

這將轉化爲以下字節碼:

 0: aload_0
 1: invokespecial #1 // Method java/lang/Object."<init>":()V
 4: aload_0
 5: invokedynamic #2, 0 // InvokeDynamic
                  #0:apply:()Ljava/util/function/Function;
10: putfield #3 // Field f:Ljava/util/function/Function;
13: return

注意,方法引用的編譯略有不同,因爲javac不需要生成合成方法,可以直接引用方法。

如何執行第二步取決於lambda表達式是非捕獲 non-capturing (lambda不訪問定義在其主體外部的任何變量) 還是捕獲 capturing (lambda訪問定義在其主體外部的變量),比如類成員變量。

非捕獲 lambda簡單地被描述爲一個靜態方法,該方法具有與lambda表達式完全相同的簽名,並在使用lambda表達式的同一個類中聲明。 例如,上面的Lambda類中聲明的lambda表達式可以被描述爲這樣的方法,這個方法就在使用了lambda表達式的方法的下面生成。

static Integer lambda$1(String s) {
   return Integer.parseInt(s);
}

捕獲 lambda表達式的情況要複雜一些,因爲捕獲的變量必須與lambda的形式參數一起傳遞給實現lambda表達式主體的方法。在這種情況下,常見的轉換策略是在lambda表達式的參數之前爲每個捕獲的變量添加一個額外的參數。讓我們來看一個實際的例子:

int offset = 100;
Function<String, Integer> f = s -> Integer.parseInt(s) + offset; 

可以生成相應的方法實現:

static Integer lambda$1(int offset, String s) {
    return Integer.parseInt(s) + offset;
}

然而,這種翻譯策略並不是一成不變的,因爲使用invokedynamic指令可以讓編譯器在將來靈活地選擇不同的實現策略。例如,可以將捕獲的值封裝在數組中,或者,如果lambda表達式讀取使用它的類的某些字段,則生成的方法可以是實例方法,而不是聲明爲靜態方法,從而避免了將這些字段作爲附加參數傳遞的需要。

理論上的性能

第一步:是鏈接步驟,它對應於上面提到的lambda工廠步驟。如果我們將性能與匿名內部類進行比較,那麼等效的操作將是裝入匿名內部類。Oracle已經發布了Sergey Kuksenko關於這一權衡的性能分析,您可以看到Kuksenko在2013年JVM語言峯會[3]上發表了關於這個主題的演講。分析表明,預熱lambda工廠方法需要時間,在此期間,初始化速度較慢。當鏈接了足夠多的調用站點時,如果代碼處於熱路徑上(即,其中一個頻繁調用,足以編譯JIT)。另一方面,如果是冷路徑 (cold path),lambda工廠方法可以快100倍。

第二步是:從周圍範圍捕獲變量。正如我們已經提到的,如果沒有要捕獲的變量,那麼可以自動優化此步驟,以避免使用基於lambda工廠的實現分配新對象。在匿名內部類方法中,我們將實例化一個新對象。爲了優化相同的情況,您必須手動優化代碼,方法是創建一個對象並將其提升到一個靜態字段中。例如:

// Hoisted Function
public static final Function<String, Integer> parseInt = new Function<String, Integer>() {
    public Integer apply(String arg) {
        return Integer.parseInt(arg);
    }
}; 

// Usage:
int result = parseInt.apply(123);

第三步:是調用實際的方法。目前,匿名內部類和lambda表達式都執行完全相同的操作,所以這裏的性能沒有區別。非捕獲lambda表達式的開箱即用性能已經領先於提升的匿名內部類等效性能。捕獲lambda表達式的實現與爲捕獲這些字段而分配匿名內部類的性能類似。

下文將講述lambda表達式的實現在很大程度上執行得很好。雖然匿名內部類需要手工優化來避免分配,但是JVM已經爲我們優化了這種最常見的情況(一個lambda表達式沒有捕獲它的參數)。

實測的性能

當然,很容易理解總體性能模型,但在實測中又會是怎樣的?我們已經在一些軟件項目中使用了Java 8,並取得了良好的效果。自動優化非捕獲lambdas可以提供很好的好處。有一個特定的例子,它提出了一些關於未來優化方向的有趣問題。

所討論的示例發生在處理系統中使用的一些代碼時,這些代碼需要特別低的GC暫停(理想情況下是沒有暫停)。因此,最好避免分配太多的對象。該項目廣泛使用lambdas來實現回調處理程序。不幸的是,我們仍然有相當多的回調,在這些回調中,我們沒有捕獲局部變量,而是希望引用當前類的一個字段,甚至只是調用當前類的一個方法。目前,這似乎仍然需要分配。

總結

在本文中,我們解釋了lambdas不僅僅是底層的匿名內部類,以及爲什麼匿名內部類不是lambda表達式的合適實現方法。考慮lambda表達式實現方法已經做了大量工作。目前,對於大多數任務,它們都比匿名內部類更快,但目前的情況並不完美;測量驅動的手工優化仍有一定的空間。

不過,Java 8中使用的方法不僅限於Java本身。Scala歷來通過生成匿名內部類來實現它的lambda表達式。在Scala 2.12中,雖然已經開始使用Java 8中引入的lambda元操作機制。隨着時間的推移,JVM上的其他語言也可能採用這種機制。

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