使用 JMH(Java Microbenchmark Harness) 进行基准性能测试

2022-12-05, 星期一, 15:55

JavaDev

概念与基本使用

  • Iteration:JMH 进行测试的最小单位,包含一组 invocation
  • Invocation:一次 benchmark 方法调用

使用 @Benchmark 修饰要测试的方法,就像 JUnit 的 @Test 一样。@BenchmarkMode(Mode[]) 指定从哪些维度做测量和统计。

org.openjdk.jmh.annotations.Mode 含义
Mode.Throughput 吞吐量,单位时间内能够执行多少次调用(ops/time
Mode.AverageTime 调用方法的平均耗时(time/op
Mode.SampleTime 持续执行一段时间,按特定频率采样获取方法的执行耗时
Mode.SingleShotTime 仅执行一次,通常用于评估冷启动性能
Mode.All

当 JVM 发现某个方法或代码块运行时执行的特别频繁的时候,就会认为这是 Hot Spot Code(热点代码)。JIT 会对热点代码进行优化,因此实际测试前需要预热。使用 Warmup 注解声明预热方式,常用以下参数:

  • time:每次预热持续的时间
  • timeUnit:时间单位,默认是秒
  • iterations:预热阶段的迭代数

预热阶段不会测量数据,使用 Measurement 描述真正测量阶段的配置,参数及含义与 Warmup 相同。

待测方法需要反复执行多次,但是有一些承载状态的对象是固定不变的。使用 State 注解标记这些对象,其 Scope 属性表示作用范围:

org.openjdk.jmh.annotations.Scope 作用范围
Scope.Benchmark 变量的作用范围是某个基准测试类
Scope.Group 同一个 Group 里的测试方法共享一个变量实例
Scope.Thread 每个 Thread 拥有独立副本

@Setup@TearDown 修饰的方法分别在测试方法运行前后执行,可以用于资源的初始化与释放。这两个注解的 Level 属性标明了方法运行的时机。

org.openjdk.jmh.annotations.Level 含义
Level.Trial 默认,Benchmark 级别
Level.Iteration 每次迭代都会运行
Level.Invocation 每次方法调用都会运行

还有一些配置参数,例如禁止方法内联 @CompilerControl(CompilerControl.Mode.DONT_INLINE) 等,可以参考 jmh - samples | GitHub

案例

某系统需要开发一个基于用户群组的待办事项清单模块,原理和效果类似使用生产者 - 消费者模型的消息队列,使用时需要进行大量的序列化(写)和更大量的反序列化(读)操作。目前使用的方案是 Jackson,亦有考虑使用 Protobuf。

创建性能测试

快速创建一个 JMH 工程:

mvn archetype:generate \
  -DinteractiveMode=false \
  -DarchetypeGroupId=org.openjdk.jmh \
  -DarchetypeArtifactId=jmh-java-benchmark-archetype \
  -DgroupId=cc.ddrpa.showcase \
  -DartifactId=object-serialization-benchmark \
  -Dversion=1.0

创建的测试消息类如下,实际做性能测试时需要根据目标业务设计测试的数据结构,不同方案可能对特定类型的对象有奇效。

public class EventPayloadEntity implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;
    private Long id;
    private String name;
    private String veryLongAndMultiLineDescription;
    private EventStatus status;
    private List<Address> addressBook;
    private LocalDateTime startTime;
    private Map<String, String> dictionary;
}

public enum EventStatus {
    ACTIVE(1),
    INACTIVE(2),
    DELETED(10);
}

public class Address implements Serializable {
    @Serial
    private static final long serialVersionUID = 2L;
    private String province;
    private String city;
    private String street;
}

Jackson 方案通过 ObjectMapper 实现对象和字符串之间的转换。Protobuf 方案需要编写 .proto 定义文件,通过 protoc 生成 Message 和 Builder 类的 Java 代码,编写 Helper 函数实现 POJO 和 Message 间的转换。

使用 mvn clean verify 产生 benchmarks.jar 进行性能测试。

# Detecting actual CPU count: 16 detected
# JMH version: 1.36
# VM version: JDK 17.0.5, OpenJDK 64-Bit Server VM, 17.0.5+8
# VM invoker: C:\Program Files\Eclipse Adoptium\jdk-17.0.5.8-hotspot\bin\java.exe
# VM options: <none>
# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 5 iterations, 3 s each
# Measurement: 5 iterations, 3 s each
# Timeout: 10 min per iteration
# Threads: 16 threads
# Benchmark mode: Throughput, ops/time

Benchmark                                      Mode  Cnt     Score     Error   Units
BenchmarkRunner.jackson                       thrpt   25  3227.269 ± 151.785  ops/ms
BenchmarkRunner.jackson:jacksonDeserialize    thrpt   25  1075.210 ±  28.830  ops/ms
BenchmarkRunner.jackson:jacksonSerialize      thrpt   25  2152.059 ± 152.515  ops/ms
BenchmarkRunner.jdk                           thrpt   25    70.897 ±   3.908  ops/ms
BenchmarkRunner.jdk:jdkDeserialize            thrpt   25    69.811 ±   3.844  ops/ms
BenchmarkRunner.jdk:jdkSerialize              thrpt   25     1.086 ±   0.096  ops/ms
BenchmarkRunner.protobuf                      thrpt   25  6649.438 ± 152.138  ops/ms
BenchmarkRunner.protobuf:protobufDeserialize  thrpt   25  3633.028 ± 113.231  ops/ms
BenchmarkRunner.protobuf:protobufSerialize    thrpt   25  3016.410 ±  97.707  ops/ms

机器在运行时可能会受到多种因素影响,因此测试结果应关注相对值。在本次测试中使用 Protobuf 进行对象的反序列化,性能大约是 Jackson 方案的 3.3 倍。

对这方面有兴趣的话还可以尝试如下方案: