Java 效能測試難題
現在的 JVM 已經越來越為智慧,它可以在編譯階段、載入階段、執行階段對程式碼進行優化。比如你寫了一段不怎麼聰明的程式碼,到了 JVM 這裡,它發現幾處可以優化的地方,就順手幫你優化了一把。這對程式的執行固然美妙,卻讓開發者不能準確瞭解程式的執行情況。在需要進行效能測試時,如果不知道 JVM 優化細節,可能會導致你的測試結果差之毫釐,失之千里,同樣的,Java 誕生之初就有一次編譯、隨處執行的口號,JVM 提供了底層支援,也提供了記憶體管理機制,這些機制都會對我們的效能測試結果造成不可預測的影響。
long start = System.currentTimeMillis();
// ....
long end = System.currentTimeMillis();
System.out.println(end - start);
上面可能就是你最常見的效能測試了,這樣的測試結果真的準確嗎?答案是否定的,它有下面幾個問題。
- 時間精度問題,本身獲取到的時間戳就是存在誤差的,它和作業系統有關。
- JVM 在執行時會進行程式碼預熱,說白了就是越跑越快。因為類需要裝載、需要準備操作。
- JVM 會在各個階段都有可能對你的程式碼進行優化處理。
- 資源回收的不確定性,可能執行很快,回收很慢。
帶著這些問題,突然發現進行一次嚴格的基準測試的難度大大增加。那麼如何才能進行一次嚴格的基準測試呢?
JMH 介紹
那麼如何對 Java 程式進行一次精準的效能測試呢?難道需要掌握很多 JVM 優化細節嗎?難道要研究如何避免,並進行正確編碼才能進行嚴格的效能測試嗎?顯然不是,如果是這樣的話,未免過於困難了,好在有一款一款官方的微基準測試工具 - JMH.
JMH 的全名是 Java Microbenchmark Harness,它是由 Java 虛擬機器團隊開發的一款用於 Java 微基準測試工具。用自己開發的工具測試自己開發的另一款工具,以子之矛,攻子之盾果真手到擒來,如臂使指。使用 JMH 可以讓你方便快速的進行一次嚴格的程式碼基準測試,並且有多種測試模式,多種測試維度可供選擇;而且使用簡單、增加註解便可啟動測試。
JMH 使用
JMH 的使用首先引入 maven 所需依賴,當前最新版 為 1.23 版本。
<!--jmh 基準測試 -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.23</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.23</version>
<scope>provided</scope>
</dependency>
快速測試
下面使用註解的方式指定測試引數,通過一個例子展示 JMH 基準測試的具體用法,先看一次執行效果,然後再瞭解每個註解的具體含義。
這個例子是使用 JMH 測試,使用加號拼接字串和使用 StringBuilder
的 append
方法拼接字串時的速度如何,每次拼接1000個數字進行平均速度比較。
import java.util.concurrent.TimeUnit;
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;
/**
* <p>
* JMH 基準測試入門
*
* @author niujinpeng
* @Date 2020/8/21 1:13
*/
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
public class JmhHello {
String string = "";
StringBuilder stringBuilder = new StringBuilder();
@Benchmark
public String stringAdd() {
for (int i = 0; i < 1000; i++) {
string = string + i;
}
return string;
}
@Benchmark
public String stringBuilderAppend() {
for (int i = 0; i < 1000; i++) {
stringBuilder.append(i);
}
return stringBuilder.toString();
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JmhHello.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
程式碼很簡單,不做解釋,stringAdd
使用加號拼接字串 1000次,stringBuilderAppend
使用 append
拼接字串 1000次。直接執行 main 方法,稍等片刻後可以得到詳細的執行輸出結果。
// 開始測試 stringAdd 方法
# JMH version: 1.23
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: D:\develop\Java\jdk8_181\jre\bin\java.exe
# VM options: -javaagent:C:\ideaIU-2020.1.3.win\lib\idea_rt.jar=50363:C:\ideaIU-2020.1.3.win\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 10 s each // 預熱執行三次
# Measurement: 5 iterations, 10 s each // 效能測試5次
# Timeout: 10 min per iteration // 超時時間10分鐘
# Threads: 1 thread, will synchronize iterations // 執行緒數量為1
# Benchmark mode: Average time, time/op // 統計方法呼叫一次的平均時間
# Benchmark: net.codingme.jmh.JmhHello.stringAdd // 本次執行的方法
# Run progress: 0.00% complete, ETA 00:02:40
# Fork: 1 of 1
# Warmup Iteration 1: 95.153 ms/op // 第一次預熱,耗時95ms
# Warmup Iteration 2: 108.927 ms/op // 第二次預熱,耗時108ms
# Warmup Iteration 3: 167.760 ms/op // 第三次預熱,耗時167ms
Iteration 1: 198.897 ms/op // 執行五次耗時度量
Iteration 2: 243.437 ms/op
Iteration 3: 271.171 ms/op
Iteration 4: 295.636 ms/op
Iteration 5: 327.822 ms/op
Result "net.codingme.jmh.JmhHello.stringAdd":
267.393 ±(99.9%) 189.907 ms/op [Average]
(min, avg, max) = (198.897, 267.393, 327.822), stdev = 49.318 // 執行的最小、平均、最大、誤差值
CI (99.9%): [77.486, 457.299] (assumes normal distribution)
// 開始測試 stringBuilderAppend 方法
# Benchmark: net.codingme.jmh.JmhHello.stringBuilderAppend
# Run progress: 50.00% complete, ETA 00:01:21
# Fork: 1 of 1
# Warmup Iteration 1: 1.872 ms/op
# Warmup Iteration 2: 4.491 ms/op
# Warmup Iteration 3: 5.866 ms/op
Iteration 1: 6.936 ms/op
Iteration 2: 8.465 ms/op
Iteration 3: 8.925 ms/op
Iteration 4: 9.766 ms/op
Iteration 5: 10.143 ms/op
Result "net.codingme.jmh.JmhHello.stringBuilderAppend":
8.847 ±(99.9%) 4.844 ms/op [Average]
(min, avg, max) = (6.936, 8.847, 10.143), stdev = 1.258
CI (99.9%): [4.003, 13.691] (assumes normal distribution)
# Run complete. Total time: 00:02:42
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.
// 測試結果對比
Benchmark Mode Cnt Score Error Units
JmhHello.stringAdd avgt 5 267.393 ± 189.907 ms/op
JmhHello.stringBuilderAppend avgt 5 8.847 ± 4.844 ms/op
Process finished with exit code 0
上面日誌裡的 //
註釋是我手動增加上去的,其實我們只需要看下面的最終結果就可以了,可以看到 stringAdd
方法平均耗時 267.393ms,而 stringBuilderAppend
方法平均耗時只有 8.847ms,可見 StringBuilder
的 append
方法進行字串拼接速度快的多,這也是我們推薦使用 append
進行字串拼接的原因。
註解說明
經過上面的示例,想必你也可以快速的使用 JMH 進行基準測試了,不過上面的諸多註解你可能還有疑惑,下面一一介紹。
類上使用了六個註解。
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@BenchmarkMode(Mode.AverageTime) 表示統計平均響應時間,不僅可以用在類上,也可用在測試方法上。
除此之外還可以取值:
- Throughput:統計單位時間內可以對方法測試多少次。
- SampleTime:統計每個響應時間範圍內的響應次數,比如 0-1ms,3次;1-2ms,5次。
- SingleShotTime:跳過預熱階段,直接進行一次****微基準測試。
@State(Scope.Thread):每個進行基準測試的執行緒都會獨享一個物件示例。
除此之外還能取值:
- Benchmark:多執行緒共享一個示例。
- Group:執行緒組共享一個示例,在測試方法上使用 @Group 設定執行緒組。
@Fork(1):表示開啟一個執行緒進行測試。
**OutputTimeUnit(TimeUnit.MILLISECONDS):輸出的時間單位,這裡寫的是毫秒。
@Warmup(iterations = 3):微基準測試前進行三次預熱執行,也可用在測試方法上。
@Measurement(iterations = 5):進行 5 次微基準測試,也可用在測試方法上。
在兩個測試方法上只使用了一個註解 @Benchmark,這個註解表示這個方法是要進行基準測試的方法,它類似於 Junit 中的 @Test 註解。上面還提到某些註解還可以用到測試方法上,也就是使用了 @Benchmark 的方法之上,如果類上和測試方法同時存在註解,會以方法上的註解為準。
其實 JMH 也可以把這些引數直接在 main 方法中指定,這時 main 方法中指定的級別最高。
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JmhHello.class.getSimpleName())
.forks(1)
.warmupIterations(5)
.measurementIterations(10)
.build();
new Runner(opt).run();
}
正確的微基準測試
如果編寫的程式碼本身就存在著諸多問題,那麼即使使用正確的測試方法,也不可能得到正確的測試結果。這些測試程式碼中的問題應該由我們進行主動避免,那麼有哪些常見問題呢?下面介紹兩種最常見的情況。
無用程式碼消除 ( Dead Code Elimination )
也有網友形象的翻譯成死程式碼,死程式碼是指那些 JVM 經過檢查發現的根本不會使用到的程式碼。比如下面這個程式碼片段。
import java.util.concurrent.TimeUnit;
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;
/**
* <p>
* 測試死程式碼消除
*
* @author niujinpeng
* @Date 2020/8/21 8:04
*/
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class JmhDCE {
@Benchmark
public double test1() {
return Math.log(Math.PI);
}
@Benchmark
public void test2() {
double result = Math.log(Math.PI);
result = Math.log(result);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JmhDCE.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
在這個程式碼片段裡裡,test1
方法對圓周率進行對數計算,並返回計算結果;而 test2
中不僅對圓周率進行對數計算,還對計算的結果再次對數計算,看起來複雜一些,但是因為沒有用到計算結果,所以 JVM 會自動消除這段程式碼, 因為它沒有任何意義。
Benchmark Mode Cnt Score Error Units
JmhDCE.test1 avgt 5 0.002 ± 0.001 us/op
JmhDCE.test2 avgt 5 ≈ 10⁻⁴ us/op
測試結果裡也可以看到 test
平均耗時 0.0004 微秒,而 test1
平均耗時 0.002 微秒。
常量摺疊 (Constant Folding)
在對 Java 原始檔編譯的過程中,編譯器通過語法分析,可以發現某些能直接得到計算結果而不會再次更改的程式碼,然後會將計算結果記錄下來,這樣在執行的過程中就不需要再次運算了。比如這段程式碼。
import java.util.concurrent.TimeUnit;
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;
/**
* <p>
* 測試常量摺疊
*
* @author niujinpeng
* @Date 2020/8/21 8:23
*/
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class JmhConstantFolding {
final double PI1 = 3.14159265358979323846;
double PI2 = 3.14159265358979323846;
@Benchmark
public double test1() {
return Math.log(PI1) * Math.log(PI1);
}
@Benchmark
public double test2() {
return Math.log(PI2) * Math.log(PI2);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder().include(JmhConstantFolding.class.getSimpleName()).build();
new Runner(opt).run();
}
}
test
1 中使用 final
修飾的 PI1 進行物件計算,因為 PI1 不能再次更改,所以 test1
的計算結果必定是不會更改的,所以 JVM 會進行常量摺疊優化,而 test2
使用的 PI2
可能會被修改,所以只能每次進行計算。
Benchmark Mode Cnt Score Error Units
JmhConstantFolding.test1 avgt 5 0.002 ± 0.001 us/op
JmhConstantFolding.test2 avgt 5 0.019 ± 0.001 us/op
可以看到 test2
耗時要多的多,達到了 0.019 微秒。
其實 JVM 做的優化操作遠不止上面這些,還有比如常量傳播(Constant Propagation)、迴圈展開(Loop Unwinding)、迴圈表示式外提(Loop Expression Hoisting)、消除公共子表示式(Common Subexpression Elimination)、本塊重排序(Basic Block Reordering)、範圍檢查消除(Range Check Elimination)等。
總結
JMH 進行基準測試的使用過程並不複雜,同為 Java 虛擬機器團隊開發,準確性毋容置疑。但是在進行基準測試時還是要注意自己的程式碼問題,如果編寫的要進行測試的程式碼本身存在問題,那麼測試的結果必定是不準的。掌握了 JMH 基準測試之後,可以嘗試測試一些常用的工具或者框架的效能如何,看看哪個工具的效能最好,比如 FastJSON 真的比 GSON 在進行 JSON 轉換時更 Fast 嗎?
參考:
-
https://www.ibm.com/developerworks/cn/java/j-benchmark1.html
-
深入理解Java虛擬機器:JVM高階特性與最佳實踐(第3版)第11章 後端編譯與優化
最後的話
文章已經收錄在 Github.com/niumoo/JavaNotes ,歡迎Star和指教。更有一線大廠面試點,Java程式設計師需要掌握的核心知識等文章,也整理了很多我的文字,歡迎 Star 和完善,希望我們一起變得優秀。
文章有幫助可以點個「贊」或「分享」,都是支援,我都喜歡!
文章每週持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀程式碼 」公眾號或者我的部落格。