不要再用main方法测试代码性能了,用这款JDK自带工具

作为软件开发人员,我们通常会写一些测试程序用来对比不同算法、不同工具的性能问题。而最常见的做法是写一个 main 方法,构造模拟场景进行并发测试。

如果细心的朋友可能已经发现,每次测试结果误差很大,有时候测试出的结果甚至与事实相反。当然,这不排除是因为软硬件环境因素导致,但更多的可能是因为所使用测试方法自身有问题。

比如,不同需要性能比较方法放到一个虚拟机里调用,有可能会互相影响,缺少预热的过程等。

本文给大家推荐一款 JDK9 及以后自带的一款可用于软件基准测试的工具 JMH(Java Microbenchmark Harness)。

JMH 简介

JMH 是用于代码微基准测试的工具套件,主要是基于方法层面的基准测试,精度可以达到纳秒级。

何谓 Micro Benchmark 呢?简单的来说就是基于方法层面的基准测试,精度可以达到微秒级。当你定位到热点方法,希望进一步优化方法性能的时候,就可以使用 JMH 对优化的结果进行量化的分析。

这款工具是由 Oracle 内部实现 JIT 的作者所写。我们知道 JIT(Java 即时编译器)是将 JVM 优化的所有高效手段和技术都使用上的地方。可想而知,开发者比任何人都更加了解 JVM 和 JIT 对基准测试的影响。

因此,这款工具是值得我们信赖和在实践中进行使用的。而且使用起来也非常方便。

使用场景

JMH 不仅能帮我们测试一些常见类的性能,比如对比 StringBuffer 和 StringBuilder 的性能、对比不同算法的在不同数据量的性能等,还能够帮助我们对系统中发现的热点代码进行量化分析。

JMH 通常用于以下应用场景:

测试某个方法在稳定执行的情况下所需时间,以及执行时间和问题规模的相关性;

对比接口不同实现在给定条件下的吞吐量

查看多少百分比的请求在多长时间内完成

使用实例

依赖引入

如果你使用的是 JDK9 或以上版本,则 JDK 中已经自带了该工具,直接使用即可。如果你使用的是其他版本则可以通过 maven 直接引入以下依赖:

    org.openjdk.jmh    jmh-core    1.27    org.openjdk.jmh    jmh-generator-annprocess    1.27复制代码

其中 1.27 是当前的最新版本,可根据实际需要更新或降低版本。

测试案例

下面以 StringBuffer 和 StringBuilder 的性能测试对比为例来进行基准测试。

//使用模式 默认是Mode.Throughput@BenchmarkMode(Mode.AverageTime)// 配置预热次数,默认是每次运行1秒,运行10次,这里设置为3次@Warmup(iterations = 3, time = 1)// 本例是一次运行4秒,总共运行3次,在性能对比时候,采用默认1秒即可@Measurement(iterations = 3, time = 4)// 配置同时起多少个线程执行@Threads(1)//代表启动多个单独的进程分别测试每个方法,这里指定为每个方法启动一个进程@Fork(1)// 定义类实例的生命周期,Scope.Benchmark:所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能@State(value = Scope.Benchmark)// 统计结果的时间单元@OutputTimeUnit(TimeUnit.NANOSECONDS)public class JmhTest {    @Param(value = {"10", "50", "100"})    private int length;    public static void main(String[] args) throws RunnerException {        Options opt = new OptionsBuilder()                .include(JmhTest.class.getSimpleName())                .result("result.json")                .resultFormat(ResultFormatType.JSON).build();        new Runner(opt).run();    }    @Benchmark    public void testStringBufferAdd(Blackhole blackhole) {        StringBuffer sb = new StringBuffer();        for (int i = 0; i < length; i++) {            sb.append(i);        }        blackhole.consume(sb.toString());    }    @Benchmark    public void testStringBuilderAdd(Blackhole blackhole) {        StringBuilder sb = new StringBuilder();        for (int i = 0; i < length; i++) {            sb.append(i);        }        blackhole.consume(sb.toString());    }}复制代码

上面介绍概念时已经提到 Benchmark 为基准测试,在使用中只需对要测试的方法添加 @Benchmark 注解即可。而在测试类 JmhTest 指定测试的预热、线程、测试维度等信息。

main 方法中通过 OptionsBuilder 构造测试配置对象 Options,并传入 Runner,启动测试。这里指定测试结果为 json 格式,同时会将结果存储在 result.json 文件当中。

执行测试

执行 main 方法,控制台首先会打印出如下信息:

