1. 程式人生 > >34、有人說“Lambda能讓Java程式慢30倍”,你怎麼看?

34、有人說“Lambda能讓Java程式慢30倍”,你怎麼看?

在上一講中,我介紹了 Java 效能問題分析的一些基本思路。但在實際工作中,我們不能僅僅等待效能出現問題再去試圖解決,而是需要定量的、可對比的方法,去評估 Java 應用效能,來判斷其是否能夠符合業務支撐目標。今天這一講,我會介紹從 Java 
開發者角度,如何從程式碼級別判斷應用的效能表現,重點理解最廣泛使用的基準測試(Benchmark)。

今天我要問你的問題是,有人說“Lambda 能讓 Java 程式慢 30 倍”,你怎麼看?

為了讓你清楚地瞭解這個背景,請參考下面的程式碼片段。在實際執行中,基於 Lambda/Stream 的版本(lambdaMaxInteger),比傳統的 for-each 版本(forEachLoopMaxInteger)慢很多。

// 一個大的 ArrayList,內部是隨機的整形資料
volatile List<Integer> integers = …
 
// 基準測試 1
public int forEachLoopMaxInteger() {
   int max = Integer.MIN_VALUE;
   for (Integer n : integers) {
      max = Integer.max(max, n);
   }
   return max;
}
 
// 基準測試 2
public int lambdaMaxInteger() {
   return integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
}

典型回答

我認為,“Lambda 能讓 Java 程式慢 30 倍”這個爭論實際反映了幾個方面:

第一,基準測試是一個非常有效的通用手段,讓我們以直觀、量化的方式,判斷程式在特定條件下的效能表現。

第二,基準測試必須明確定義自身的範圍和目標,否則很有可能產生誤導的結果。前面程式碼片段本身的邏輯就有瑕疵,更多的開銷是源於自動裝箱、拆箱(auto-boxing/unboxing),而不是源自 Lambda 和 Stream,所以得出的初始結論是沒有說服力的。

第三,雖然 Lambda/Stream 為 Java 提供了強大的函數語言程式設計能力,但是也需要正視其侷限性:

  •   一般來說,我們可以認為 Lambda/Stream 提供了與傳統方式接近對等的效能,但是如果對於效能非常敏感,就不能完全忽視它在特定場景的效能差異了,例如:初始化的開銷。 Lambda 並不算是語法糖,而是一種新的工作機制,在首次呼叫時,JVM 需要為其構建CallSite例項。這意味著,如果 Java 應用啟動過程引入了很多 Lambda 語句,會導致啟動過程變慢。其實現特點決定了 JVM 對它的優化可能與傳統方式存在差異。
  •   增加了程式診斷等方面的複雜性,程式棧要複雜很多,Fluent 風格本身也不算是對於除錯非常友好的結構,並且在可檢查異常的處理方面也存在著侷限性等。

 

考點分析

今天的題目是源自於一篇有爭議的文章,原文後來更正為“如果 Stream 使用不當,會讓你的程式碼慢 5 倍”。針對這個問題我給出的回答,並沒有糾結於所謂的“快”與“慢”,而是從工程實踐的角度指出了基準測試本身存在的問題,以及 Lambda 自身的侷限性。

從知識點的角度,這個問題考察了我在專欄第 7 講中介紹過的自動裝箱 / 拆箱機制對效能的影響,並且考察了 Java 8 中引入的 Lambda 特性的相關知識。除了這些知識點,面試官還可能更加深入探討如何用基準測試之類的方法,將含糊的觀點變成可驗證的結論。

對於 Java 語言的很多特性,經常有很多似是而非的 “祕籍”,我們有必要去偽存真,以定量、定性的方式探究真相,探討更加易於推廣的實踐。找到結論的能力,比結論本身更重要,因此在今天這一講中,我們來探討一下:

  •   基準測試的基礎要素,以及如何利用主流框架構建簡單的基準測試。
  •   進一步分析,針對保證基準測試的有效性,如何避免偏離測試目的,如何保證基準測試的正確性。

 

知識擴充套件

