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
表達式到字節碼的轉換分兩步執行:
- 生成一個
invokedynamic
調用站點(稱為lambda工廠),調用該站點時,該站點返回lambda
正在轉換到的功能接口的實例; - 將
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 Lambda
和Invokedynamic
詳情的文章就介紹到這瞭,更多相關Java8 Lambda
和Invokedynamic
內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- java8中的lambda表達式簡介
- 一文帶你掌握Java8中Lambda表達式 函數式接口及方法構造器數組的引用
- 詳解如何熟練使用java函數式接口
- JDK1.8新特性之方法引用 ::和Optional詳解
- Lambda表達式原理及示例