# JMH version: 1.27# VM version: JDK 1.8.0_271, Java HotSpot(TM) 64-Bit Server VM, 25.271-b09# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_271.jdk/Contents/Home/jre/bin/java# VM options: -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=56800:/Applications/IntelliJ IDEA.app/Contents/bin -Dfile.encoding=UTF-8# JMH blackhole mode: full blackhole + dont-inline hint# Warmup: 3 iterations, 1 s each# Measurement: 3 iterations, 4 s each# Timeout: 10 min per iteration# Threads: 1 thread, will synchronize iterations# Benchmark mode: Average time, time/op# Benchmark: com.choupangxia.strings.JmhTest.testStringBufferAdd# Parameters: (length = 10)复制代码

这些信息主要用来展示测试的基本信息,包括 jdk、JVM、预热配置、执行轮次、执行时间、执行线程、测试的统计单位等。

# Warmup Iteration  1: 76.124 ns/op# Warmup Iteration  2: 77.703 ns/op# Warmup Iteration  3: 249.515 ns/op复制代码

这是对待测试方法的预热处理,这部分不会记入测试结果。预热主要让 JVM 对被测代码进行足够多的优化,比如 JIT 编译器的优化。

Iteration  1: 921.191 ns/opIteration  2: 897.729 ns/opIteration  3: 890.245 ns/opResult "com.choupangxia.strings.JmhTest.testStringBuilderAdd":  903.055 ±(99.9%) 294.557 ns/op [Average]  (min, avg, max) = (890.245, 903.055, 921.191), stdev = 16.146  CI (99.9%): [608.498, 1197.612] (assumes normal distribution)复制代码

显示每次(共 3 次)迭代执行速率,最后进行统计。这里是对 testStringBuilderAdd 方法执行 length 为 100 的测试,通过 (min, avg, max) 三项可以看出最小时间、平均时间、最大时间的值,单位为 ns。stdev 显示的是误差时间。

通常情况下,我们只用看最后的结果即可:

Benchmark                    (length)  Mode  Cnt    Score      Error  UnitsJmhTest.testStringBufferAdd              10  avgt    3    92.599 ±  105.019  ns/opJmhTest.testStringBufferAdd              50  avgt    3  582.974 ±  580.536  ns/opJmhTest.testStringBufferAdd              100  avgt    3  1131.460 ± 1109.380  ns/opJmhTest.testStringBuilderAdd        10  avgt    3    76.072 ±    2.824  ns/opJmhTest.testStringBuilderAdd        50  avgt    3  450.325 ±  14.271  ns/opJmhTest.testStringBuilderAdd      100  avgt    3  903.055 ±  294.557  ns/op复制代码

看到上述结果我们可能会很吃惊,我们知道 StringBuffer 要比 StringBuilder 的性能低一些,但结果发现它们的之间的差别并不是很大。这是因为 JIT 编译器进行了优化,比如当 JVM 发现在测试当中 StringBuffer 并没有发生逃逸,于是就进行了锁消除操作。

常用注解

下面对 JHM 当中常用的注解进行说明,以便大家可以更精确的使用。

@BenchmarkMode

配置 Mode 选项,作用于类或者方法上,其 value 属性为 Mode 数组,可同时支持多种 Mode,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime}),也可设为 Mode.All,即全部执行一遍。

org.openjdk.jmh.annotations.Mode 为枚举类,对应的源代码如下:

