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

原创
11/21 11:28
阅读数 2.2K
AI总结

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

一、JMH概述

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

JMH的主要特点

  • 高精度:支持纳秒级别的性能测试。
  • 易用性:通过注解配置测试,无需复杂的测试环境搭建。
  • 多模式测试:支持多种测试模式,如吞吐量、平均时间等。
  • 多维度测试:可以测试代码在不同条件下的性能表现。

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 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
AccuracyBenchmark.measure             avgt    5  0.293 ± 0.104  ns/op
AccuracyBenchmark.measureComplexMath  avgt    5  2.118 ± 0.622  ns/op
AccuracyBenchmark.measureSimpleMath   avgt    5  2.222 ± 0.539  ns/op


Process finished with exit code 0


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

  1. 预热(Warmup):通过预热迭代,我们确保JVM的即时编译器(JIT)有足够的时间对代码进行优化,从而模拟实际运行情况。
  2. 多次测量(Measurement):通过多次测量迭代,我们可以减少偶然误差并计算统计上显著的结果。
  3. 隔离测试(Fork):通过在单独的JVM进程中运行每个基准测试,我们避免了测试之间的相互影响,并确保每个测试都在相同的初始条件下进行。
  4. 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 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
CompilerControlExample.testWithCompilerControl     avgt    5  2.916 ± 0.333  ns/op
CompilerControlExample.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都会输出详细的测试报告,包括平均时间、吞吐量、单次操作时间、统计误差等。分析这些结果时,你需要关注几个关键点:

  1. 吞吐量(Throughput):表示单位时间内可以完成的操作数量。
  2. 平均时间(Average Time):表示完成一次操作所需的平均时间。
  3. 样本时间(Sample Time):基于采样的执行时间,通常包含百分位数统计。
  4. 单次执行时间(Single Shot Time):表示单次执行操作所需的时间,用于测试冷启动性能。
  5. 统计误差(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


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
AnalysisBenchmark.measureMethod  avgt    5  0.268 ± 0.070  ns/op

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

  1. Score(分数):0.268 ns/op表示每次操作的平均时间是0.268纳秒。
  2. Error(误差):±0.070%表示测试结果的误差率,误差越小,测试结果越可靠。
  3. 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  Units
StringConcatenationBenchmark.concatUsingPlus           thrpt    5  127801.402 ±   7365.452  ops/s
StringConcatenationBenchmark.concatUsingStringBuffer   thrpt    5  385107.338 ±  66488.847  ops/s
StringConcatenationBenchmark.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.增长在流量规则巡检的探索实践|得物技术

 

文 / 鲁班

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

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

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

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
1 收藏
0
分享
AI总结
返回顶部
顶部