深入探索Java 8 Lambda表達式

NorColechin 8年前發布 | 17K 次閱讀 Java8 Java開發

來自: http://www.cnblogs.com/bymax/p/5242913.html

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); }</pre>

查看上面的類經過編譯之后生成的字節碼:

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</pre>

需要注意的是,方法引用的編譯稍微有點不同,因為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”);</pre>

第三部就是真實方法的調用。在這一步中匿名內部類和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); } ... }); }</pre>

有一個簡單的辦法解決這個問題,我們將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); }</pre>

然而上面的修改后代碼給卻給整個工程帶來了一個嚴重的問題:性能分析表明,這種修改產生很大的對象申請,其產生的內存申請在總應用的60%以上。

類似這種無關上下文的優化可能帶來其他問題。

  1. 純粹為了優化的目的,使用了非慣用的代碼寫法,可讀性會稍差一些。
  2. 內存分配方面的問題,示例中為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

</div>

 本文由用戶 NorColechin 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!