在上一講中,我介紹了 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](https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/CallSite.html)實例。這意味著,如果 Java 應用啟動過程引入了很多 Lambda 語句,會導致啟動過程變慢。其實現特點決定了 JVM 對它的優化可能與傳統方式存在差異。
* 增加了程序診斷等方面的復雜性,程序棧要復雜很多,Fluent 風格本身也不算是對于調試非常友好的結構,并且在可檢查異常的處理方面也存在著局限性等。
## 考點分析
今天的題目是源自于一篇有爭議的[文章](https://blog.takipi.com/benchmark-how-java-8-lambdas-and-streams-can-make-your-code-5-times-slower/),原文后來更正為“如果 Stream 使用不當,會讓你的代碼慢 5 倍”。針對這個問題我給出的回答,并沒有糾結于所謂的“快”與“慢”,而是從工程實踐的角度指出了基準測試本身存在的問題,以及 Lambda 自身的局限性。
從知識點的角度,這個問題考察了我在[專欄第 7 講](http://time.geekbang.org/column/article/7514)中介紹過的自動裝箱 / 拆箱機制對性能的影響,并且考察了 Java 8 中引入的 Lambda 特性的相關知識。除了這些知識點,面試官還可能更加深入探討如何用基準測試之類的方法,將含糊的觀點變成可驗證的結論。
對于 Java 語言的很多特性,經常有很多似是而非的 “秘籍”,我們有必要去偽存真,以定量、定性的方式探究真相,探討更加易于推廣的實踐。找到結論的能力,比結論本身更重要,因此在今天這一講中,我們來探討一下:
* 基準測試的基礎要素,以及如何利用主流框架構建簡單的基準測試。
* 進一步分析,針對保證基準測試的有效性,如何避免偏離測試目的,如何保證基準測試的正確性。
## 知識擴展
首先,我們先來整體了解一下基準測試的主要目的和特征,專欄里我就不重復那些[書面的定義](https://baike.baidu.com/item/%E5%9F%BA%E5%87%86%E6%B5%8B%E8%AF%95)了。
性能往往是特定情景下的評價,泛泛地說性能“好”或者“快”,往往是具有誤導性的。通過引入基準測試,我們可以定義性能對比的明確條件、具體的指標,進而保證得到**定量的、可重復的**對比數據,這是工程中的實際需要。
不同的基準測試其具體內容和范圍也存在很大的不同。如果是專業的性能工程師,更加熟悉的可能是類似[SPEC](https://www.spec.org/)提供的工業標準的系統級測試;而對于大多數 Java 開發者,更熟悉的則是范圍相對較小、關注點更加細節的微基準測試(Micro-Benchmark)。我在文章開頭提的問題,就是典型的微基準測試,也是我今天的側重點。
**什么時候需要開發微基準測試呢?**
我認為,當需要對一個大型軟件的某小部分的性能進行評估時,就可以考慮微基準測試。換句話說,微基準測試大多是 API 級別的驗證,或者與其他簡單用例場景的對比,例如:
* 你在開發共享類庫,為其他模塊提供某種服務的 API 等。
* 你的 API 對于性能,如延遲、吞吐量有著嚴格的要求,例如,實現了定制的 HTTP 客戶端 API,需要明確它對 HTTP 服務器進行大量 GET 請求時的吞吐能力,或者需要對比其他 API,保證至少對等甚至更高的性能標準。
所以微基準測試更是偏基礎、底層平臺開發者的需求,當然,也是那些追求極致性能的前沿工程師的最愛。
**如何構建自己的微基準測試,選擇什么樣的框架比較好?**
目前應用最為廣泛的框架之一就是[JMH](http://openjdk.java.net/projects/code-tools/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
~~~
更加具體的上手步驟,請參考相關[指南](http://www.baeldung.com/java-microbenchmark-harness)。JMH 處處透著濃濃的工程師味道,并沒有糾結于完善的文檔,而是提供了非常棒的[樣例代碼](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/jmh/samples),所以你需要習慣于直接從代碼中學習。
**如何保證微基準測試的正確性,有哪些坑需要規避?**
首先,構建微基準測試,需要從白盒層面理解代碼,尤其是具體的性能開銷,不管是 CPU 還是內存分配。這有兩個方面的考慮,第一,需要保證我們寫出的基準測試符合測試目的,確實驗證的是我們要覆蓋的功能點,這一講的問題就是個典型例子;第二,通常對于微基準測試,我們通常希望代碼片段確實是有限的,例如,執行時間如果需要很多毫秒(ms),甚至是秒級,那么這個有效性就要存疑了,也不便于診斷問題所在。
更加重要的是,由于微基準測試基本上都是體量較小的 API 層面測試,最大的威脅來自于過度“聰明”的 JVM!Brain Goetz 曾經很早就指出了微基準測試中的[典型問題](https://www.ibm.com/developerworks/java/library/j-jtp02225/)。
由于我們執行的是非常有限的代碼片段,必須要保證 JVM 優化過程不影響原始測試目的,下面幾個方面需要重點關注:
* 保證代碼經過了足夠并且合適的預熱。我在[專欄第 1 講](http://time.geekbang.org/column/article/6845)中提到過,默認情況,在 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](http://hg.openjdk.java.net/code-tools/jmh/file/3769055ad883/jmh-core/src/main/java/org/openjdk/jmh/infra/Blackhole.java)設施,在方法中添加下面語句。
~~~
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](https://blogs.oracle.com/dave/java-contended-annotation-to-help-reduce-false-sharing))的影響,標記 @State,JMH 會自動進行補齊。
* 如果你希望確定方法內聯(Inlining)對性能的影響,可以考慮打開下面的選項。
~~~
-XX:+PrintInlining
~~~
從上面的總結,可以看出來微基準測試是一個需要高度了解 Java、JVM 底層機制的技術,是個非常好的深入理解程序背后效果的工具,但是也反映了我們需要審慎對待微基準測試,不被可能的假象蒙蔽。
我今天介紹的內容是相對常見并易于把握的,對于微基準測試,GC 等基層機制同樣會影響其統計數據。我在前面提到,微基準測試通常希望執行時間和內存分配速率都控制在有限范圍內,而在這個過程中發生 GC,很可能導致數據出現偏差,所以 Serial GC 是個值得考慮的選項。另外,JDK 11 引入了[Epsilon GC](http://openjdk.java.net/jeps/318),可以考慮使用這種什么也不做的 GC 方式,從最大可能性去排除相關影響。
今天我從一個爭議性的程序開始,探討了如何從開發者角度而不是性能工程師角度,利用(微)基準測試驗證你在性能上的判斷,并且介紹了其基礎構建方式和需要重點規避的風險點。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?我們在項目中需要評估系統的容量,以計劃和保證其業務支撐能力,談談你的思路是怎么樣的?常用手段有哪些?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?