写点什么

Java 性能测试利器:JMH 入门与实践|得物技术

作者:得物技术
  • 2024-11-21
    上海
  • 本文字数:14013 字

    阅读完需:约 46 分钟

在软件开发中,性能测试是不可或缺的一环。但是编写基准测试来正确衡量大型应用程序的一小部分的性能却又非常困难。当基准测试单独执行组件时,JVM 或底层硬件可能会对您的组件应用许多优化。当组件作为大型应用程序的一部分运行时,这些优化可能无法应用。因此,实施不当的微基准测试可能会让您相信组件的性能比实际情况更好。编写正确的 Java 微基准测试通常需要防止 JVM 和硬件在微基准测试执行期间应用的优化,而这些优化在实际生产系统中是无法应用的。这就是 JMH(Java 微基准测试工具)可以帮助您实现的功能。这篇文章我会全面给大家介绍下 JMH 的各个方面。


一、JMH 概述


JMH 是一个用于微基准测试的 Java 库,它允许开发者对代码的热点进行精确的性能测试。JMH 由 OpenJDK 团队开发,是 Java 性能测试领域的事实标准。


JMH 的主要特点:

  1. 高精度:支持纳秒级别的性能测试。

  2. 易用性:通过注解配置测试,无需复杂的测试环境搭建。

  3. 多模式测试:支持多种测试模式,如吞吐量、平均时间等。

  4. 多维度测试:可以测试代码在不同条件下的性能表现。


JMH 与其他性能测试工具的比较:

与 JVM 其他性能测试工具相比,JMH 提供了更细粒度的控制和更高的测试精度。


二、快速开始


原型方式生成 Maven 项目


使用 JMH 的最简单方法是使用 Maven 原型生成一个新的 JMH 项目。Maven 会生成一个新的 Java 项目,其中包含一个 Java 示例类和一个 pom.xml 文件。pom.xml 文件包含编译和构建 JMH 微基准测试 Java 示例类所需的 Maven 依赖。


以下是生成 JMH 项目模板所需的 Maven 命令行:


mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype -DgroupId=com.dewu -DartifactId=first-benchmark -Dversion=1.0
复制代码


这个命令行将创建一个名为 first-benchmark(Maven 命令中指定的 artifactId)的新目录。这个目录下将生成一个新的 Maven 源目录结构(src/main/java)。java 源根目录中将生成一个名为 com.dewu 的包。包内是一个名为 MyBenchmark 的 JMH 基准测试类。


已有项目配置 JMH


如果是已有项目,你可以在 Maven 项目中添加以下依赖:

xml<dependency>    <groupId>org.openjdk.jmh</groupId>    <artifactId>jmh-core</artifactId>    <version>1.33</version>    <scope>test</scope></dependency><dependency>    <groupId>org.openjdk.jmh</groupId>    <artifactId>jmh-generator-annprocess</artifactId>    <version>1.33</version>    <scope>test</scope></dependency>
复制代码


然后再编写你的第一个 JMH 基准测试类,下面是我写的一个示例:


