2014年3月,Java 8發佈,Lambda表達式作為一項重要的特性隨之而來。或許現在你已經在使用Lambda表達式來書寫簡潔靈活的代碼。比如,你可以使用Lambda表達式和新增的流相關的API,完成如下的大量數據的查詢處理: int total = invoices.stream() .filt
2014年3月,Java 8發佈,Lambda表達式作為一項重要的特性隨之而來。或許現在你已經在使用Lambda表達式來書寫簡潔靈活的代碼。比如,你可以使用Lambda表達式和新增的流相關的API,完成如下的大量數據的查詢處理:
int total = invoices.stream()
.filter(inv -> inv.getMonth() == Month.JULY)
.mapToInt(Invoice::getAmount)
.sum();
上面的示例代碼描述瞭如何從一打發票中計算出7月份的應付款總額。其中我們使用Lambda表達式過濾出7月份的發票,使用方法引用來提取出發票的金額。
到這裡,你可能會對Java編譯器和JVM內部如何處理Lambda表達式和方法引用比較好奇。可能會提出這樣的問題,Lambda表達式會不會就是匿名內部類的語法糖呢?畢竟上面的示例代碼可以使用匿名內部類實現,將Lambda表達式的方法體實現移到匿名內部類對應的方法中即可,但是我們並不贊成這樣做。如下為匿名內部類實現版本:
int total = invoices.stream()
.filter(new Predicate<Invoice>() {
@Override
public boolean test(Invoice inv) {
return inv.getMonth() == Month.JULY;
}
})
.mapToInt(new ToIntFunction<Invoice>() {
@Override
public int applyAsInt(Invoice inv) {
return inv.getAmount();
}
})
.sum();
本文將會介紹為什麼Java編譯器沒有採用內部類的形式處理Lambda表達式,並解密Lambda表達式和方法引用的內部實現。接著介紹位元組碼生成並簡略分析Lambda表達式理論上的性能。最後,我們將討論一下實踐中Lambda表達式的性能問題。
為什麼匿名內部類不好?
實際上,匿名內部類存在著影響應用性能的問題。
首先,編譯器會為每一個匿名內部類創建一個類文件。創建出來的類文件的名稱通常按照這樣的規則 ClassName符合和數字。生成如此多的文件就會帶來問題,因為類在使用之前需要載入類文件併進行驗證,這個過程則會影響應用的啟動性能。類文件的載入很有可能是一個耗時的操作,這其中包含了磁碟IO和解壓JAR文件。
假設Lambda表達式翻譯成匿名內部類,那麼每一個Lambda表達式都會有一個對應的類文件。隨著匿名內部類進行載入,其必然要占用JVM中的元空間(從Java 8開始永久代的一種替代實現)。如果匿名內部類的方法被JIT編譯成機器代碼,則會存儲到代碼緩存中。同時,匿名內部類都需要實例化成獨立的對象。以上關於匿名內部類的種種會使得應用的記憶體占用增加。因此我們有必要引入新的緩存機制減少過多的記憶體占用,這也就意味著我們需要引入某種抽象層。
最重要的,一旦Lambda表達式使用了匿名內部類實現,就會限制了後續Lambda表達式實現的更改,降低了其隨著JVM改進而改進的能力。
我們看一下下麵的這段代碼:
import java.util.function.Function;
public class AnonymousClassExample {
Function<String, String> format = new Function<String, String>() {
public String apply(String input){
return Character.toUpperCase(input.charAt(0)) + input.substring(1);
}
};
}
使用這個命令我們可以檢查任何類文件生成的位元組碼
javap -c -v ClassName
示例中使用Function創建的匿名內部類對應的位元組碼如下:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class AnonymousClassExample$1
8: dup
9: aload_0
10: invokespecial #3 // Method AnonymousClass$1."<init>":(LAnonymousClassExample;)V
13: putfield #4 // Field format:Ljava/util/function/Function;
16: return
上述位元組碼的含義如下:
- 第5行,使用位元組碼操作new創建了類型AnonymousClassExample$1的一個對象,同時將新創建的對象的的引用壓入棧中。
- 第8行,使用dup操作複製棧上的引用。
- 第10行,上面的複製的引用被指令invokespecial消耗使用,用來初始化匿名內部類實例。
- 第13行,棧頂依舊是創建的對象的引用,這個引用通過putfield指令保存到AnonymousClassExample類的format屬性中。
AnonymousClassExample1這個類文件,你會發現這個類就是Function介面的實現。
將Lambda表達式翻譯成匿名內部類會限制以後可能進行的優化(比如緩存)。因為一旦使用了翻譯成匿名內部類形式,那麼Lambda表達式則和匿名內部類的位元組碼生成機制綁定。因而,Java語言和JVM工程師需要設計一個穩定並且具有足夠信息的二進位表示形式來支持以後的JVM實現策略。下麵的部分將介紹不使用匿名內部類機制,Lambda表達式是如何工作的。
Lambdas表達式和invokedynamic
為瞭解決前面提到的擔心,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不需要創建一個合成的方法,javac可以直接訪問該方法。
Lambda表達式轉化成位元組碼的第二步取決於Lambda表達式是否為對變數捕獲。Lambda表達式方法體需要訪問外部的變數則為對變數捕獲,反之則為對變數不捕獲。
對於不進行變數捕獲的Lambda表達式,其方法體實現會被提取到一個與之具有相同簽名的靜態方法中,這個靜態方法和Lambda表達式位於同一個類中。比如上面的那段Lambda表達式會被提取成類似這樣的方法:
static Integer lambda$1(String s) {
return Integer.parseInt(s);
}
需要註意的是,這裡的$1並不是代表內部類,這裡僅僅是為了展示編譯後的代碼而已。
對於捕獲變數的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;
}
需要註意的是編譯器對於Lambda表達式的翻譯策略並非固定的,因為這樣invokedynamic可以使編譯器在後期使用不同的翻譯實現策略。比如,被捕獲的變數可以放入數組中。如果Lambda表達式用到了類的實例的屬性,其對應生成的方法可以是實例方法,而不是靜態方法,這樣可以避免傳入多餘的參數。
性能分析
Lambda表達式最主要的優勢表現在性能方面,雖然使用它很輕鬆的將很多行代碼縮減成一句,但是其內部實現卻不這麼簡單。下麵對內部實現的每一步進行性能分析。
第一步就是連接,對應的就是我們上面提到的Lambda工廠。這一步相當於匿名內部類的類載入過程。來自Oracle的Sergey Kuksenko發佈過相關的性能報告,並且他也在2013 JVM語言大會就該話題做過分享。報告表明,Lambda工廠的預熱準備需要消耗時間,並且這個過程比較慢。伴隨著更多的調用點連接,代碼被頻繁調用後(比如被JIT編譯優化)性能會提升。另一方面如果連接處於不頻繁調用的情況,那麼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表達式要比進行static優化過的匿名內部類較優。進行變數捕獲的Lambda表達式和匿名內部類表達式性能大致相同。
在這一節中,我們明顯可以看到Lambda表達式的實現表現良好,匿名內部類通常需要我們手動的進行優化來避免額外對象生成,而對於不進行變數捕獲的Lambda表達式,JVM已經為我們做好了優化。
實踐中的性能分析
理解了Lambda的性能模型很是重要,但是實際應用中的總體性能如何呢?我們在使用Java 8 編寫了一些軟體項目,一般都取得了很好的效果。非變數捕獲的Lambda表達式給我們帶來了很大的幫助。這裡有一個很特殊的例子描述了關於優化方向的一些有趣的問題。
這個例子的場景是代碼需要運行在一個要求GC暫定時間越少越好的系統上。因而我們需要避免創建大量的對象。在這個工程中,我們使用了大量的Lambda表達式來實現回調處理。然而在這些使用Lambda實現的回調中很多並沒有捕獲局部變數,而是需要引用當前類的變數或者調用當前類的方法。然而目前仍需要對象分配。下麵就是我們提到的例子的代碼:
public MessageProcessor() {}
public int processMessages() {
return queue.read(obj -> {
if (obj instanceof NewClient) {
this.processNewClient((NewClient) obj);
}
...
});
}
有一個簡單的辦法解決這個問題,我們將Lambda表達式的代碼提前到構造方法中,並將其賦值給一個成員屬性。在調用點我們直接引用這個屬性即可。下麵就是修改後的代碼:
private final Consumer<Msg> handler;
public MessageProcessor() {
handler = obj -> {
if (obj instanceof NewClient) {
this.processNewClient((NewClient) obj);
}
...
};
}
public int processMessages() {
return queue.read(handler);
}
然而上面的修改後代碼給卻給整個工程帶來了一個嚴重的問題:性能分析表明,這種修改產生很大的對象申請,其產生的記憶體申請在總應用的60%以上。
類似這種無關上下文的優化可能帶來其他問題。
- 純粹為了優化的目的,使用了非慣用的代碼寫法,可讀性會稍差一些。
- 記憶體分配方面的問題,示例中為MessageProcessor增加了一個成員屬性,使得MessageProcessor對象需要申請更大的記憶體空間。Lambda表達式的創建和捕獲位於構造方式中,使得MessageProcessor的構造方法調用緩慢一些。
我們遇到這種情況,需要進行記憶體分析,結合合理的業務用例來進行優化。有些情況下,我們使用成員屬性確保為經常調用的Lambda表達式只申請一個對象,這樣的緩存策略大有裨益。任何性能調優的科學的方法都可以進行嘗試。
上述的方法也是其他程式員對Lambda表達式進行優化應該使用的。書寫整潔,簡單,函數式的代碼永遠是第一步。任何優化,如上面的提前代碼作為成員屬性,都必須結合真實的具體問題進行處理。變數捕獲並申請對象的Lambda表達式並非不好,就像我們我們寫出new Foo()
代碼並非一無是處一樣。
除此之外,我們想要寫出最優的Lambda表達式,常規書寫很重要。如果一個Lambda表達式用來表示一個簡單的方法,並且沒有必要對上下文進行捕獲,大多數情況下,一切以簡單可讀即可。
總結
在這片文章中,我們研究了Lambda表達式不是簡單的匿名內部類的語法糖,為什麼匿名內部類不是Lambda表達式的內部實現機制以及Lambda表達式的具體實現機制。對於大多數情況來說,Lambda表達式要比匿名內部類性能更優。然而現狀並非完美,基於測量驅動優化,我們仍然有很大的提升空間。
Lambda表達式的這種實現形式並非Java 8 所有。Scala曾經通過生成匿名內部類的形式支持Lambda表達式。在Scala 2.12版本,Lambda的實現形式替換為Java 8中的Lambda 工廠機制。後續其他可以在JVM上運行的語言也可能支持Lambda的這種機制。
原文:Java 8 Lambdas - A Peek Under the Hood
查看英文原文:Java 8 Lambdas - A Peek Under the Hood