首先,我們先來整體瞭解一下基準測試的主要目的和特徵,專欄裡我就不重複那些書面的定義了。

效能往往是特定情景下的評價,泛泛地說效能“好”或者“快”,往往是具有誤導性的。通過引入基準測試,我們可以定義效能對比的明確條件、具體的指標,進而保證得到定量的、可重複的對比資料,這是工程中的實際需要。

不同的基準測試其具體內容和範圍也存在很大的不同。如果是專業的效能工程師,更加熟悉的可能是類似SPEC提供的工業標準的系統級測試;而對於大多數 Java 開發者,更熟悉的則是範圍相對較小、關注點更加細節的微基準測試(Micro-Benchmark)。我在文章開頭提的問題,就是典型的微基準測試,也是我今天的側重點。

 

什麼時候需要開發微基準測試呢?

我認為,當需要對一個大型軟體的某小部分的效能進行評估時,就可以考慮微基準測試。換句話說,微基準測試大多是 API 
級別的驗證,或者與其他簡單用例場景的對比,例如:

  •   你在開發共享類庫,為其他模組提供某種服務的 API 等。
  •   你的 API 對於效能,如延遲、吞吐量有著嚴格的要求,例如,實現了定製的 HTTP 客戶端 API,需要明確它對 HTTP 伺服器進行大量 GET 請求時的吞吐能力,或者需要對比其他 API,保證至少對等甚至更高的效能標準。

所以微基準測試更是偏基礎、底層平臺開發者的需求,當然,也是那些追求極致效能的前沿工程師的最愛。

如何構建自己的微基準測試,選擇什麼樣的框架比較好?

目前應用最為廣泛的框架之一就是JMH,OpenJDK 自身也大量地使用 JMH 進行效能對比,如果你是做 Java API 級別的效能對比,JMH 往往是你的首選。

JMH 是由 Hotspot JVM 團隊專家開發的,除了支援完整的基準測試過程,包括預熱、執行、統計和報告等,還支援 Java 和其他 JVM 語言。更重要的是,它針對 Hotspot JVM 提供了各種特性,以保證基準測試的正確性,整體準確性大大優於其他框架,並且,JMH 還提供了用近乎白盒的方式進行 Profiling 等工作的能力。

使用 JMH 也非常簡單,你可以直接將其依賴加入 Maven 工程,如下圖:


也可以,利用類似下面的命令,直接生成一個 Maven 專案。

$ mvn archetype:generate \
          -DinteractiveMode=false \
          -DarchetypeGroupId=org.openjdk.jmh \
          -DarchetypeArtifactId=jmh-java-benchmark-archetype \
          -DgroupId=org.sample \
          -DartifactId=test \
          -Dversion=1.0

JMH 利用註解(Annotation),定義具體的測試方法,以及基準測試的詳細配置。例如,至少要加上“@Benchmark”以標識它是個基準測試方法,而 BenchmarkMode 則指定了基準測試模式,例如下面例子指定了吞吐量(Throughput)模式,還可以根據需要指定平均時間(AverageTime)等其他模式。

@Benchmark
@BenchmarkMode(Mode.Throughput)
public void testMethod() {
   // Put your benchmark code here.
}

當我們實現了具體的測試後,就可以利用下面的 Maven 命令構建。

mvn clean install

執行基準測試則與執行不同的 Java 應用沒有明顯區別。

java -jar target/benchmarks.jar

更加具體的上手步驟,請參考相關指南。JMH 處處透著濃濃的工程師味道,並沒有糾結於完善的文件,而是提供了非常棒的樣例程式碼,所以你需要習慣於直接從程式碼中學習。

如何保證微基準測試的正確性,有哪些坑需要規避?

首先,構建微基準測試,需要從白盒層面理解程式碼,尤其是具體的效能開銷,不管是 CPU 還是記憶體分配。這有兩個方面的考慮,第一,需要保證我們寫出的基準測試符合測試目的,確實驗證的是我們要覆蓋的功能點,這一講的問題就是個典型例子;第二,通常對於微基準測試,我們通常希望程式碼片段確實是有限的,例如,執行時間如果需要很多毫秒(ms),甚至是秒級,那麼這個有效性就要存疑了,也不便於診斷問題所在。

