深入探索 Java 8 Lambda 表示式

InfoQ發表於2015-11-28

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%以上。

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

  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的這種機制。

相關文章