
Java 8是在2014年3月發(fā)布的,其中一個標志性的特性就是lambda表達式??赡苣阋呀?jīng)開始使用這些特性來編寫更加精簡的代碼了。舉一個簡單地例子,你可以組合使用lambda表達式和Stream API來完成豐富的數(shù)據(jù)處理:
int total = invoices.stream()
.filter(inv -> inv.getMonth() == Month.JULY)
.mapToInt(Invoice::getAmount)
.sum();
這個例子展示了如何從一組發(fā)票里提取七月份的發(fā)票并統(tǒng)計總金額。直接通過一個lambda表達式來過濾出7月份的發(fā)票,然后再通過一個方法引用(method reference)來獲得發(fā)票的金額,最后求和即可。
這時候,你可能會在想Java編譯器是如何實現(xiàn)lambda表達式以及方法引用(method reference)的,以及Java虛擬機(JVM)是如何處理它們的。例如,lambda表達式只是針對匿名內部類(anonymous inner class)的語法糖嗎?或者說,上面的代碼只需要把lambda表達式里的代碼拷貝到一個匿名內部來來就可以實現(xiàn)了(我不鼓勵你這樣去看待它?。?/p>
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編譯器沒有按照剛剛說的機制來實現(xiàn)lambda表達式,并且會簡單講解lambda表達式和方法引用(method referen)是如何實現(xiàn)的。然后,我們還會剖析最終生成的字節(jié)碼并且簡單分析它的性能。最后,還會討論現(xiàn)實情況下的性能問題。
為什么不用匿名內部類?
匿名內部類最典型的一個問題就是對應用的性能有影響。
首先,編譯器會為每個匿名內部類單獨生成一個類文件。這個類文件的名字都是類似ClassName$1的格式,其中,ClassName是匿名內部類所在的類名,接著是個$符號加一個數(shù)字。生成很多匿名內部類的方式是很不現(xiàn)實的,因為每個匿名內部類在使用之前都要被加載和驗證,這個會影響應用的啟動性能。而且類加載本身也是個很費資源的操作,它會消耗磁盤I/O,同時還需要對JAR包進行解壓。
如果lambda表達式都被翻譯成匿名內部類來實現(xiàn)的話,那么對于每個lambda表達式都會生成一個新的類文件。每個匿名內部類都需要被加載,這將會消耗JVM meta-space(Java 8 里替代Permanent Generation)的空間。然后每個匿名內部類里的代碼都被JVM編譯成機器碼,那么它們都要被存放在代碼緩存區(qū)(code cache)里從而占用緩存。除此之外,這些匿名內部類都要被實例化成單獨的對象。這樣一來,匿名內部類的實現(xiàn)就會增加你的應用的內存消耗。如果在這中間加入一個緩存機制的話,是極有可能減少內存的消耗的,這就是我們想引入一個中間層來解決這個問題的動機。
我們來看看這段代碼:
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);
}
};
}
你可以通過下面的命令來查看類文件的字節(jié)碼
javap -c -v ClassName
對應Function所生成的字節(jié)碼和下面的類似:
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: 一個
AnonymousClassExample$1的實例通過new操作符進行了初始化。同時這個新創(chuàng)建實例的引用被放入到棧頂。 - 8:
dup操作符復制了棧頂?shù)倪@個引用。 - 10:然后這個引用值被
invokeSpecial質量作為參數(shù)來初始化匿名內部類的實例。 - 13:現(xiàn)在棧頂?shù)闹狄琅f是這個實例的引用,通過
putfiled指令,這個引用被保存到AnonymousClassExample$1的format成員里。
AnonymousClassExample$1 是編譯器為匿名內部類所生成的類名。如果你想自己來驗證的話,你可以自己打開AnonymousClassExample$1 的類文件,你會找到Function接口的實現(xiàn)代碼。
把Lambda表達式翻譯成匿名內部類的這種實現(xiàn)方式對于后期實現(xiàn)上的優(yōu)化(例如緩存方面)會有影響,因為這個實現(xiàn)依賴于匿名內部類的字節(jié)碼生成機制。因此,java語言本身以及JVM的工程師們都亟需一個穩(wěn)定的二進制方案,這個方案也能需要能夠為未來JVM采用新的實現(xiàn)策略提供做夠的上下文信息。下一節(jié),我們會講述如何實現(xiàn)這一點。
Lambda和invokedynamic指令
為了解決前面提到的那個問題,Java語言本身以及JVM的工程師都采用把lambda表達式的翻譯策略推遲到運行時再來決定的方案。Java 7里新引入的invokedynamic給了他們一個可以有效實現(xiàn)這種策略的途徑。Lambda表達式翻譯成字節(jié)碼的步驟分為兩步:
- 生成一個
invokedynamic調用點(call site)(也叫l(wèi)ambda工廠),當它被調用的時候的時候,它會返回一個由lambda轉換成的Function Interface 實例。 - 將lambda表達式的代碼轉換成一個可以通過
invokespecial命令調用的函數(shù)。
為了展示上面的第一步,我們先來看看只包含一個lambda表達式的類所生成的字節(jié)碼:
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
值得注意的是方法引用(method reference)的編譯結果有點不一樣,因為javac并不需要生成一個可以直接引用的方法。
第二步的實現(xiàn)取決于lambda表達式是非捕獲式(non-capturing)的還是捕獲式的(capturing)。非捕獲式的也就是說lambda表達式不會訪問任何它外部的變量,捕獲式的lambda會訪問在lambda外部定義的變量。
非捕獲式(non-capturing)的lambda會被去掉語法糖直接翻譯成當前類里和lambda表達式有相同簽名的靜態(tài)函數(shù)。以上面的lambda表達式為例,它會被去糖替換成下面的方法:
static Integer lambda$1(String s) {
return Integer.parseInt(s);
}
注意:$1不是匿名內部類,它只是表示這段代碼是由編譯器生成的。
但是捕獲式(capturing)的lambda就有點復雜了,因為被捕獲的變量需要和lambda的參數(shù)一起傳入到lambda表達式里去執(zhí)行。這種情況下的轉換策略是將捕獲到的變量追加到lambda表達式的參數(shù)里。我們來看一個實際的例子:
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指令本身給編譯器的策略提供了很大的選擇空間。例如,捕獲的變量也可以放在一個數(shù)組里,也或者如果lambda表達式讀取了它所在類的變量,那么生成的方法也可以是實例方法,而不是靜態(tài)方法,這樣就可以不需要把這些變量作為額外的參數(shù)傳遞給lambda了。
實驗情況下的性能
這種實現(xiàn)最大的優(yōu)勢就是性能有所提升。當然了,如果能夠有個單一具體的數(shù)據(jù)來說明就最好了,但是這中間涉及到很多個階段,每個階段的耗時都不一樣。
第一個階段就是鏈接階段,這個對應上面提到的lambda工廠。如果我們和匿名內部類來對比的話,這個階段就對應到匿名內部類本身的加載了。Oracle發(fā)布了Sergey Kuksenko 編寫的針對這個實現(xiàn)的性能分析(performance analysis),你也可以參考Kuksenko在2013年JVM語言峰會(JVM Language Summit)上的演講deliver a talk on the topic。這個分析里說明了lambda工廠需要一定的時間來啟動,第一次調用比較慢。當足夠的調用點(call site)被鏈接起來代碼成為熱點(例如,代碼調用足夠頻繁被JIT編譯)之后,它的性能就能趕上類加載了。
第二個階段是從上下文里捕獲變量。正如我們已經(jīng)提到過,如果沒有變量被捕獲的話,基于lambda工廠的實現(xiàn)可以進一步優(yōu)化來避免創(chuàng)建新的對象。在匿名內部類里,我們就需要創(chuàng)建一個新的實例了。如果要達到相同的優(yōu)化效果,你需要自己手動創(chuàng)建一個實例對象,然后用一個靜態(tài)變量來引用它。例如:
// 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表達式都是調用相同的代碼,所以這個地方性能上沒有差別。對于非捕獲(non-capturing)的場景,lambda表達式已經(jīng)優(yōu)于匿名內部類的實現(xiàn)了。對于捕獲式(capturing)的場景,lambda表達式的實現(xiàn)和創(chuàng)建一個匿名內部類來保存變量的性能大同小異。
我們這里看到的是一個大體上性能比較不錯的lambda表達式的實現(xiàn)。對于匿名內部類方式需要手動優(yōu)化避免對象創(chuàng)建的這種場景的場景(非捕獲式的lambda表達式)已經(jīng)被JVM進行優(yōu)化了。
實際場景下的性能
如果只是想簡單了解一下性能模型也是很不錯的,但是有時候我們也會問實際上表現(xiàn)如何呢?我們在好幾個軟件項目上都已經(jīng)使用了Java 8,并且效果都很不錯。對于非捕獲(non-capturing)lambda的自動優(yōu)化也是一個很不錯的功能。還有一個有趣的例子,它對于未來的優(yōu)化方向提出了一些有趣的問題。
這個有問題的例子時出現(xiàn)在一個需要盡量減少GC的系統(tǒng)上,但是事實上確沒有這樣。這個實現(xiàn)原本是為了避免創(chuàng)建太多對象。它里面大量使用了lambda表達式來作為進行回調處理。不幸的是,我們有好幾個回調雖然沒有捕獲局部變量,但是需要引用當前類的成員變量或者函數(shù)。目前來看,好像還是會導致對象的創(chuàng)建。下面是作為說明的實例代碼:
public MessageProcessor() {}
public int processMessages() {
return queue.read(obj -> {
if (obj instanceof NewClient) {
this.processNewClient((NewClient) obj);
}
...
});
}
對于這個問題,我們有個很簡單的解決方案。就是把這段代碼抽取到構造函數(shù)里,然后用一個變量來引用調用點(call site)。下面是重寫后的代碼:
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%。
但是使用這種方式來優(yōu)化,也存在著其他問題。
- 這里純粹是為了性能才寫這樣不符合規(guī)范的代碼。所以會導致可讀性降低。
- 這里也有其他內存分配的問題。你在MessageProcessor里添加了字段,導致它需要占用更多內存。同時,lambda的創(chuàng)建以及變量的捕獲都會導致MessageProcessor的構造函數(shù)變慢。
我們之所以會有這樣的方案,并不是實際有這樣的場景,而是通過內存診斷才發(fā)現(xiàn)這個問題的,然后我們恰好有個合適的業(yè)務場景證實了這個優(yōu)化的可行性。我們也會有只創(chuàng)建一次對象,然后頻繁使用lambda表達式的場景,這樣的話緩存就變得非常有用了。和其他所有內存調優(yōu)實踐一樣,科學的方法往往都是最值得推薦的。
這個方法也適用于其他想要對lambda表達式進行調優(yōu)的場景。首先盡量編寫干凈、簡單以及函數(shù)式的代碼。任何優(yōu)化,例如這種抽取,都是盡量用來對付一些棘手的問題。編寫需要捕獲創(chuàng)建對象的lambda表達式并不是壞事 — 就像用Java代碼來調用new Foo()本身就沒有任何問題一樣。
這個實踐也向我們建議使用lambda表達式的最佳方案就是按照常規(guī)編碼習慣來用。如果lambda表達式只是用來表示小的,純函數(shù)式的功能,那么它完全沒有必要去捕獲上下文的變量。就像其他所有事情一樣 — 越簡單越高效。
結論
在這篇文章里,我們說明了lambda表達式不是由匿名內部類來實現(xiàn)的,同時也闡述了匿名內部類不是合適的方案的原因。對于lambda表達式的實現(xiàn),目前已經(jīng)有很多人投入了大量的工作。目前的實現(xiàn),在很多情況下都是比匿名內部類要快的,盡管如此,目前的方案還不是完美的,還是存在很多需要手動去診斷調優(yōu)的場景。
最后,Java 8里使用的方案也并不局限于Java自身。Scala也曾經(jīng)通過生成匿名內部類來實現(xiàn)lambda表達式。在Scala 2.12版本里,已經(jīng)改成使用Java 8 里引入的lambda工廠的方式了。隨著時間的推移,其他的JVM語言也會慢慢都采用這種機制的。