問題解決思路:查看編譯生成的字節碼文件
本文本來我是發佈在博客園,現在移植到CSDN;原文鏈接
思路一:
- 編譯
javac fileName.java
- 反編譯
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$1
和Strategize$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
匿名內部類具有可能影響應用程序性能的不受歡迎的特性。
- 編譯器爲每個匿名內部類生成一個新的類文件。生成許多類文件是不可取的,因爲每個類文件在使用之前都需要加載和驗證,這會影響應用程序的啓動性能。加載可能是一個昂貴的操作,包括磁盤I/O和解壓縮JAR文件本身。
- 如果lambdas被轉換爲匿名內部類,那麼每個lambda都有一個新的類文件。由於每個匿名內部類都將被加載,它將佔用JVM的元空間(這是Java 8對永久生成的替代)。如果JVM將每個此類匿名內部類中的代碼編譯爲機器碼,那麼它將存儲在代碼緩存中。此外,這些匿名內部類將被實例化爲單獨的對象。因此,匿名內部類會增加應用程序的內存消耗。爲了減少所有這些內存開銷,引入一種緩存機制可能是有幫助的,這將促使引入某種抽象層。
- 最重要的是,從第一天開始就選擇使用匿名內部類來實現lambdas,這將限制未來lambda實現更改的範圍,以及它們根據未來JVM改進而演進的能力。
- 將lambda表達式轉換爲匿名內部類將限制未來可能的優化(例如緩存),因爲它們將綁定到匿名內部類字節碼生成機制。
基於以上4點,lambda表達式的實現不能直接在編譯階段就用匿名內部類實現
,而是需要一個穩定的二進制表示,它提供足夠的信息,同時允許JVM在未來採用其他可能的實現策略。
解決上述解釋的問題,Java語言和JVM工程師決定將翻譯策略的選擇推遲到運行時。Java 7 中引入的新的 invokedynamic
字節碼指令爲他們提供了一種高效實現這一目標的機制。將lambda表達式轉換爲字節碼需要兩個步驟:
- 生成
invokedynamic
調用站點 ( 稱爲lambda工廠 ),當調用該站點時,返回一個函數接口實例,lambda將被轉換到該接口; - 將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上的其他語言也可能採用這種機制。