public enum Mode {    Throughput("thrpt", "Throughput, ops/time"),    AverageTime("avgt", "Average time, time/op"),    SampleTime("sample", "Sampling time"),    SingleShotTime("ss", "Single shot invocation time"),    All("all", "All benchmark modes");    // 省略其他内容}复制代码

不同模式之间,测量的维度或测量的方式不同。目前 JMH 共有四种模式:

Throughput:整体吞吐量,例如 “1 秒内可以执行多少次调用”,单位为 ops/time;

AverageTime:调用的平均时间,例如 “每次调用平均耗时 xxx 毫秒”,单位为 time/op;

SampleTime:随机取样,最后输出取样结果的分布,,例如 “99% 的调用在 xxx 毫秒以内,99.99% 的调用在 xxx 毫秒以内”;

SingleShotTime:以上模式都是默认一次 iteration 是 1s,只有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为 0,用于测试冷启动时的性能;

All:上面的所有模式都执行一次;

@Warmup

在执行 @Benchmark 之前进行预热操作,确保测试的准确性,可用于类或者方法上。默认是每次运行 1 秒,运行 10 次。

其中 @Warmup 有以下属性:

iterations:预热的次数;Iteration 是 JMH 进行测试的最小单位,在大部分模式下,一次 iteration 代表的是一秒,JMH 会在这一秒内不断调用需要 benchmark 的方法,然后根据模式对其采样,计算吞吐量,计算平均执行时间等。

time:每次预热的时间;

timeUnit:时间的单位,默认秒;

batchSize:批处理大小,每次操作调用几次方法;

JIT 在执行的过程中会将热点代码编译为机器码,并进行各种优化,从而提高执行效率。预热的主要目的是让 JVM 的 JIT 机制生效,让结果更接近真实效果。

@State

类注解,JMH 测试类必须使用 @State 注解,不然会提示无法运行。

State 定义了一个类实例的生命周期(作用范围),可以类比 Spring Bean 的 Scope。因为很多 benchmark 会需要一些表示状态的类,JMH 会根据 scope 来进行实例化和共享操作。

@State 可以被继承使用,如果父类定义了该注解,子类则无需定义。

由于 JMH 允许多线程同时执行测试,不同的选项含义如下:

Scope.Thread:默认的 State,该状态为每个线程独享,每个测试线程分配一个实例;

Scope.Benchmark:该状态在所有线程间共享,所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能;

Scope.Group:该状态为同一个组里面所有线程共享。

@OutputTimeUnit

benchmark 统计结果所使用的时间单位,可用于类或者方法注解,使用 java.util.concurrent.TimeUnit 中的标准时间单位。

@Measurement

度量,其实就是实际调用方法所需要配置的一些基本测试参数,可用于类或者方法上。配置属性项目和作用与 @Warmup 相同。

一般比较重的程序可以进行大量的测试,放到服务器上运行。在性能对比时,采用默认 1 秒即可,如果用 jvisualvm 做性能监控,可以指定一个较长时间运行。

@Threads

每个进程中同时起多少个线程执行,可用于类或者方法上。默认值是 Runtime.getRuntime().availableProcessors(),根据具体情况选择,一般为 cpu 乘以 2。

@Fork

代表启动多个单独的进程分别测试每个方法,可用于类或者方法上。如果 fork 数是 2 的话,则 JMH 会 fork 出两个进程来进行测试。

JVM 因为使用了 profile-guided optimization 而 “臭名昭著”,这对于微基准测试来说十分不友好,因为不同测试方法的 profile 混杂在一起,“互相伤害” 彼此的测试结果。对于每个 @Benchmark 方法使用一个独立的进程可以解决这个问题,这也是 JMH 的默认选项。注意不要设置为 0,设置为 n 则会启动 n 个进程执行测试(似乎也没有太大意义)。fork 选项也可以通过方法注解以及启动参数来设置。

@Param

属性级注解,指定某项参数的多种情况,特别适合用来测试一个函数在不同的参数输入的情况下的性能,只能作用在字段上,使用该注解必须定义 @State 注解。

@Param 注解接收一个 String 数组,在 @Setup 方法执行前转化为对应的数据类型。多个 @Param 注解的成员之间是乘积关系,譬如有两个用 @Param 注解的字段,第一个有 5 个值,第二个字段有 2 个值,那么每个测试方法会跑 5*2=10 次。

@Benchmark

方法注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。

@Setup

方法注解,这个注解的作用就是我们需要在测试之前进行一些准备工作,比如对一些数据的初始化之类的。

@TearDown

方法注解,与 @Setup 相对的,会在所有 benchmark 执行结束以后执行,比如关闭线程池,数据库连接等的,主要用于资源的回收等。

Threads

每个 fork 进程使用多少个线程去执行测试方法,默认值是 Runtime.getRuntime().availableProcessors()。

@Group

方法注解,可以把多个 benchmark 定义为同一个 group,则它们会被同时执行,譬如用来模拟生产者-消费者读写速度不一致情况下的表现。

@Level

用于控制 @Setup,@TearDown 的调用时机,默认是 Level.Trial。

Trial:每个 benchmark 方法前后;

Iteration:每个 benchmark 方法每次迭代前后;

Invocation:每个 benchmark 方法每次调用前后,谨慎使用,需留意 javadoc 注释;

JMH 注意事项

无用代码消除(Dead Code Elimination)

现代编译器是十分聪明的,它们会对代码进行推导分析,判定哪些代码是无用的然后进行去除,这种行为对微基准测试是致命的,它会使你无法准确测试出你的方法性能。

JMH 本身已经对这种情况做了处理,要记住:1. 永远不要写 void 方法;2. 在方法结束返回计算结果。有时候如果需要返回多于一个结果,可以考虑自行合并计算结果,或者使用 JMH 提供的 BlackHole 对象:

/* * This demonstrates Option A: * * Merge multiple results into one and return it. * This is OK when is computation is relatively heavyweight, and merging * the results does not offset the results much. */@Benchmarkpublic double measureRight_1() {    return Math.log(x1) + Math.log(x2);}/* * This demonstrates Option B: * * Use explicit Blackhole objects, and sink the values there. * (Background: Blackhole is just another @State object, bundled with JMH). */@Benchmarkpublic void measureRight_2(Blackhole bh) {    bh.consume(Math.log(x1));    bh.consume(Math.log(x2));}复制代码

再比如下面代码:

@Benchmarkpublic void testStringAdd(Blackhole blackhole) {    String a = "";    for (int i = 0; i < length; i++) {        a += i;    }}复制代码

JVM 可能会认为变量 a 从来没有使用过,从而进行优化把整个方法内部代码移除掉,这就会影响测试结果。

JMH 提供了两种方式避免这种问题,一种是将这个变量作为方法返回值 return a,一种是通过 Blackhole 的 consume 来避免 JIT 的优化消除。

常量折叠(Constant Folding)

常量折叠是一种现代编译器优化策略,例如,i = 320 * 200 * 32,多数的现代编译器不会真的产生两个乘法的指令再将结果储存下来,取而代之的,它们会辨识出语句的结构,并在编译时期将数值计算出来(i = 2,048,000)。

在微基准测试中,如果你的计算输入是可预测的,也不是一个 @State 实例变量,那么很可能会被 JIT 给优化掉。对此,JMH 的建议是:1. 永远从 @State 实例中读取你的方法输入;2. 返回你的计算结果;3. 或者考虑使用 BlackHole 对象;

见如下官方例子:

@State(Scope.Thread)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.NANOSECONDS)public class JMHSample_10_ConstantFold {    private double x = Math.PI;    private final double wrongX = Math.PI;    @Benchmark    public double baseline() {        // simply return the value, this is a baseline        return Math.PI;    }    @Benchmark    public double measureWrong_1() {        // This is wrong: the source is predictable, and computation is foldable.        return Math.log(Math.PI);    }    @Benchmark    public double measureWrong_2() {        // This is wrong: the source is predictable, and computation is foldable.        return Math.log(wrongX);    }    @Benchmark    public double measureRight() {        // This is correct: the source is not predictable.        return Math.log(x);    }    public static void main(String[] args) throws RunnerException {        Options opt = new OptionsBuilder()                .include(JMHSample_10_ConstantFold.class.getSimpleName())                .warmupIterations(5)                .measurementIterations(5)                .forks(1)                .build();        new Runner(opt).run();    }}复制代码

循环展开(Loop Unwinding)

循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。也有利于指令流水线的调度。例如:

for (i = 1; i <= 60; i++)    a[i] = a[i] * b + c;复制代码

可以展开成:

for (i = 1; i <= 60; i+=3){  a[i] = a[i] * b + c;  a[i+1] = a[i+1] * b + c;  a[i+2] = a[i+2] * b + c;}复制代码

由于编译器可能会对你的代码进行循环展开,因此 JMH 建议不要在你的测试方法中写任何循环。如果确实需要执行循环计算,可以结合 @BenchmarkMode(Mode.SingleShotTime) 和 @Measurement(batchSize = N) 来达到同样的效果。参考如下例子:

/* * Suppose we want to measure how much it takes to sum two integers: */int x = 1;int y = 2;/* * This is what you do with JMH. */@Benchmark@OperationsPerInvocation(100)public int measureRight() {    return (x + y);}复制代码

JMH 可视化

在示例的 main 方法中指定了生成测试结果的输出文件 result.json,其中的内容就是控制台输出的相关内容以 json 格式存储。

针对 json 格式的内容,可以在其他网站上以图表的形式可视化展示。

对应网站,JMH Visual Chart(http://deepoove.com/jmh-visual-chart/)、JMH Visualizer(https://jmh.morethan.io/)。

展示效果如下图:

生成 jar 包执行

对于大型的测试,一般会放在 Linux 服务器里去执行。JMH 官方提供了生成 jar 包的方式来执行,在 maven 里增加如下插件:

            org.apache.maven.plugins        maven-shade-plugin        2.4.1                                    package                                    shade                                                    jmh-demo                                                                        org.openjdk.jmh.Main                                                                                    复制代码

执行 maven 的命令生成可执行 jar 包,并执行:

mvn clean packagejava -jar target/jmh-demo.jar JmhTest复制代码

总结

一篇文章几乎涵盖了 JMH 各方面的知识点,如果实践中还没运用,赶紧用起来吧,你的专业水平将又提升那么一点。当然,也可以收藏起来,以备不时不需。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容