更加重要的是,由於微基準測試基本上都是體量較小的 API 層面測試,最大的威脅來自於過度“聰明”的 JVM!Brain Goetz 
曾經很早就指出了微基準測試中的典型問題。

由於我們執行的是非常有限的程式碼片段,必須要保證 JVM 優化過程不影響原始測試目的,下面幾個方面需要重點關注:

  •   保證程式碼經過了足夠並且合適的預熱。我在專欄第 1 講中提到過,預設情況,在 server 模式下,JIT 會在一段程式碼執行 10000 次後,將其編譯為原生代碼,client 模式則是 1500 次以後。我們需要排除程式碼執行初期的噪音,保證真正取樣到的統計資料符合其穩定執行狀態。通常建議使用下面的引數來判斷預熱工作到底是經過了多久。
-XX:+PrintCompilation

我這裡建議考慮另外加上一個引數,否則 JVM 將預設開啟後臺編譯,也就是在其他執行緒進行,可能導致輸出的資訊有些混淆。

-Xbatch

與此同時,也要保證預熱階段的程式碼路徑和採集階段的程式碼路徑是一致的,並且可以觀察 PrintCompilation 輸出是否在後期執行中仍然有零星的編譯語句出現。

  •   防止 JVM 進行無效程式碼消除(Dead Code Elimination),例如下面的程式碼片段中,由於我們並沒有使用計算結果 mul,那麼 JVM 就可能直接判斷無效程式碼,根本就不執行它。
public void testMethod() {
   int left = 10;
   int right = 100;
   int mul = left * right;
}

如果你發現程式碼統計資料發生了數量級程度上的提高,需要警惕是否出現了無效程式碼消除的問題。

解決辦法也很直接,儘量保證方法有返回值,而不是 void 方法,或者使用 JMH 提供的BlackHole設施,在方法中新增下面語句。

public void testMethod(Blackhole blackhole) {
   // …
   blackhole.consume(mul);
}
  • 防止發生常量摺疊(Constant,Folding)。JVM 如果發現計算過程是依賴於常量或者事實上的常量,就可能會直接計算其結果,所以基準測試並不能真實反映程式碼執行的效能。JMH 提供了 State 機制來解決這個問題,將本地變數修改為 State 物件資訊,請參考下面示例。
@State(Scope.Thread)
public static class MyState {
   public int left = 10;
   public int right = 100;
}

public void testMethod(MyState state, Blackhole blackhole) {
   int left = state.left;
   int right = state.right;
   int mul = left * right;
   blackhole.consume(mul);
}
  •   另外 JMH 還會對 State 物件進行額外的處理,以儘量消除偽共享(False Sharing)的影響,標記 @State,JMH 會自動進行補齊。
  •   如果你希望確定方法內聯(Inlining)對效能的影響,可以考慮開啟下面的選項。
-XX:+PrintInlining

從上面的總結,可以看出來微基準測試是一個需要高度瞭解 Java、JVM 
底層機制的技術,是個非常好的深入理解程式背後效果的工具,但是也反映了我們需要審慎對待微基準測試,不被可能的假象矇蔽。

我今天介紹的內容是相對常見並易於把握的,對於微基準測試,GC 
等基層機制同樣會影響其統計資料。我在前面提到,微基準測試通常希望執行時間和記憶體分配速率都控制在有限範圍內,而在這個過程中發生 GC,很可能導致資料出現偏差,所以 
Serial GC 是個值得考慮的選項。另外,JDK 11 引入了Epsilon GC,可以考慮使用這種什麼也不做的 GC 方式,從最大可能性去排除相關影響。

今天我從一個爭議性的程式開始,探討了如何從開發者角度而不是效能工程師角度,利用(微)基準測試驗證你在效能上的判斷,並且介紹了其基礎構建方式和需要重點規避的風險點。

 

一課一練

關於今天我們討論的題目你做到心中有數了嗎?我們在專案中需要評估系統的容量,以計劃和保證其業務支撐能力,談談你的思路是怎麼樣的?常用手段有哪些?