Java8 Lambda和Invokedynamic詳情

一、闡明lambda

Java8於2014年3月發佈,並引入瞭lambda表達式作為其旗艦功能。我們可能已經在代碼庫中使用它們來編寫更簡潔、更靈活的代碼。例如,我們可以將lambda表達式與新的Streams API結合起來,以表達豐富的數據處理查詢:

int total = invoices.stream()
                    .filter(inv -> inv.getMonth() == Month.JULY)
                    .mapToInt(Invoice::getAmount)
                    .sum();

此示例顯示如何從發票集合中計算7月份到期的總金額。傳遞lambda表達式以查找月份為7月的發票,並傳遞方法引用以從發票中提取金額。

您可能想知道Java編譯器如何在幕後實現lambda表達式和方法引用,以及Java虛擬機(JVM)如何處理它們。例如,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性能。最後,我們將討論現實世界中的性能影響。

二、匿名內部類

匿名內部類具有可能影響應用程序性能的不良特征。

首先,編譯器為每個匿名內部類生成一個新的類文件。文件名通常看起來像ClassName$1,其中ClassName是定義匿名內部類的類的名稱,後跟一個美元符號和一個數字。生成許多類文件是不可取的,因為每個類文件在使用之前都需要加載和驗證,這會影響應用程序的啟動性能。加載可能是一項昂貴的操作,包括磁盤I/O和解壓縮JAR文件本身。

如果將lambda轉換為匿名內部類,則每個lambda都會有一個新的類文件。由於每個匿名內部類都將被加載,因此它將占用JVM元空間的空間(這是永久生成的Java8替代品)。如果JVM將每個匿名內部類中的代碼編譯成機器代碼,那麼它將存儲在代碼緩存中。此外,這些匿名內部類將被實例化為單獨的對象。因此,匿名內部類會增加應用程序的內存消耗。引入緩存機制以減少所有這些內存開銷可能會有所幫助,這促使引入某種抽象層。

最重要的是,從第一天起選擇使用匿名內部類實現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 

為作為匿名內部類創建的函數生成的相應字節碼如下所示:

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實例化匿名類示例$1類型的對象。同時在堆棧上推送對新創建對象的引用。
  • 8:dup操作在堆棧上復制該引用。
  • 10:然後,該值由invokespecial指令使用,該指令初始化匿名內部類實例。
  • 13:堆棧頂部現在仍然包含對對象的引用,該引用使用putfield指令存儲在AnonymousClassExample類的format字段中。

AnonymousClassExample$1是編譯器為匿名內部類生成的名稱。如果您想讓自己放心,還可以檢查AnonymousClassExample$1類文件,您將找到函數接口實現的代碼。

lambda表達式轉換為匿名內部類將限制未來可能的優化(例如緩存),因為它們與匿名內部類字節碼生成機制相關聯。因此,語言和JVM工程師需要一個穩定的二進制表示,該表示提供瞭足夠的信息,同時允許JVM在將來使用其他可能的實現策略。下一節將解釋這是如何實現的!

三、Lambdas和Invokedynamic

為瞭解決上一節中解釋的問題,Java語言和JVM工程師決定將轉換策略的選擇推遲到運行時。Java7引入的新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不需要生成合成方法,可以直接引用該方法。

第二步的執行方式取決於lambda表達式是非捕獲(lambda不訪問在其主體外部定義的任何變量)還是捕獲(lambda訪問在其主體外部定義的變量)。

非捕獲lambda被簡單地分解為一個靜態方法,該方法具有與lambda表達式完全相同的簽名,並在使用lambda表達式的同一類中聲明。例如,可以將上面lambda類中聲明的lambda表達式分解為如下方法:

static Integer lambda$1(String s) {
    return Integer.parseInt(s);
}

註意:$1不是一個內部類,它隻是我們表示編譯器生成代碼的方式

捕獲lambda表達式的情況稍微復雜一些,因為捕獲的變量必須與lambda的形式參數一起傳遞給實現lambda表達式主體的方法。在這種情況下,常見的轉換策略是在lambda表達式的參數前面加上每個捕獲變量的附加參數。讓我們看一個實際的例子:

int offset = 100;
Function<String, Integer> f = s -> Integer.parseInt(s) + offset; 


相應的方法實現可以通過asy生成:

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的路徑),則性能與類加載一致。另一方面,如果是冷路徑,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表達式)。

當然,理解整體性能模型是很好的,但是在實踐中,事情是如何疊加的呢?我們已經在一些軟件項目中使用瞭Java8,並取得瞭積極的成果。自動優化非捕獲lambda可以提供很好的好處。這裡有一個特別的例子,它提出瞭一些關於未來優化方向的有趣問題。

所討論的示例發生在處理某些代碼以供系統使用時,該系統需要特別低的GC暫停,理想情況下沒有。因此,希望避免分配太多的對象。該項目廣泛使用lambdas來實現回調處理程序。不幸的是,我們仍然有相當多的回調,其中我們沒有捕獲局部變量,但希望引用當前類的字段,甚至隻調用當前類上的方法。目前,這似乎仍然需要分配。下面是一個代碼示例,旨在闡明我們所討論的內容:

public MessageProcessor() {} 

public int processMessages() {
    return queue.read(obj -> {
        if (obj instanceof NewClient) {
            this.processNewClient((NewClient) obj);
        } 
        ...
    });
}

這個問題有一個簡單的解決辦法。我們將代碼提升到構造函數中,並將其分配給一個字段,然後在調用站點直接引用該字段。下面是我們之前重寫的代碼示例:

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添加一個字段,使其更大,以便分配。相關lambda的創建和捕獲也會減慢對MessageProcessor的構造函數調用。

我們不是通過尋找場景,而是通過內存分析發現瞭這種情況,並且有一個很好的業務用例證明瞭優化的合理性。我們還處於這樣一個位置:對象隻分配一次,大量重用lambda表達式,因此緩存非常有益。與任何性能調整練習一樣,通常推薦使用科學方法。

這也是任何其他最終用戶尋求優化其lambda表達式使用的方法。嘗試編寫幹凈、簡單且功能強大的代碼始終是最好的第一步。任何優化,如本次吊裝,應僅針對真正的問題進行。編寫捕獲分配對象的lambda表達式本身並不壞——正如編寫調用'new Foo()‘的Java代碼本身也不壞一樣。

這一經驗也確實表明,要充分利用lambda表達式,重要的是要習慣地使用它們。如果lambda表達式用於表示小的純函數,則它們幾乎不需要從其周圍范圍捕獲任何內容。和大多數事情一樣,如果你保持簡單,事情就會表現得很好。

結論
在本文中,我們解釋瞭lambda不僅僅是隱藏的匿名內部類,以及為什麼匿名內部類不是lambda表達式的合適實現方法。通過lambda表達式實現方法,已經進行瞭大量的工作。目前,對於大多數任務,它們都比匿名內部類快,但當前的狀態並不完美;測量驅動的手動優化仍有一定的空間。

Java8中使用的方法不僅僅局限於Java本身。Scala歷來通過生成匿名內部類來實現其lambda表達式。在Scala2.12中,我們已經開始使用Java8中引入的lambda元工廠機制。隨著時間的推移,JVM上的其他語言也可能采用這種機制。

到此這篇關於Java8 LambdaInvokedynamic詳情的文章就介紹到這瞭,更多相關Java8 LambdaInvokedynamic內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: