一、介绍
JMH全称“Java Microbenchmark Harness(微基准测试框架)”,是专门用于Java代码微基准测试的一套测试工具API,是由OpenJDK/Oracle官方发布的工具。
二、快速使用
在pom.xml文件中引入下述依赖:
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.33</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.33</version>
<scope>provided</scope>
</dependency>
构建基准测试的“HelloWorld”示例,用于测试String与StringBuffer拼接字符串的吞吐量:
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode({Mode.Throughput})
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 1)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(1)
public class HelloWorld {
@Benchmark
public void measureBuffer() {
String[] helloWorld = {"h", "e", "l", "l", "o", "W", "o", "r", "l", "d"};
StringBuffer stringBuffer = new StringBuffer();
for (int i = 0; i < helloWorld.length; i++) {
stringBuffer.append(helloWorld[i]);
}
}
@Benchmark
public void measureGeneral() {
String[] helloWorld = {"h", "e", "l", "l", "o", "W", "o", "r", "l", "d"};
String data = "";
for (int i = 0; i < helloWorld.length; i++) {
data = data + helloWorld[i];
}
}
public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(HelloWorld.class.getSimpleName())
.resultFormat(ResultFormatType.CSV)
.build();
new Runner(opts).run();
}
}
执行上述入门示例的main方法,其输出结果如下:
可以看到StringBuffer拼接字符串的吞吐量要远比String拼接字符串的吞吐量大很多,因此在使用上关于字符串拼接优先使用StringBuffer,并且它是线程安全的。
三、注解介绍
3.1 @BenchmarkMode
该注解一般作用在公共类上,其取值是一个列表,其配置示例为“@BenchmarkMode({Mode.Throughput, Mode.AverageTime})”,共有下述五种取值:
Mode.Throughput:表示测试的是吞吐量。
Mode.AverageTime:表示测试的是平均时间,即每次调用平均耗时。
Mode.SampleTime:表示测试的是随机取样(具体这个的作用是什么,目前我还不知道)
Mode.SingleShotTime:顾名思义,只运行一次,一般用于测试冷启动性能。
Mode.All:表示的是将前面这四种类型全部进行测试
3.2 @OutputTimeUnit
该注解表示测试结果的时间单位,其配置示例为“@OutputTimeUnit(TimeUnit.SECONDS)”。
- TimeUnit.MILLISECONDS: 表示毫秒
- TimeUnit.SECONDS:表示秒
3.3 @Benchmark
该注解用于标识需要进行基准测试的方法,类比JUnit的@Test。这个注解告诉JMH,被注解的方法是需要进行性能测试的目标。通过使用@Benchmark注解,开发者可以指定哪些方法需要进行性能测试,从而帮助评估代码的性能并进行优化。
3.4 @Fork
如果@Fork注解的value值为3的话,则JMH会fork出3组进程来进行测试。
3.5 @Warmup
该注解表示“预热”阶段的配置,注解中iterations属性的值为1则表示在每一组进程中会进行1轮的预热。注解中time属性的值为2,timeUnit属性的值为TimeUnit.SECONDS,则表示每一轮预热处理的总时间为2秒。
3.6 @Measurement
该注解表示“压测”阶段的配置,注解中iterations属性的值为1则表示在每一组进程中会进行1轮的压测。注解中time属性的值为2,timeUnit属性的值为TimeUnit.SECONDS,则表示每一轮压测的总时间为2秒。
3.7 @Threads
该注解表示在每一轮“预热”和“压测”处理中的线程数设置有多少个。
3.8 @State
该标签用于指定一个属性共享的范围(了解这个标签前,先看完下面的文章,需要对基准测试有一个大概的认识)
3.8.1 基准测试内线程内共享
在JMH类中定义如下这个属性(静态内部类):
@State(Scope.Thread)
public static class ThreadState {
volatile double k = 1;
}
然后再对应的Benchmark方法当中使用:
@Benchmark
public void measureUnshared(ThreadState state) throws InterruptedException {
state.k++;
Thread.sleep(500);
System.out.println("thread name = " + Thread.currentThread().getName() + " ,state= " + state.hashCode() + " ,k " +
"= " + state.k);
}
基准方法可以引用状态,并且JMH将在调用这些方法时注入适当的状态。 在上面的基准方法当中就包含了一个状态,在这个方法运行时,JMH会自动注入这个对象。
开启4个线程来测试,对应的主类方法如下:
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode({Mode.Throughput})
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 1)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(4)
public class HelloWorld {
@State(Scope.Thread)
public static class ThreadState {
volatile double k = 1;
}
@Benchmark
public void measureUnshared(ThreadState state) throws InterruptedException {
state.k++;
Thread.sleep(500);
System.out.println("thread name = " + Thread.currentThread().getName() + " ,state= " + state.hashCode() + " ,k " +
"= " + state.k);
}
public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(HelloWorld.class.getSimpleName())
.resultFormat(ResultFormatType.CSV)
.build();
new Runner(opts).run();
}
}
以下为第一轮预热的结果:
从这个结果可以看出,JMH使用了四个线程,这个四个线程使用的state对象都是不同的(从hashCode不同可以看),每个都有自己的独一份,也就是说通过 @State(Scope.Thread) 将这个对象限定为线程内部共享了。在上面同一个线程中,下一轮测试会使用上面的值,比如以下为预热完成之后,真正开始测试的第一轮结果。
从这个结果可以看出,Measurement阶段与Warmup阶段使用的线程相同,线程内的对象也是同一个对象,对象内的变量被共享(k的值持续增加)。
3.8.2 基准测试内线程间共享
如果想一个变量在同一个基础测试内被所有线程共享,可定义对象如下:
@State(Scope.Benchmark)
public static class ThreadState {
volatile double k = 1;
}
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode({Mode.Throughput})
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 1)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(4)
public class HelloWorld {
@State(Scope.Benchmark)
public static class ThreadState {
volatile double k = 1;
}
@Benchmark
public void measureShared(ThreadState state) throws InterruptedException {
state.k++;
Thread.sleep(1000);
System.out.println("thread name = " + Thread.currentThread().getName() + " ,state= " + state.hashCode() + " ,k " +
"= " + state.k);
}
public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(HelloWorld.class.getSimpleName())
.resultFormat(ResultFormatType.CSV)
.build();
new Runner(opts).run();
}
}
从这里可以看出两个结论:第一个就是不同的线程之间确实是共享了同一个对象,但是共享对象中的变量值看起来好像不对(总量是对的,但是单次取的值不对)。目前这个小问题,不是重点,重点是了解其相关注解的作用。
四、吞吐量测试(推荐使用)
Mode.AverageTime 表示测量吞吐量,即单位时间内可以运行多少次。
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode({Mode.Throughput})
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(value = 1)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(1)
public class HelloWorld {
@Benchmark
public void measureThroughput() {
String[] helloWorld = {"h", "e", "l", "l", "o", "W", "o", "r", "l", "d"};
String data = "";
for (int j = 0; j < helloWorld.length; j++) {
data = data + helloWorld[j];
}
}
public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(HelloWorld.class.getSimpleName())
.resultFormat(ResultFormatType.CSV)
.build();
new Runner(opts).run();
}
}
运行的时候会在控制台打印结果,下面对测试结果做简要分析。
以下为预热的吞吐量和测试的吞吐量,其中的ops/s表示每秒操作次数:
五、平均耗时测试
Mode.AverageTime 表示测量平均执行时间,它的执行方式类似于 Mode.Throughput。有人可能会说这是吞吐量的倒数,的确如此,在某些工作负载中,测量时间更方便。
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode({Mode.AverageTime})
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(value = 1)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(1)
public class HelloWorld {
@Benchmark
public void measureAvgTime() {
String[] helloWorld = {"h", "e", "l", "l", "o", "W", "o", "r", "l", "d"};
String data = "";
for (int i = 1; i <= 5000; i++) {
for (int j = 0; j < helloWorld.length; j++) {
data = data + helloWorld[j];
}
}
}
public static void main(String[] args) throws Exception {
Options opts = new OptionsBuilder()
.include(HelloWorld.class.getSimpleName())
.resultFormat(ResultFormatType.CSV)
.build();
new Runner(opts).run();
}
}
执行上述代码,在输出结果中可以看到其平均耗时在200ms左右:
特别说明:JMH基准测试的时间和实际应用程序中日志打印的时间不一样,主要是因为JMH专注于测量代码本身的执行时间,而忽略了日志打印等外部开销,同时还会受到CPU调度和外部系统响应时间的影响。