package com.dewu; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { // 这是用于构建 JMH 基准的演示/示例模板。根据需要进行编辑。 // 在此处放置基准代码。 } }
复制代码


你可以把要测量的代码放在 testMethod()方法体里面。下面是一个例子:


package com.dewu; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { // 这是用于构建 JMH 基准的演示/示例模板。根据需要进行编辑。 // 在此处放置基准代码。 int a = 1; int b = 2; int sum = a + b; } }
复制代码


注意:这个特定示例是一个糟糕的基准测试实现,因为 JVM 检测到 sum 变量从未使用过,因此可能会消除这段总和计算的代码。我将在本教程的后面部分介绍如何使用 JMH 正确的实现基准测试来避免 JVM 的死代码消除。


三、JMH 的核心概念和注解


基准测试方法


使用 @Benchmark 注解标记需要跑基准测试的方法。


测试模式(Benchmark Mode)

测试模式使用 @BenchmarkMode 注解标记,主要包含以下几种模式:

  • Throughput:吞吐量,单位时间内可以完成的操作数。

  • AverageTime:平均时间,完成一次操作所需的平均时间。

  • SampleTime:基于采样的执行时间,提供统计分布数据。

  • SingleShotTime:单次执行时间,用于测试冷启动性能。

  • ALL:运行所有模式。


状态(State)

使用 @State 注解定义,表示测试状态的生命周期和作用域。

  • Scope.Thread:每个线程一个实例。

  • Scope.Benchmark:所有线程共享一个实例。

  • Scope.Group:每个线程组共享一个实例。


预热(Warmup):

使用 @Warmup 注解配置,预热是正式测试前的准备阶段,用于“热身”JVM,减少 JIT 编译的影响。


测量(Measurement):

使用 @Measurement 注解配置,指定正式测试的迭代次数和每次迭代的运行时间。


输出时间单位(Output Time Unit):

使用 @OutputTimeUnit 注解指定测试结果的时间单位。


多线程(Threads):

使用 @Threads 注解指定测试方法运行的线程数。


参数化(Params):

使用 @Param 注解为基准测试方法提供参数,允许在单个测试中运行多个参数集。


隔离(Fork):

使用 @Fork 注解指定测试运行在不同的 JVM 进程中进行,以避免测试间的相互影响。通常设置为 1。


辅助计数器(AuxCounters):

使用 @AuxCounters 注解提供额外的性能计数器。


控制编译器优化(CompilerControl):

使用 @CompilerControl 注解控制 JVM 的编译优化行为。


Blackhole:

JMH 提供的一个机制,用于“吞噬”测试方法的输出,防止 JVM 的死代码消除优化。


结果分析(Result Analysis):

JMH 生成详细的测试报告,包括操作的平均时间、吞吐量、误差范围等。


API 和注解(API and Annotations):

JMH 提供了丰富的 API 和注解来配置和运行基准测试。


四、JMH 的工作原理


JVM 对性能测试的影响


JVM 的即时编译器(JIT)会对代码进行优化,这可能会影响性能测试的结果。JMH 通过控制测试环境,确保测试结果的准确性。


JMH 如何提供准确的测试结果


JMH 通过预热、多轮迭代、多进程测试等机制,减少 JVM 优化对测试结果的影响。以下是一个使用 JMH 进行基准测试的示例,它展示了 JMH 如何通过预热、多次迭代和避免 JVM 优化来提供准确的测试结果。


首先,确保你的项目中已经添加了 JMH 的依赖。然后,创建一个基准测试类,我们将测试两个方法:一个简单的数学运算和一个更复杂的数学运算,以比较它们的执行时间。


import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.annotations.BenchmarkMode;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.annotations.OutputTimeUnit;import org.openjdk.jmh.annotations.Warmup;import org.openjdk.jmh.annotations.Measurement;import org.openjdk.jmh.annotations.Fork;import org.openjdk.jmh.annotations.State;import org.openjdk.jmh.annotations.Scope;import org.openjdk.jmh.infra.Blackhole;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;@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)@Fork(1)public class AccuracyBenchmark {



@Benchmark public void measure() { // 空执行,用于模拟优化掉测试代码情景 } @Benchmark public void measureSimpleMath(Blackhole blackhole) { // 简单的数学运算,用于模拟轻量级操作 blackhole.consume(add(1, 2)); } @Benchmark public void measureComplexMath(Blackhole blackhole) { // 复杂的数学运算,用于模拟重量级操作 blackhole.consume(calculate(123, 456, 789)); } private int add(int a, int b) { return a + b; } private int calculate(int a, int b, int c) { int result = a; for (int i = 0; i < b; i++) { result += c; } return result; } // Blackhole消耗方法,防止JVM优化掉测试代码 // 主方法,用于运行基准测试 public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(AccuracyBenchmark.class.getSimpleName()) .build(); new Runner(opt).run(); }}
复制代码


运行上述代码后,JMH 会输出类似以下的测试结果:


# Run complete. Total time: 00:00:33

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up onwhy the numbers are the way they are. Use profilers (see -prof, -lprof), design factorialexperiments, perform baseline and negative tests that provide experimental control, make surethe 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 UnitsAccuracyBenchmark.measure avgt 5 0.293 ± 0.104 ns/opAccuracyBenchmark.measureComplexMath avgt 5 2.118 ± 0.622 ns/opAccuracyBenchmark.measureSimpleMath avgt 5 2.222 ± 0.539 ns/op

Process finished with exit code 0

复制代码


在这个例子中,我们使用了以下 JMH 特性来确保测试结果的准确性:


  • 预热(Warmup):通过预热迭代,我们确保 JVM 的即时编译器(JIT)有足够的时间对代码进行优化,从而模拟实际运行情况。

  • 多次测量(Measurement):通过多次测量迭代,我们可以减少偶然误差并计算统计上显著的结果。

  • 隔离测试(Fork):通过在单独的 JVM 进程中运行每个基准测试,我们避免了测试之间的相互影响,并确保每个测试都在相同的初始条件下进行。

  • Blackhole 消耗:Blackhole 是一个 JMH 提供的工具,用于消耗测试方法的输出,防止 JVM 优化掉测试代码(例如,死代码消除)。


要运行这个基准测试,你可以执行 main 方法,JMH 会输出每个方法的平均执行时间。这个例子展示了简单数学运算和复杂数学运算的性能差异。


请注意,这个例子是一个简单的基准测试,实际使用时可能需要更复杂的测试场景和更多的配置。此外,JMH 的输出应该被解释为趋势而不是绝对值,因为性能测试受到很多因素的影响,包括 JVM 状态、系统负载等。


五、JMH 的高级特性

多线程测试和同步


JMH 支持多线程测试,并提供了同步机制以确保测试的准确性。


在 JMH 中进行多线程测试时,你需要使用 @Threads 或 @Fork 注解来指定线程数量。为了确保所有线程在测量阶段同时开始和结束,可以使用 @Benchmark 注解的 syncIterations 参数。


以下是一个使用 JMH 进行多线程测试的示例:


import org.openjdk.jmh.annotations.*;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;

@State(Scope.Thread) // 每个线程都有自己的状态@BenchmarkMode(Mode.Throughput) // 测试吞吐量@OutputTimeUnit(TimeUnit.SECONDS) // 时间单位为秒@Warmup(iterations = 5) // 预热迭代5次public class MultiThreadBenchmark {

@Benchmark @Threads(2) // 指定使用2个线程执行测试 public void multiThreadTest() { // 这里是需要测试的多线程代码 }

// 主方法,用于运行基准测试 public static void main(String[] args) throws Exception { Options opt = new OptionsBuilder() .include(MultiThreadBenchmark.class.getSimpleName()) .syncIterations(true) // 启用同步迭代 .build(); new Runner(opt).run(); }}
复制代码


在这个例子中,我们使用 @Threads(2)注解来指定测试方法 multiThreadTest 将在 2 个线程中并行执行,使用 syncIterations(true)确保所有线程在每个测量迭代中同步执行。这意味着 JMH 将创建 2 个线程,每个线程都将执行 multiThreadTest 方法。


如果你想要更细致地控制每个线程的行为,你可以使用 @Group 和 @GroupThreads 注解来定义线程组:


import org.openjdk.jmh.annotations.*;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) // 时间单位为秒@Warmup(iterations = 5) // 预热迭代5次@State(Scope.Group) // 每个线程都有自己的状态public class ThreadGroupBenchmark { private int counter;

@Setup(Level.Trial) public void setUp() { counter = 0; }

@Benchmark @GroupThreads(2) @Group("testGroup") public void increment( ThreadGroupState state) { state.increment(); }

@Benchmark @Group("testGroup") public void decrement(ThreadGroupState state) { state.decrement(); }

@State(Scope.Thread) public static class ThreadGroupState { private int value;

public void increment() { value++; }

public void decrement() { value--; } }

// 主方法,用于运行基准测试 public static void main(String[] args) throws Exception { Options opt = new OptionsBuilder() .include(ThreadGroupBenchmark.class.getSimpleName()) .syncIterations(true) // 启用同步迭代 .build(); new Runner(opt).run(); }}
复制代码


在这个例子中,我们定义了一个线程组 testGroup,并使用 @GroupThreads(2)注解指定每个线程组有 2 个线程。increment 和 decrement 方法都属于 testGroup 线程组,它们将并行执行。ThreadGroupState 类定义了线程组共享的状态。


这两个示例展示了如何在 JMH 中设置和运行多线程基准测试。通过这种方式,你可以评估并发代码在多线程环境中的性能。


参数化测试


在 JMH 中实现参数化测试,可以使用 @Param 注解来为基准测试方法提供不同的参数值。这种方式特别适合于测量方法性能与参数取值之间的关系。下面是一个参数化测试的示例:


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;

@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)@Fork(1)public class ParametrizedBenchmark { @Param({"1", "10", "100"}) private int numberOfElements;

private int[] array;

@Setup public void setup() { array = new int[numberOfElements]; for (int i = 0; i < numberOfElements; i++) { array[i] = i; } }

@Benchmark public int sumArray() { int sum = 0; for (int value : array) { sum += value; } return sum; }

public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(ParametrizedBenchmark.class.getSimpleName()) .forks(1) .warmupIterations(5) .measurementIterations(5) .build();

new Runner(opt).run(); }}
复制代码


