函式效能對比工具之JMH
概述
JMH 是一個由 OpenJDK/Oracle 裡面那群開發了 Java 編譯器的大牛們所開發的 Micro Benchmark Framework 。何謂 Micro Benchmark 呢?簡單地說就是在 method 層面上的 benchmark,精度可以精確到微秒級。可以看出 JMH 主要使用在當你已經找出了熱點函式,而需要對熱點函式進行進一步的優化時,就可以使用 JMH 對優化的效果進行定量的分析。
比較典型的使用場景還有:
- 想定量地知道某個函式需要執行多長時間,以及執行時間和輸入 n 的相關性
- 一個函式有兩種不同實現(例如實現 A 使用了 FixedThreadPool,實現 B 使用了 ForkJoinPool),不知道哪種實現效能更好
儘管 JMH 是一個相當不錯的 Micro Benchmark Framework,但很無奈的是網上能夠找到的文件比較少,而官方也沒有提供比較詳細的文件,對使用造成了一定的障礙。但是有個好訊息是官方的 Code Sample 寫得非常淺顯易懂,推薦在需要詳細瞭解 JMH 的用法時可以通讀一遍——本文則會介紹 JMH 最典型的用法和部分常用選項。
第一個例子
如果你使用 maven 來管理你的 Java 專案的話,引入 JMH 是一件很簡單的事情——只需要在 pom.xml
裡增加 JMH 的依賴即可
-
<properties
>
-
<jmh.version>1.14.1
</jmh.version>
-
</properties>
-
-
<dependencies>
-
<dependency
>
-
<groupId>org.openjdk.jmh
</groupId>
-
<artifactId>jmh-core
</artifactId>
-
<version>${jmh.version}
</version>
-
</dependency>
-
<dependency>
-
<groupId>org.openjdk.jmh
</groupId>
-
<artifactId>jmh-generator-annprocess
</artifactId>
-
<version>${jmh.version}
</version>
-
<scope>provided
</scope>
-
</dependency>
-
</dependencies>
-
@BenchmarkMode(Mode.AverageTime)
-
@OutputTimeUnit(TimeUnit.MICROSECONDS)
-
@State(Scope.Thread)
-
public
class FirstBenchmark {
-
-
@Benchmark
-
public int sleepAWhile() {
-
try {
-
Thread.sleep(
500);
-
}
catch (InterruptedException e) {
-
// ignore
-
}
-
return
0;
-
}
-
-
public static void main(String[] args) throws RunnerException {
-
Options opt =
new OptionsBuilder()
-
.include(FirstBenchmark.class.getSimpleName())
-
.forks(
1)
-
.warmupIterations(
5)
-
.measurementIterations(
5)
-
.build();
-
-
new Runner(opt).run();
-
}
-
}
結果:
-
# JMH 1.14.1 (released 39 days ago)
-
# VM version: JDK 1.8.0_11, VM 25.11-b03
-
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_11.jdk/Contents/Home/jre/bin/java
-
# VM options: -Didea.launcher.port=7535 -Didea.launcher.bin.path=/Applications/IntelliJ IDEA 15 CE.app/Contents/bin -Dfile.encoding=UTF-8
-
# Warmup: 5 iterations, 1 s each
-
# Measurement: 5 iterations, 1 s each
-
# Timeout: 10 min per iteration
-
# Threads: 1 thread, will synchronize iterations
-
# Benchmark mode: Average time, time/op
-
# Benchmark: com.dyng.FirstBenchmark.sleepAWhile
-
-
# Run progress: 0.00% complete, ETA 00:00:10
-
# Fork: 1 of 1
-
# Warmup Iteration 1: 503.440 ms/op
-
# Warmup Iteration 2: 503.885 ms/op
-
# Warmup Iteration 3: 503.714 ms/op
-
# Warmup Iteration 4: 504.333 ms/op
-
# Warmup Iteration 5: 502.596 ms/op
-
Iteration
1:
504.352 ms/op
-
Iteration
2:
502.583 ms/op
-
Iteration
3:
501.256 ms/op
-
Iteration
4:
501.655 ms/op
-
Iteration
5:
504.212 ms/op
-
-
Result
"sleepAWhile":
-
502.811 ±(
99.9%)
5.495 ms/op [Average]
-
(min, avg, max) = (
501.256,
502.811,
504.352), stdev =
1.427
-
CI (
99.9%): [
497.316,
508.306] (assumes normal distribution)
-
-
# Run complete. Total time: 00:00:12
-
-
Benchmark Mode Cnt Score Error Units
-
FirstBenchmark.sleepAWhile avgt
5
502.811 ±
5.495 ms/op
對 sleepAWhile()
的測試結果顯示執行時間平均約為502毫秒。因為我們的測試物件 sleepAWhile()
正好就是睡眠500毫秒,所以 JMH 顯示的結果可以說很符合我們的預期。
那好,現在我們再來詳細地解釋程式碼的意義。不過在這之前,需要先了解一下 JMH 的幾個基本概念。
基本概念
Mode
Mode 表示 JMH 進行 Benchmark 時所使用的模式。通常是測量的維度不同,或是測量的方式不同。目前 JMH 共有四種模式:
Throughput
: 整體吞吐量,例如“1秒內可以執行多少次呼叫”。AverageTime
: 呼叫的平均時間,例如“每次呼叫平均耗時xxx毫秒”。SampleTime
: 隨機取樣,最後輸出取樣結果的分佈,例如“99%的呼叫在xxx毫秒以內,99.99%的呼叫在xxx毫秒以內”SingleShotTime
: 以上模式都是預設一次 iteration 是 1s,唯有SingleShotTime
是只執行一次。往往同時把 warmup 次數設為0,用於測試冷啟動時的效能。
Iteration
Iteration 是 JMH 進行測試的最小單位。在大部分模式下,一次 iteration 代表的是一秒,JMH 會在這一秒內不斷呼叫需要 benchmark 的方法,然後根據模式對其取樣,計算吞吐量,計算平均執行時間等。
Warmup
Warmup 是指在實際進行 benchmark 前先進行預熱的行為。為什麼需要預熱?因為 JVM 的 JIT 機制的存在,如果某個函式被呼叫多次之後,JVM 會嘗試將其編譯成為機器碼從而提高執行速度。所以為了讓 benchmark 的結果更加接近真實情況就需要進行預熱。
註解
現在來解釋一下上面例子中使用到的註解,其實很多註解的意義完全可以望文生義 :)
@Benchmark
表示該方法是需要進行 benchmark 的物件,用法和 JUnit 的 @Test
類似。
@Mode
Mode
如之前所說,表示 JMH 進行 Benchmark 時所使用的模式。
@State
State
用於宣告某個類是一個“狀態”,然後接受一個 Scope
引數用來表示該狀態的共享範圍。因為很多 benchmark 會需要一些表示狀態的類,JMH 允許你把這些類以依賴注入的方式注入到 benchmark 函式裡。Scope
主要分為兩種。
Thread
: 該狀態為每個執行緒獨享。Benchmark
: 該狀態在所有執行緒間共享。
關於State
的用法,官方的 code sample 裡有比較好的例子。
@OutputTimeUnit
benchmark 結果所使用的時間單位。
啟動選項
解釋完了註解,再來看看 JMH 在啟動前設定的引數。
-
Options opt =
new OptionsBuilder()
-
.include(FirstBenchmark.
class.getSimpleName())
-
.forks(
1)
-
.warmupIterations(
5)
-
.measurementIterations(
5)
-
.build();
-
-
new Runner(opt).run();
include
benchmark 所在的類的名字,注意這裡是使用正則表示式對所有類進行匹配的。
fork
進行 fork 的次數。如果 fork 數是2的話,則 JMH 會 fork 出兩個程序來進行測試。
warmupIterations
預熱的迭代次數。
measurementIterations
實際測量的迭代次數。
第二個例子
在看過第一個完全只為示範的例子之後,再來看一個有實際意義的例子。
問題:
計算 1 ~ n 之和,比較序列演算法和並行演算法的效率,看 n 在大約多少時並行演算法開始超越序列演算法
首先定義一個表示這兩種實現的介面
-
public
interface Calculator {
-
/**
-
* calculate sum of an integer array
-
* @param numbers
-
* @return
-
*/
-
public long sum(int[] numbers);
-
-
/**
-
* shutdown pool or reclaim any related resources
-
*/
-
public void shutdown();
-
}
由於這兩種演算法的實現不是這篇文章的重點,而且本身並不困難,所以實際程式碼就不贅述了。如果真的感興趣的話,可以看最後的附錄。以下僅說明一下我所指的序列演算法和並行演算法的含義。
- 序列演算法:使用
for-loop
來計算 n 個正整數之和。 - 並行演算法:將所需要計算的 n 個正整數分成 m 份,交給 m 個執行緒分別計算出和以後,再把它們的結果相加。
進行 benchmark 的程式碼如下
-
@BenchmarkMode(Mode.AverageTime)
-
@OutputTimeUnit(TimeUnit.MICROSECONDS)
-
@State(Scope.Benchmark)
-
public
class SecondBenchmark {
-
@Param({
"10000",
"100000",
"1000000"})
-
private
int length;
-
-
private
int[] numbers;
-
private Calculator singleThreadCalc;
-
private Calculator multiThreadCalc;
-
-
public static void main(String[] args) throws RunnerException {
-
Options opt =
new OptionsBuilder()
-
.include(SecondBenchmark.class.getSimpleName())
-
.forks(
2)
-
.warmupIterations(
5)
-
.measurementIterations(
5)
-
.build();
-
-
new Runner(opt).run();
-
}
-
-
@Benchmark
-
public long singleThreadBench() {
-
return singleThreadCalc.sum(numbers);
-
}
-
-
@Benchmark
-
public long multiThreadBench() {
-
return multiThreadCalc.sum(numbers);
-
}
-
-
@Setup
-
public void prepare() {
-
numbers = IntStream.rangeClosed(
1, length).toArray();
-
singleThreadCalc =
new SinglethreadCalculator();
-
multiThreadCalc =
new MultithreadCalculator(Runtime.getRuntime().availableProcessors());
-
}
-
-
@TearDown
-
public void shutdown() {
-
singleThreadCalc.shutdown();
-
multiThreadCalc.shutdown();
-
}
-
}
注意到這裡用到了3個之前沒有使用的註解。
@Param
@Param
可以用來指定某項引數的多種情況。特別適合用來測試一個函式在不同的引數輸入的情況下的效能。
@Setup
@Setup
會在執行 benchmark 之前被執行,正如其名,主要用於初始化。
@TearDown
@TearDown
和 @Setup
相對的,會在所有 benchmark 執行結束以後執行,主要用於資源的回收等。
最後來猜猜看實際結果如何?並行演算法在哪個問題集下能夠超越序列演算法?
我在自己的 mac 上跑下來的結果,總數在10000時並行演算法不如序列演算法,總數達到100000時並行演算法開始和序列演算法接近,總數達到1000000時並行演算法所耗時間約是序列演算法的一半左右。
常用選項
還有一些 JMH 的常用選項沒有提及的,簡單地在此介紹一下
CompilerControl
控制 compiler 的行為,例如強制 inline,不允許編譯等。
Group
可以把多個 benchmark 定義為同一個 group,則它們會被同時執行,主要用於測試多個相互之間存在影響的方法。
Level
用於控制 @Setup
,@TearDown
的呼叫時機,預設是 Level.Trial
,即benchmark開始前和結束後。
Profiler
JMH 支援一些 profiler,可以顯示等待時間和執行時間比,熱點函式等。
延伸閱讀
IDE外掛
IntelliJ 有 JMH 的外掛,提供 benchmark 方法的自動生成等便利功能。
JMH 教程
Jenkov 的 JMH 教程,相比於這篇文章介紹得更為詳細,非常推薦。順便 Jenkov 的其他 Java 教程也非常值得一看。
附錄
程式碼清單
-
public
class SinglethreadCalculator implements Calculator {
-
public long sum(int[] numbers) {
-
long total =
0L;
-
for (
int i : numbers) {
-
total += i;
-
}
-
return total;
-
}
-
-
@Override
-
public void shutdown() {
-
// nothing to do
-
}
-
}
-
-
public
class MultithreadCalculator implements Calculator {
-
private
final
int nThreads;
-
private
final ExecutorService pool;
-
-
public MultithreadCalculator(int nThreads) {
-
this.nThreads = nThreads;
-
this.pool = Executors.newFixedThreadPool(nThreads);
-
}
-
-
private
class SumTask implements Callable<Long> {
-
private
int[] numbers;
-
private
int from;
-
private
int to;
-
-
public SumTask(int[] numbers, int from, int to) {
-
this.numbers = numbers;
-
this.from = from;
-
this.to = to;
-
}
-
-
public Long call() throws Exception {
-
long total =
0L;
-
for (
int i = from; i < to; i++) {
-
total += numbers[i];
-
}
-
return total;
-
}
-
}
-
-
public long sum(int[] numbers) {
-
int chunk = numbers.length / nThreads;
-
-
int from, to;
-
List<SumTask> tasks =
new ArrayList<SumTask>();
-
for (
int i =
1; i <= nThreads; i++) {
-
if (i == nThreads) {
-
from = (i -
1) * chunk;
-
to = numbers.length;
-
}
else {
-
from = (i -
1) * chunk;
-
to = i * chunk;