返回文章列表
JVM虚拟机
JVMJMH基准测试JIT

10JMH基准测试框架

面试中容易问到性能测试问题:

面试官:你是如何判断一个方法需要耗时多少时间的?

我:我会在方法上打印开始时间和结束时间,他们的差值就是方法的执行耗时。手动通过 postman 或者 jmeter 发起一笔请求,在控制台上看输出的时间。

这样做是不准确的。第一,测试时有些对象创建是懒加载的,所以会影响第一次的请求时间;第二,因为虚拟机中 JIT 即时编译器会优化你的代码,所以你这个测试得出的时间并不一定是最终用户处理的时间。

1.JIT 对程序性能的影响

Java 程序在运行过程中,JIT 即时编译器会实时对代码进行性能优化,所以仅凭少量的测试是无法真实反应运行系统最终给用户提供的性能。如下图,随着执行次数的增加,程序性能会逐渐优化(横轴单位十万次)。

JIT 优化使程序执行耗时随次数增多逐渐下降

所以简单地打印时间是不准确的,JIT 有可能还没有对程序进行性能优化,我们拿到的测试数据和最终用户使用的数据是不一致的。

2.正确地测试代码性能

OpenJDK 中提供了一款叫 JMH(Java Microbenchmark Harness)的工具,可以准确地对 Java 代码进行基准测试,量化方法的执行性能。官网地址:https://github.com/openjdk/jmh

JMH 会首先执行预热过程,确保 JIT 对代码进行优化之后再进行真正的迭代测试,最后输出测试的结果。

JMH 执行流程:预热过程 + 迭代测试 + 输出结果

3.JMH 环境搭建

创建基准测试项目,在 CMD 窗口中,使用以下命令创建 JMH 环境项目:

mvn archetype:generate \
  -DinteractiveMode=false \
  -DarchetypeGroupId=org.openjdk.jmh \
  -DarchetypeArtifactId=jmh-java-benchmark-archetype \
  -DgroupId=org.sample \
  -DartifactId=test \
  -Dversion=1.0

修改 POM 文件中的 JDK 版本号和 JMH 版本号,JMH 最新版本号参考 Github。

4.JMH 编写测试方法

编写测试方法,几个需要注意的点:

  • 死代码问题
  • 黑洞的用法

初始代码:

package org.sample;
 
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.results.format.ResultFormatType;
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.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.concurrent.TimeUnit;
 
//执行5轮预热,每次持续1秒
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
//执行一次测试
@Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
//显示平均时间,单位纳秒
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class HelloWorldBench {
 
    @Benchmark
    public int test1() {
        int i = 0;
        i++;
        return i;
    }
 
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(HelloWorldBench.class.getSimpleName())
                .resultFormat(ResultFormatType.JSON)
                .forks(1)
                .build();
 
        new Runner(opt).run();
    }
}

如果不将 i 返回,JIT 会直接将这段代码去掉,因为它认为你不会使用 i,那么我们对 i 进行的任何处理都是没有意义的,这种代码无法执行的现象称之为死代码。

我们可以将 i 返回,或者添加黑洞来消费这些变量,让 JIT 无法消除这些代码:

@Benchmark
public void test2(Blackhole bh) {
    int i = 0;
    i++;
    bh.consume(i);
}

通过 maven 的 verify 命令,检测代码问题并打包成 jar 包。通过 java -jar target/benchmarks.jar 命令执行基准测试。

添加 ResultFormatType.JSON 这行参数可以生成 JSON 文件,测试结果通过 JMH Visualizer 生成可视化的结果。

5.实际案例:日期格式化性能对比

在 JDK8 中,可以使用 Date 进行日期的格式化,也可以使用 LocalDateTime 进行格式化,使用 JMH 对比这两种格式化的性能。

解决思路:

1、搭建 JMH 测试环境。

2、编写 JMH 测试代码。

3、进行测试。

4、比对测试结果。

package org.sample;
 
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.results.format.ResultFormatType;
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.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.concurrent.TimeUnit;
 
//执行5轮预热,每次持续1秒
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
//执行一次测试
@Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
//显示平均时间,单位纳秒
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class DateBench {
 
    private static String sDateFormatString = "yyyy-MM-dd HH:mm:ss";
    private Date date = new Date();
    private LocalDateTime localDateTime = LocalDateTime.now();
    private static ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal = new ThreadLocal();
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
    @Setup
    public void setUp() {
        SimpleDateFormat sdf = new SimpleDateFormat(sDateFormatString);
        simpleDateFormatThreadLocal.set(sdf);
    }
 
    @Benchmark
    public String date() {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat(sDateFormatString);
        return simpleDateFormat.format(date);
    }
 
    @Benchmark
    public String localDateTime() {
        return localDateTime.format(formatter);
    }
 
    @Benchmark
    public String localDateTimeNotSave() {
        return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }
 
    @Benchmark
    public String dateThreadLocal() {
        return simpleDateFormatThreadLocal.get().format(date);
    }
 
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(DateBench.class.getSimpleName())
                .resultFormat(ResultFormatType.JSON)
                .forks(1)
                .build();
 
        new Runner(opt).run();
    }
}

测试结果对比了四种实现:

  • date():每次 new 一个 SimpleDateFormat(线程不安全但典型用法)
  • localDateTime():复用一个 DateTimeFormatter
  • localDateTimeNotSave():每次都重新 ofPattern 一个 DateTimeFormatter
  • dateThreadLocal():用 ThreadLocal 复用 SimpleDateFormat

通过 JMH Visualizer 生成的可视化结果可以非常清晰地看到 LocalDateTime 复用 formatter 的方式性能最优。这也印证了为什么实际生产代码中推荐使用 LocalDateTime + 静态 DateTimeFormatter 来替代 SimpleDateFormat,既能保证线程安全又能获得更好的吞吐量。