在这个例子中,@Param 注解用于定义参数 numberOfElements,它将取三个不同的值:1、10 和 100。setup 方法用于初始化数组,sumArray 方法用于计算数组元素的总和。JMH 将为每个参数值运行基准测试,并生成相应的结果。


这种方式允许你用一个测试方法来覆盖多种输入情况下的性能测试,从而更全面地了解代码的性能表现。


控制 JVM 的编译优化


@CompilerControl 注解是 JMH 提供的一个高级特性,它允许测试作者精确控制 JVM 的编译行为。这在基准测试中非常有用,因为它可以防止 JVM 优化掉测试代码,从而确保测试结果的准确性。


以下是如何使用 @CompilerControl 注解来控制 JVM 的编译优化的一个示例:


import org.openjdk.jmh.annotations.*;import org.openjdk.jmh.infra.Blackhole;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;

@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)@Fork(1)public class CompilerControlExample {

@CompilerControl(CompilerControl.Mode.DONT_INLINE) public static int doNotInlineMe(int x) { // 这个方法不会被内联,即使它是一个简单的方法 return x + 42; }

@Benchmark @Threads(1) // 单线程执行 public void testWithCompilerControl(Blackhole bh) { int result = doNotInlineMe(1); bh.consume(result); }

@Benchmark @Threads(1) // 单线程执行 public void testWithoutCompilerControl(Blackhole bh) { int result = inlineMe(1); bh.consume(result); }

// 这是一个可能会被JVM内联的简单方法 public static int inlineMe(int x) { return x + 42; }

// 主方法,用于运行基准测试 public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(CompilerControlExample.class.getSimpleName()) .build(); new Runner(opt).run(); }
复制代码


在这个例子中,doNotInlineMe 方法用了 @CompilerControl(CompilerControl.Mode.DONT_INLINE)注解,这告诉 JMH 和 JVM 不要内联这个方法,即使它是一个简单的方法。这可以防止 JVM 的即时编译器(JIT)在测试过程中优化掉这个方法。testWithCompilerControl 基准测试方法调用了 doNotInlineMe 方法,并且它的结果被传递给了 Blackhole,这是一个用来防止编译器优化掉测试代码的工具。


另一方面,inlineMe 方法是一个可能会被 JVM 内联的简单方法,testWithoutCompilerControl 基准测试方法调用了这个方法,并且没有使用 @CompilerControl 注解。


通过比较这两个测试方法的结果,你可以观察到是否内联对性能测试结果的影响。这种控制对于确保基准测试的准确性非常重要。以下是跑完这个例子 JMH 输出的测试结果:

# Run complete. Total time: 00:00:21

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up onwhy the numbers are the way they are. Use profilers (see -prof, -lprof), design factorialexperiments, perform baseline and negative tests that provide experimental control, make surethe 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 UnitsCompilerControlExample.testWithCompilerControl avgt 5 2.916 ± 0.333 ns/opCompilerControlExample.testWithoutCompilerControl avgt 5 1.857 ± 0.094 ns/op
复制代码


六、编写有效的基准测试


避免常见的性能测试陷阱


现在我们已经了解了如何使用 JMH 编写基准测试,现在是时候讨论如何编写正确的基准测试了。在写基准测试时,我们很容易陷入几个陷阱。我将在以下部分讨论其中一些陷阱。


一个常见的陷阱是,JVM 可能会在基准测试中执行时对您的代码进行优化,而如果代码在您的实际应用程序中执行,则无法应用这些优化。此类优化将使您的代码看起来比实际运行速度更快。

循环优化


我们很容易将基准测试代码放在基准测试方法的循环中,以便在每次调用基准测试方法时重复多次(以减少基准测试方法调用的开销)。但是,JVM 非常擅长优化循环,因此最终结果可能与预期不同。一般来说,您应该避免在基准测试方法中使用循环。而是使用 @OperationsPerInvocation 注解来告诉 JMH 每次迭代应该执行多少次操作。比如这个基准测试示例:


@Benchmark@OperationsPerInvocation(1000)public void measureLoop() {    for (int i = 0; i < 1000; i++) {        // ...    }}
复制代码


消除死代码


执行性能基准测试时要避免的 JVM 优化之一是消除死代码。如果 JVM 检测到某些计算的结果从未使用过,JVM 可能会认为该计算是死代码并将其消除。比如下面这个基准测试示例:


import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {

@Benchmark public void testMethod() { int a = 1; int b = 2; int sum = a + b; }

}
复制代码


JVM 可以检测到 a+b 分配给的 sum 从未使用过。因此,JVM 可以完全删除 sum 的计算。最后,基准测试中没有留下任何代码。因此,运行此基准测试的结果具有很大的误导性。基准测试实际上并没有测量添加两个变量并将值分配给第三个变量的时间。基准测试根本没有测量任何代码逻辑。


避免消除死代码


为了避免消除死代码,我们必须确保要测量的代码对 JVM 来说不像死代码。有两种方法可以做到这一点。


  • 从基准测试方法返回代码的结果。

  • 将计算出的值传递到 JMH 提供的 Blackhole 中。


以下是这两种方法的示例:

基准测试方法的返回值

从 JMH 基准测试方法返回计算值如下所示:

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {

@Benchmark public int testMethod() { int a = 1; int b = 2; int sum = a + b; return sum; }

}
复制代码


注意 testMethod()方法现在会返回 sum 变量。这样,JVM 就不能直接消除代码,因为返回值可能会被调用者使用。如果你的基准测试方法正在计算最终可能被视为死代码而被消除的多个值,那么您可以将两个值组合为一个,然后返回该值(例如,包含两个值的对象)。


将值传递给 Blackhole


返回组合值的另一种方法是将计算值传递到 JMH 提供的 Blackhole 变量中。将值传递到 Blackhole 的方式如下:

import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.infra.Blackhole;
public class MyBenchmark {
@Benchmark public void testMethod(Blackhole blackhole) { int a = 1; int b = 2; int sum = a + b; blackhole.consume(sum); }}
复制代码


testMethod()基准测试方法现在将 Blackhole 对象作为参数。调用时,JMH 将向测试方法提供该参数。


还要注意变量中计算出的总和 sum 现在是传递给了实例 Blackhole 的 consume()方法。这样 JVM 会认为 sum 变量会被使用。如果您的基准测试方法产生多个结果,您可以将这些结果都传递给 Blackhole。


常量折叠


常量折叠是另一种常见的 JVM 优化。基于常量的计算通常会导致完全相同的结果,无论执行多少次计算。JVM 可能会检测到这一点,并用计算结果替换该计算。


举个例子,看一下这个基准测试:


import org.openjdk.jmh.annotations.Benchmark;
public class MyBenchmark {
@Benchmark public int testMethod() { int a = 1; int b = 2; int sum = a + b; return sum; }
}
复制代码


JVM 会检测到 sum 的值是基于 1 和 2 这两个常量值的和 。因此,它可以将上述代码替换为以下内容:


import org.openjdk.jmh.annotations.Benchmark;
public class MyBenchmark {
@Benchmark public int testMethod() { int sum = 3; return sum; }
}
复制代码


或者直接 return 3。


避免常量折叠


为了避免常量折叠,我们不能将常量硬编码到基准测试方法中。相反,计算的输入应该来自状态对象。这使得 JVM 很难看出计算是基于常量值的。以下是一个例子:


import org.openjdk.jmh.annotations.*;
public class MyBenchmark {
@State(Scope.Thread) public static class MyState { public int a = 1; public int b = 2; }

@Benchmark public int testMethod(MyState state) { int sum = state.a + state.b; return sum; }}
复制代码


如果你的基准测试方法计算了多个值,你可以将它们传递到 Blackhole 而不是返回它们,这样也可以避免死代码消除优化。例如:


@Benchmark public void testMethod(MyState state, Blackhole blackhole) { int sum1 = state.a + state.b; int sum2 = state.a + state.a + state.b + state.b; blackhole.consume(sum1); blackhole.consume(sum2); }
复制代码


七、JMH 测试结果分析


解读 JMH 输出的报告


每次跑完基准测试,JMH 都会输出详细的测试报告,包括平均时间、吞吐量、单次操作时间、统计误差等。分析这些结果时,你需要关注几个关键点:


  • 吞吐量(Throughput):表示单位时间内可以完成的操作数量。

  • 平均时间(Average Time):表示完成一次操作所需的平均时间。

  • 样本时间(Sample Time):基于采样的执行时间,通常包含百分位数统计。

  • 单次执行时间(Single Shot Time):表示单次执行操作所需的时间,用于测试冷启动性能。

  • 统计误差(Score Error):表示测试结果的可变性,误差越小,结果越稳定。


以下是一个简单的 JMH 测试示例,我们会根据跑完这个实例再来分析生成的结果:

import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.annotations.BenchmarkMode;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.annotations.OutputTimeUnit;import org.openjdk.jmh.annotations.Warmup;import org.openjdk.jmh.annotations.Measurement;import org.openjdk.jmh.annotations.Fork;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;

@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)@Warmup(iterations = 5)@Measurement(iterations = 5)@Fork(1)public class AnalysisBenchmark {

@Benchmark public void measureMethod() { // 测试方法 }

public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(AnalysisBenchmark.class.getSimpleName()) .build(); new Runner(opt).run(); }}
复制代码


运行上述代码后,JMH 会输出类似以下的测试结果:

Run complete. Total time: 00:01:41

# Run complete. Total time: 00:01:41

REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up onwhy the numbers are the way they are. Use profilers (see -prof, -lprof), design factorialexperiments, perform baseline and negative tests that provide experimental control, make surethe 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 UnitsAnalysisBenchmark.measureMethod avgt 5 0.268 ± 0.070 ns/op
复制代码


我们可以从测试结果分析到如下信息:


  • Score(分数):0.268 ns/op 表示每次操作的平均时间是 0.268 纳秒。

  • Error(误差):±0.070%表示测试结果的误差率,误差越小,测试结果越可靠。

  • Score Error(分数误差):表示测试结果的可变性,这里没有给出具体数值,但通常在输出中会显示。


通过结果得到一下结论:


  • 如果测试结果的误差很小(例如±0.01%),则表示测试结果比较稳定和可靠。

  • 如果测试结果显示高误差,可能需要增加迭代次数或预热次数来降低误差。


通过比较不同测试方法的结果,可以了解不同实现的性能差异。

分析 JMH 测试结果时,应该综合考虑所有输出的数据,包括误差、百分位数和置信区间,以得出准确的结论。


八、案例研究


实际案例分析:使用 JMH 测试字符串拼接性能


在 Java 中,字符串拼接是一个常见的操作,有多种方式可以实现,比如使用 String 对象的+操作符、StringBuilder 或 StringBuffer。不同的方法在性能上可能存在差异,特别是在循环或大量拼接操作时。使用 JMH 可以对这些不同的字符串拼接方法进行性能测试。


以下是一个使用 JMH 测试不同字符串拼接方法性能的示例:


import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.annotations.BenchmarkMode;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.annotations.OutputTimeUnit;import org.openjdk.jmh.annotations.Warmup;import org.openjdk.jmh.annotations.Measurement;import org.openjdk.jmh.annotations.Fork;import org.openjdk.jmh.annotations.State;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;

@State(Scope.Thread)@BenchmarkMode(Mode.Throughput)@OutputTimeUnit(TimeUnit.SECONDS)@Warmup(iterations = 5)@Measurement(iterations = 5)@Fork(1)public class StringConcatenationBenchmark {

@Benchmark public String concatUsingPlus() { String string = ""; for (int i = 0; i < 100; i++) { string += "String"; } return string; }

@Benchmark public String concatUsingStringBuilder() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100; i++) { sb.append("String ").append(i); } return sb.toString(); }

@Benchmark public String concatUsingStringBuffer() { StringBuffer sb = new StringBuffer(); for (int i = 0; i < 100; i++) { sb.append("String ").append(i); } return sb.toString(); }

public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(StringConcatenationBenchmark.class.getSimpleName()) .build(); new Runner(opt).run(); }}
复制代码


