JMH性能測試,試試你代碼的性能如何
最近在研究一些基礎(chǔ)組件實現(xiàn)的時候遇到一個問題,關(guān)于不同技術(shù)的運行性能比對該如何去實現(xiàn)。
什么是性能比對呢?
舉個簡單的栗子🌰 來說:假設(shè)我們需要驗證String,StringBuffer,StringBuilder三者在使用的時候,希望能夠通過一些測試來比對它們的性能開銷。下邊我羅列出最簡單的測試思路:
for循環(huán)比對
這種測試思路的特點:簡單直接
- public class TestStringAppendDemo {
- public static void testStringAdd() {
- long begin = System.currentTimeMillis();
- String item = new String();
- for (int i = 0; i < 100000; i++) {
- itemitem = item + "-";
- }
- long end = System.currentTimeMillis();
- System.out.println("StringBuffer 耗時:" + (end - begin) + "ms");
- }
- public static void testStringBufferAdd() {
- long begin = System.currentTimeMillis();
- StringBuffer item = new StringBuffer();
- for (int i = 0; i < 100000; i++) {
- itemitem = item.append("-");
- }
- long end = System.currentTimeMillis();
- System.out.println("StringBuffer 耗時:" + (end - begin) + "ms");
- }
- public static void testStringBuilderAdd() {
- long begin = System.currentTimeMillis();
- StringBuilder item = new StringBuilder();
- for (int i = 0; i < 100000; i++) {
- itemitem = item.append("-");
- }
- long end = System.currentTimeMillis();
- System.out.println("StringBuilder 耗時:" + (end - begin) + "ms");
- }
- public static void main(String[] args) {
- testStringAdd();
- testStringBufferAdd();
- testStringBuilderAdd();
- }
- }
不知道你在平時工作中是否經(jīng)常會這么做,雖然說通過簡單的for循環(huán)執(zhí)行來看,我們確實能夠較好地給出誰強誰弱的這種結(jié)論,但是比對的結(jié)果并不精準。因為Java程序的運行時有可能會越跑越快的!
代碼越跑越快
看到這里你可能會有些疑惑,Java程序不是在啟動之前都編譯成了統(tǒng)一的字節(jié)碼么,難道在字節(jié)碼翻譯為機器代碼的過程中還有什么不為人知的優(yōu)化處理手段?
下邊我們來觀察這么一段測試程序:
- public static void testStringAdd() {
- long begin = System.currentTimeMillis();
- String item = new String();
- for (int i = 0; i < 100000; i++) {
- itemitem = item + "-";
- }
- long end = System.currentTimeMillis();
- System.out.println("String 耗時:" + (end - begin) + "ms");
- }
- //循環(huán)20次執(zhí)行同一個方法
- public static void main(String[] args) {
- for(int i=0;i<20;i++){
- testStringAdd();
- }
- }
執(zhí)行的程序耗時打印在了控制臺上:
20次的重復調(diào)用之后,發(fā)現(xiàn)首次和最后一次調(diào)用幾乎存在5倍的差異??磥泶a運行越跑越快是存在的了,但是為什么會有這種現(xiàn)象發(fā)生呢?
這里我們需要了解一項叫做JIT的技術(shù)。
JIT技術(shù)
在介紹JIT技術(shù)之前,需要先進行些相關(guān)知識的補充鋪墊。
解釋型語言
解釋型語言,是在運行的時候才將程序翻譯成 機器語言 。解釋型語言的程序不需要在運行前提前做編譯工作,在運行程序的時候才翻譯,解釋器負責在每個語句執(zhí)行的時候解釋程序代碼。這樣解釋型語言每執(zhí)行一次就要“翻譯”一次,效率比較低。代表語言:PHP。
編譯型語言
在程序執(zhí)行之前,提前就將程序編譯成機器代碼,這樣后續(xù)機器在運行的時候就不需要額外去做翻譯的工作,效率會相對較高。語言代表:C,C++。
而我們本文重點研究的是Java語言,我個人認為這是一門既具備解釋特點又具備編譯特點的高級語言。
JVM是Java一次編譯,跨平臺執(zhí)行的基礎(chǔ)。當Java被編譯為字節(jié)碼形式的.class文件之后,他可以在任意的JVM上運行。
PS: 這里說的編譯,主要是指前端編譯器。
前端編譯器
將.java文件編譯為JVM可執(zhí)行的.class字節(jié)碼文件,即javac,主要職責包括:詞法、語法分析,填充符號表,語義分析,字節(jié)碼生成。輸出為字節(jié)碼文件,也可以理解為是中間表達形式(稱為IR:Intermediate Representation)。這時候的編譯結(jié)果就是我們常見的xxx.class文件。
后端編譯器
在程序運行期間將字節(jié)碼轉(zhuǎn)變成機器碼,通過前端編譯器和后端編譯器的組合使用,通常就是被我們稱之為混合模式,如 HotSpot 虛擬機自帶的解釋器還有 JIT(Just In Time Compiler)編譯器(分 Client 端和 Server 端),其中JIT還會將中間表達形式進行一些優(yōu)化。
所以一份xxx.java的文件實際在執(zhí)行過程中會按照如下流程執(zhí)行,首先經(jīng)過前端解釋器轉(zhuǎn)換為.class格式的字節(jié)碼,再通過后端編譯器將其解釋為機器能夠識別的機器代碼。最后再由機器去執(zhí)行計算。
真的就這么簡單嗎?
還記得我在上邊貼出的那段測試代碼嗎,首次執(zhí)行和最后執(zhí)行的性能差異如此巨大,其實是在后端編譯器處理的過程中加入優(yōu)化的手段。
在編譯時,主要是將java源代碼文件編譯為統(tǒng)一的字節(jié)碼,但是編譯成的字節(jié)碼并不能直接運行,而是需要通過JVM讀取運行。JVM中的后端解釋器就是將.class文件一行一行翻譯之后再運行,翻譯就是轉(zhuǎn)換成當前機器可以運行的機器碼,它不會一次性把整個文件都翻譯過來,而是翻譯一句,執(zhí)行一句,再翻譯,再執(zhí)行,所以解釋器的程序運行起來會比較慢,每次都要解釋之后再執(zhí)行。所以有些時候,我們想是否可以把解釋之后的內(nèi)容緩存起來,這樣不就可以直接運行了?但是,如果每段代碼都要緩存起來,例如僅僅執(zhí)行一次的代碼也緩存起來,這樣太浪費內(nèi)存了。所以,引入一個新的運行時編譯器,JIT來解決這些問題,加速熱點代碼的執(zhí)行。
引入JIT技術(shù)之后,代碼的執(zhí)行過程是怎樣的?
在引入了JIT技術(shù)之后,一份Java程序的代碼執(zhí)行流程就會變成了下邊這種類型。首先通過前端編譯器轉(zhuǎn)變?yōu)樽止?jié)碼文件,然后再判斷對應的字節(jié)碼文件是否有被提前處理好存放在code cache中。如果有則可以直接執(zhí)行對應的機器代碼,如果沒有則需要進行判斷是否有必要進行JIT技術(shù)優(yōu)化(判斷邏輯的細節(jié)后邊會講),如果有必要優(yōu)化,則會將優(yōu)化后的機器碼也存放到code cache中,否則則是會一邊執(zhí)行一邊翻譯為機器代碼。
怎樣的代碼才會被識別為熱點代碼呢?
在JVM中會設(shè)置一個閾值,當某段代碼塊在一定時間內(nèi)被執(zhí)行的次數(shù)超過了這個閾值,則會被存放進code cache中。
如何驗證:
建立一個測試用的代碼Demo,然后設(shè)置JVM參數(shù):
-XX:CompileThreshold=500 -XX:+PrintCompilation
- public class TestCountDemo {
- public static void test() {
- int a = 0;
- }
- public static void main(String[] args) throws InterruptedException {
- for (int i = 0; i < 600; i++) {
- test();
- }
- TimeUnit.SECONDS.sleep(1);
- }
- }
接下來專心觀察啟動程序之后的編譯信息記錄:
截圖解釋:
第一列693表示系統(tǒng)啟動到編譯完成時的毫秒數(shù)。
第二列43表示編譯任務(wù)的內(nèi)部ID,一般是一個自增的值。
第三列為空,描述代碼狀態(tài)的5個屬性。
- %:是一個OSR(棧上替換)。
- s:是一個同步方法。
- !:方法有異常處理塊。
- b:阻塞模式編譯。
- n:是本地方法的一個包裝。
第四列3表示編譯級別,0表示沒有編譯而是使用解釋器,1,2,3表示使用C1編譯器(client),4表示使用C2編譯器(server),級別越高編譯生成的機器碼質(zhì)量越好,編譯耗時也越長。
最后一列表示了方法的全限定名和方法的字節(jié)碼長度。
從實驗來看,當for循環(huán)的次數(shù)一旦超過了預期設(shè)置的閾值,則會提前使用后端編譯器將代碼緩存到code cache中。
即時編譯極大地提高了Java程序的運行速度,而且跟靜態(tài)編譯相比,即時編譯器可以選擇性地編譯熱點代碼,省去了很多編譯時間,也節(jié)省很多的空間。目前,即時編譯器已經(jīng)非常成熟了,在性能層面甚至可以和編譯型語言相比。不過在這個領(lǐng)域,大家依然在不斷探索如何結(jié)合不同的編譯方式,使用更加智能的手段來提升程序的運行速度。
還記得我在文章開頭所提出的幾個問題嗎~~既然我們了解了Jvm底層具備了這些優(yōu)化的技能,那么如何才能更加準確高效地去檢測一段程序的性能呢?
基于JMH來實踐代碼基準測試
JMH是Java Microbenchmark Harness的簡稱,一個針對Java做基準測試的工具,是由開發(fā)JVM的那群人開發(fā)的。想準確的對一段代碼做基準性能測試并不容易,因為JVM層面在編譯期、運行時對代碼做很多優(yōu)化,但是當代碼塊處于整個系統(tǒng)中運行時這些優(yōu)化并不一定會生效,從而產(chǎn)生錯誤的基準測試結(jié)果,而這個問題就是JMH要解決的。
關(guān)于如何使用JMH在網(wǎng)上有很多的講解案例,這些入門的資料大家可以自行去搜索。本文主要講解在使用JMH測試的時候需要注意到的一些細節(jié)點:
常用的基本注解以及其具體含義
一般我們會將測試所使用的注解都標注在測試類的頭部,常用到的測試注解有以下幾種:
- /**
- * 吞吐量測試 可以獲取到指定時間內(nèi)的吞吐量
- *
- * Throughput 可以獲取一秒內(nèi)可以執(zhí)行多少次調(diào)用
- * AverageTime 可以獲取每次調(diào)用所消耗的平均時間
- * SampleTime 隨機抽樣,隨機抽取結(jié)果的分布,最終是99%%的請求在xx秒內(nèi)
- * SingleShotTime 只允許一次,一般用于測試冷啟動的性能
- */
- @BenchmarkMode(Mode.Throughput)
- /**
- * 如果一段程序被調(diào)用了好幾次,那么機器就會對其進行預熱操作,
- * 為什么需要預熱?因為 JVM 的 JIT 機制的存在,如果某個函數(shù)被調(diào)用多次之后,JVM 會嘗試將其編譯成為機器碼從而提高執(zhí)行速度。所以為了讓 benchmark 的結(jié)果更加接近真實情況就需要進行預熱。
- */
- @Warmup(iterations = 3)
- /**
- * iterations 每次測試的輪次
- * time 每輪進行的時間長度
- * timeUnit 時長單位
- */
- @Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS)
- /**
- * 測試的線程數(shù),一般是cpu*2
- */
- @Threads(8)
- /**
- * fork多少個進程出來測試
- */
- @Fork(2)
- /**
- * 這個比較簡單了,基準測試結(jié)果的時間類型。一般選擇秒、毫秒、微秒。
- */
- @OutputTimeUnit(TimeUnit.MILLISECONDS)
如果不喜歡使用注解的方式也可以通過在啟動入口中通過硬編碼的形式設(shè)置:
- public static void main(String[] args) throws RunnerException {
- //配置進行2輪熱數(shù) 測試2輪 1個線程
- //預熱的原因 是JVM在代碼執(zhí)行多次會有優(yōu)化
- Options options = new OptionsBuilder().warmupIterations(2).measurementBatchSize(2)
- .forks(1).build();
- new Runner(options).run();
- }
如果要對某項方法進行JMH測試的話,通常會對該方法的頭部加入@Benchmark注解。例如下邊這段:
- @Benchmark
- public String testJdkProxy() throws Throwable {
- String content = dataService.sendData("test");
- return content;
- }
JMH的一些坑
所有方法都應該要有返回值
例如這么一段測試案例:
- package org.idea.qiyu.framework.jmh.demo;
- import org.openjdk.jmh.annotations.*;
- import org.openjdk.jmh.runner.Runner;
- import org.openjdk.jmh.runner.RunnerException;
- import org.openjdk.jmh.runner.options.Options;
- import org.openjdk.jmh.runner.options.OptionsBuilder;
- import java.util.concurrent.TimeUnit;
- import static org.openjdk.jmh.annotations.Mode.AverageTime;
- import static org.openjdk.jmh.annotations.Mode.Throughput;
- /**
- * JMH基準測試
- */
- @BenchmarkMode(Throughput)
- @Fork(2)
- @Warmup(iterations = 4)
- @Threads(4)
- @OutputTimeUnit(TimeUnit.MILLISECONDS)
- public class JMHHelloWord {
- @Benchmark
- public void baseMethod() {
- }
- @Benchmark
- public void measureWrong() {
- String item = "";
- itemitem = item + "s";
- }
- @Benchmark
- public String measureRight() {
- String item = "";
- itemitem = item + "s";
- return item;
- }
- public static void main(String[] args) throws RunnerException {
- Options options = new OptionsBuilder().
- include(JMHHelloWord.class.getName()).
- build();
- new Runner(options).run();
- }
- }
其實baseMethod和measureWrong兩個方法從代碼功能角度看來,并沒有什么區(qū)別,因為調(diào)用它們兩者對于調(diào)用方本身并沒有造成什么影響,而且measureWrong函數(shù)中還存在著無用代碼塊,所以JMH會對內(nèi)部的代碼進行“死碼消除”的處理。
通過測試會發(fā)現(xiàn),其實baseMethod和measureWrong的吞吐性結(jié)果差別不大。反而再比對measureWrong和measureRight兩個方法,后者只是加入了一個return關(guān)鍵字,JMH就能很好地去測算它的整體性能。
關(guān)于什么是“死碼消除”,我在這里貼出一段維基百科上的介紹,感興趣的讀者可以自行前往閱讀:
https://zh.wikipedia.org/wiki/%E6%AD%BB%E7%A2%BC%E5%88%AA%E9%99%A4
不要在Benchmark內(nèi)部加入循環(huán)的代碼
關(guān)于這一點我們可以通過一段案例來進行測試,代碼如下:
- package org.idea.qiyu.framework.jmh.demo;
- import org.openjdk.jmh.annotations.*;
- import org.openjdk.jmh.runner.Runner;
- import org.openjdk.jmh.runner.RunnerException;
- import org.openjdk.jmh.runner.options.Options;
- import org.openjdk.jmh.runner.options.OptionsBuilder;
- import java.util.concurrent.TimeUnit;
- /**
- * @Author linhao
- * @Date created in 10:20 上午 2021/12/19
- */
- @BenchmarkMode(Mode.AverageTime)
- @Fork(1)
- @Threads(4)
- @Warmup(iterations = 1)
- @OutputTimeUnit(TimeUnit.MILLISECONDS)
- public class ForLoopDemo {
- public int reps(int count) {
- int sum = 0;
- for (int i = 0; i < count; i++) {
- sumsum = sum + count;
- }
- return sum;
- }
- @Benchmark
- @OperationsPerInvocation(1)
- public int test_1() {
- return reps(1);
- }
- @Benchmark
- @OperationsPerInvocation(10)
- public int test_2() {
- return reps(10);
- }
- @Benchmark
- @OperationsPerInvocation(100)
- public int test_3() {
- return reps(100);
- }
- @Benchmark
- @OperationsPerInvocation(1000)
- public int test_4() {
- return reps(1000);
- }
- @Benchmark
- @OperationsPerInvocation(10000)
- public int test_5() {
- return reps(10000);
- }
- @Benchmark
- @OperationsPerInvocation(100000)
- public int test_6() {
- return reps(100000);
- }
- public static void main(String[] args) throws RunnerException {
- Options options = new OptionsBuilder()
- .include(ForLoopDemo.class.getName())
- .build();
- new Runner(options).run();
- }
- }
測試出來的結(jié)果顯示:
循環(huán)越多,反而得分越低,這一結(jié)果反而越來越不可信。
關(guān)于為什么在Benchmark中跑循環(huán)代碼會出現(xiàn)這類不可信的情況,我在網(wǎng)上搜了一下技術(shù)文章,大致歸納為以下:
- 循環(huán)展開
- JIT & OSR 對循環(huán)的優(yōu)化
感興趣的朋友可以自行去深入了解,這里我就不做過多介紹了。
通過這個實驗可以發(fā)現(xiàn),以后進行Benchmark的性能測試過程中,盡量能不跑循環(huán)就不要跑循環(huán),如果真的要跑循環(huán),可以看下官方的這個用例:
Fork注解中的進程數(shù)一定要大于0
這個是我通過實驗發(fā)現(xiàn)的,如果設(shè)置為小于0的參數(shù)會發(fā)現(xiàn)跑出來的效果和預期的大大相反,具體原因還不太清楚。
測試結(jié)果報告的參數(shù)解釋
最后是關(guān)于如何閱讀JMH的測試報告,這里的這份報告是上邊講解的代碼案例中的測試結(jié)果。由于報告的內(nèi)容量比較大,所以這里只挑報告的結(jié)果來進行講解:
- Benchmark Mode Cnt Score Error Units
- JMHHelloWord.baseMethod thrpt 10 14343234.962 ± 585752.043 ops/ms
- JMHHelloWord.measureRight thrpt 10 260749.234 ± 5324.982 ops/ms
- JMHHelloWord.measureWrong thrpt 10 524449.863 ± 8330.106 ops/ms
從報告的左往右開始介紹起:
- Benchmark 就是對應的測試方法。
- Mode 測試的模式。
- Cnt 循環(huán)了多少次。
- Score 是指測試的得分,這里因為選擇了以thrpt的模式進行測試,所以分值越高表示吞吐率越高。
- Error 代表并不是表示執(zhí)行用例過程中出現(xiàn)了多少異常,而是指這個Score的精度可能存在誤差,所以前邊還有個± 的符號。
關(guān)于Error的解釋,在stackoverflow中也有解釋:
https://codereview.stackexchange.com/questions/90886/jmh-benchmark-metrics-evaluation
如果你希望報告不是輸出在控制臺,而是可以匯總到一份文檔中,可以通過啟動指令去設(shè)置,例如:
- public static void main(String[] args) throws RunnerException {
- Options options = new OptionsBuilder()
- .include(StringBuilderBenchmark.class.getSimpleName())
- .output("/Users/linhao/IdeaProjects/qiyu-framework-gitee/qiyu-framework/qiyu-framework-jmh/log/test.log")
- .build();
- new Runner(options).run();
- }