在这个例子中,我们定义了三个基准测试方法,分别测试使用+操作符、StringBuilder 和 StringBuffer 进行字符串拼接的性能。每个方法都会在循环中执行 100 次字符串拼接操作。


运行这个基准测试后,JMH 会输出每个方法的吞吐量,即每秒可以完成的字符串拼接操作数量。根据测试结果,我们可以得出哪种字符串拼接方法在特定情况下性能更优。


分析测试结果


假设 JMH 输出的测试结果如下:


Benchmark                                               Mode  Cnt       Score        Error  UnitsStringConcatenationBenchmark.concatUsingPlus           thrpt    5  127801.402 ±   7365.452  ops/sStringConcatenationBenchmark.concatUsingStringBuffer   thrpt    5  385107.338 ±  66488.847  ops/sStringConcatenationBenchmark.concatUsingStringBuilder  thrpt    5  411992.746 ± 155229.314  ops/s
复制代码


结论

  • concatUsingPlus:使用+操作符的吞吐量为 127801.402 ± 7365.452 ops/s。

  • concatUsingStringBuilder:使用 StringBuilder 的吞吐量最高,为 411992.746 ± 155229.314ops/s。

  • concatUsingStringBuffer:使用 StringBuffer 的吞吐量为 385107.338 ± 66488.847 ops/s。


根据这些结果,我们可以得出结论,对于非同步的字符串拼接操作,StringBuilder 在性能上优于 String 和 StringBuffer。这是因为 String 对象是不可变的,每次使用+操作符拼接字符串时都会创建新的 String 对象,而 StringBuilder 则是可变的,可以在不创建新对象的情况下进行字符串拼接。StringBuffer 是同步的,因此其性能通常低于 StringBuilder。


这个测试结果可以帮助开发者在实际开发中选择合适的字符串拼接方法,以优化性能。


**注意:这里的测试结果是基于 JMH 版本 1.33,JDK 版本 JDK 1.8.0_202,虚拟机版本:Java HotSpot(TM) 64-Bit Server VM, 25.202-b08,操作系统:Windows 10 64-bit CPU:Intel(R) Xeon(R) Platinum 8378C CPU @ 2.80GHz 2.80 GHz 内存:64 GB RAM。**


九、总结


在本文中,我们介绍了 Java 基准测试工具 JMH(Java Microbenchmark Harness)的基本使用方法和一些核心概念。我们探讨了如何编写有效的基准测试并避免常见的测试陷阱。最后,通过一个字符串拼接的案例,展示了完整的 JMH 使用过程。希望通过阅读本文,您可以对 JMH 有更深入的理解,并能够在实际开发中应用这一工具来优化代码性能。


往期回顾


1.解析 Go 切片:为何按值传递时会发生改变?|得物技术


2.彩虹桥架构演进之路-负载均衡篇|得物技术


3.得物精准测试平台设计与实现


4.基于 IM 场景下的 Wasm 初探:提升 Web 应用性能|得物技术


5.增长在流量规则巡检的探索实践|得物技术


文 / 鲁班


关注得物技术,每周新技术干货


要是觉得文章对你有帮助的话,欢迎评论转发点赞~


未经得物技术许可严禁转载,否则依法追究法律责任。

发布于: 刚刚阅读数: 3
用户头像

得物技术

关注

得物APP技术部 2019-11-13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
Java性能测试利器:JMH入门与实践|得物技术_Java_得物技术_InfoQ写作社区