如何使用JMH进行简单的benchmark?

前阵子写过两篇东西介绍Java标准库里对ArrayList,HashMap做的小性能优化.

有一个小问题,那些作者是怎么知道,这么改了以后,性能就会变好的呢?

答案是,通过基准测试(BenchMark)

基准测试(Bench Mark)

那两个改动, 都是通过benchmark, 来测量改动带来的影响,衡量性能是否得到了优化.基准测试其实就是计算机里的一个标准化的跑分操作.

不过, 虽然Benchmark 现在是个计算机词汇, 他最早其实是,作为一个标尺存在[1]

如何使用JMH进行简单的benchmark?


https://my.theasianparent.com/height-and-weight-tracker

这玩意的原始用法, 大概类似上图,和在门框上划标记(mark)来看孩子长了多少, 是一样的原理.

如何进行benchmark?

要进行benchmark, 当然是需要工具的.

基础版

日常我们会用for循环加上System.nanoTime()来做,比如这样.

     
long start = System.nanoTime();
for (int i = 0; i < 100000; i++) {
    dosth();
}
long end = System.nanoTime();
System.out.println("time= " + (end - start)/100000);

这么做是可以的, 不过有一个问题是, 过于不稳定了.

Java是解释执行的, 遇到热点会采用JIT来优化.这里面一上来就直接计入时间的话, 代码有没有经过JIT是无法知晓的.对性能对比来说,其实不太公平.

一般会先空跑几个循环, 来把代码热身(warmup).另外,作为一个统计指标,只跑一轮也是不准确的,要不要多跑几轮取平均?跑的话,具体跑几次呢?

这些都是基准测试里的常见问题, 因此, 理所当然,已经被人解决过了.前面提到的两个优化,用的都是官方的benchmark工具JMH.

JMH

JMH 俗称(Java Microbenchmark Harness ), 是openjdk官方出的benchmark工具,详情可见Java Microbenchmark Harness [2].

这东西的使用流程要说明白比较费时间,就不提了.Intellij idea用户可以不求甚解地按照我这个方式来使用:

引入

  1. 先去插件里按照这个JMH插件

如何使用JMH进行简单的benchmark?

  1. 在pom里加入core注解处理器.

     <!--   <jmh.version>1.34</jmh.version> -->
       
    <dependencies>
            <dependency>
                <groupId>org.openjdk.jmh</groupId>
                <artifactId>jmh-core</artifactId>
                <version>${jmh.version}</version>
            </dependency>
            <dependency>
                <groupId>org.openjdk.jmh</groupId>
                <artifactId>jmh-generator-annprocess</artifactId>
                <version>${jmh.version}</version>
            </dependency>
        </dependencies>

测试例子

有了这些之后, 我们编写这样一个类.

public class HelloWorld {
    @Benchmark
    @Fork(1)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @Warmup(iterations = 1,batchSize = 10000)
    @BenchmarkMode({Mode.AverageTime})
    @Measurement(iterations = 1, batchSize = 10000)
    public static void dosth(){
    }
}

执行按钮

如果插件安装正确, 左边会看到执行的按钮.

如何使用JMH进行简单的benchmark?

输出结果

输出很长,这是截取的部分.

Benchmark         Mode  Cnt     Score   Error  Units
HelloWorld.dosth  avgt       4633.679          ns/op

可以看到, 一次操作平均需要4633纳秒.

统计模式

前面输出结果的Modeavgt,表示平均时间.对应上面的注解@BenchmarkMode({Mode.AverageTime}).

如果把这个注解改变,变成@BenchmarkMode({Mode.Throughput}),再跑一遍会得到新的结果,得到单位时间内,可以运行的次数Throughput,俗称吞吐量.

Benchmark          Mode  Cnt   Score   Error   Units
HelloWorld.dosth  thrpt       ≈ 10⁻⁴          ops/ns

时间单位

前面的输出,每纳秒函数要跑 次…这好像有点太低了.  毕竟, 这是按照纳秒算的,对应注解@OutputTimeUnit(TimeUnit.NANOSECONDS).

把这个设置切换为@OutputTimeUnit(TimeUnit.SECONDS),重新跑可以得到输出.

Benchmark          Mode  Cnt       Score   Error  Units
HelloWorld.dosth  thrpt       204916.423          ops/s

其他设置

前面两个例子已经看出来了, 可设置的项目很多,就不一一演示输出结果了.

  • @Measurement(iterations = 1, batchSize = 10000) 跑1轮,1万次.
  • @Warmup(iterations = 1,batchSize = 10000) 暖1轮,1万次.
  • @Fork(1) 只跑一份测试

最后,前面说的这些注解,其实都是可以去掉的,只添加一个@Benchmark就可以,别的会走默认值,可以自己试试看.

小试牛刀

既然有了这么好用的工具, 可以拿来验证一些东西试试看了.

for i 循环处理ArrayList比foreach快?

曾经在一个地方看过这个说法, ArrayListRandomAccess的,foreachiterator的语法糖. 迭代器用在ArrayList这样的地方比较慢, 要用for i才好. 那么这是真的吗?

我们准备这样3个测试:

@State(Scope.Benchmark)
public class ForLoop {
    List<Integer> arr = IntStream.rangeClosed(11000000).boxed().collect(Collectors.toList());
    @Benchmark
    public void foropt() {
        long sum = 0;
        for (int i = 0, size = arr.size(); i < size; i++) {
            sum += arr.get(i);
        }
    }

    @Benchmark
    public void fori() {
        long sum = 0;
        for (int i = 0; i < arr.size(); i++) {
            sum += arr.get(i);
        }
    }

    @Benchmark
    public void foreach() {
        long sum = 0;
        for (Integer i : arr) {
            sum += i;
        }
    }
}

除了fori 和foreach外,外加一个fori 提取出size()的分支, 结果如下:

Benchmark         Mode  Cnt    Score    Error  Units
ForLoop.foreach  thrpt   25  792.805 ± 53.592  ops/s
ForLoop.fori     thrpt   25  830.634 ±  7.772  ops/s
ForLoop.foropt   thrpt   25  819.431 ± 24.805  ops/s

fori确实比foreach快, 然而, 只快了 ,而且考虑到foreach的Error(误差)有 之大,这 有多大意义也不好说.

不过有一点让人意外的是,fori 提取出size()方法, 居然比直接fori要慢, 看来以后就不需要在意是否动size了.

ArrayList构造器指定size可以减少扩容时间

这个应该是真的, 但是具体能减少多少,这事不好说,我们来测一下.

public class ArrayListInit {
    @Benchmark
    @Fork(1)
    @Measurement(batchSize = 100)
    @Warmup(batchSize = 100)
    public void nosize() {
        ArrayList<Integer> arr = new ArrayList<>();
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            arr.add(i);
        }
    }

    @Benchmark
    @Fork(1)
    @Measurement(batchSize = 100)
    @Warmup(batchSize = 100)
    public void withsize() {
        ArrayList<Integer> arr = new ArrayList<>(Short.MAX_VALUE);
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            arr.add(i);
        }
    }
}

(结果仅供娱乐, 这玩意跑着太慢了, 这里调小了轮次,不然今天这文写不完了)

Benchmark                Mode  Cnt   Score   Error  Units
ArrayListInit.nosize    thrpt    5  51.515 ± 1.052  ops/s
ArrayListInit.withsize  thrpt    5  72.811 ± 3.868  ops/s

3万级别的添加, 看起来有构造器的快了40%左右.

循环中用StringBuilder拼接字符串比用+

To increase the performance of repeated string concatenation, a Java compiler may use the StringBuffer class or a similar technique to reduce the number of intermediate String objects that are created by evaluation of an expression.
https://docs.oracle.com/javase/specs/jls/se7/html/jls-15.html#jls-15.18.1

大家都知道, 在Java7以后, 编译器会自动用StringBuilder来优化+拼接的字符串.但是有一个例外, for循环里拼接的会反复生成StringBuilder导致速度偏慢.

今天来验证下是不是真的, 这个会慢多少.另外,同时试一下,用StrinBuffer会慢还是会快.

public class StringJoin {
    @Benchmark
    @Fork(1)
    @Measurement(iterations = 1,batchSize = 100)
    @Warmup(iterations = 1, batchSize = 100)
    public void add() {
        String s= "";
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            s+=i;
        }
    }

    @Benchmark
    @Fork(1)
    @Measurement(iterations = 1,batchSize = 100)
    @Warmup(iterations = 1, batchSize = 100)
    public void sb() {
        StringBuilder sb= new StringBuilder();
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            sb.append(i);
        }
    }

    @Benchmark
    @Fork(1)
    @Measurement(iterations = 1,batchSize = 100)
    @Warmup(iterations = 1, batchSize = 100)
    public void bf() {
        StringBuffer sb= new StringBuffer();
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            sb.append(i);
        }
    }
}

结果很明显,直接add确实是挺慢的,这都有几个数量级的差别了.

Benchmark         Mode  Cnt   Score   Error  Units
StringJoin.add   thrpt        0.029          ops/s
StringJoin.bf    thrpt       17.942          ops/s
StringJoin.sb    thrpt       18.073          ops/s

StringBuilder 和StringBuffer居然一样快?

刚刚的实验里, StringBuffer基本和StringBuilder一个速度.

那就不对了,都知道StringBuffer因为有锁会慢,才出的StringBuilder这个单线程版本.前阵子还专门写过一篇 为什么StringBuilder是线程不安全的? 来聊这个.这是为什么?

其实是这样的,StringBuffer有锁, 跟锁很慢. 这是两件事

  1. StringBuilder是从去掉锁这个角度解决问题的.这个改动发生在1.5
  2. 还有另一个思路,是让锁不再慢.synchronized的优化,发生在1.6以后.

这两个优化,分别从两个方向解决了StringBuffer慢这个问题.

下面试一下,通过-UseBiasedLocking关掉优化,来对比下两种情况下StringBuffer的性能差别.

public class StringBufferLock {
    @Benchmark
    @Fork(value = 1,jvmArgsAppend = "-XX:+UseBiasedLocking")
    @Measurement(iterations = 1,batchSize = 100)
    @Warmup(iterations = 1, batchSize = 100)
    public void biased() {
        StringBuffer sb= new StringBuffer();
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            sb.append(i);
        }
    }

    @Benchmark
    @Fork(value = 1,jvmArgsAppend = "-XX:-UseBiasedLocking")
    @Measurement(iterations = 1,batchSize = 100)
    @Warmup(iterations = 1, batchSize = 100)
    public void nobiased() {
        StringBuffer sb= new StringBuffer();
        for (int i = 0; i < Short.MAX_VALUE; i++) {
            sb.append(i);
        }
    }
}

这个结果不太准,不过基本能看出来, 关了偏向锁定的实现基本差了一半吞吐.

Benchmark                   Mode  Cnt   Score   Error  Units
StringBufferLock.biased    thrpt       14.944          ops/s
StringBufferLock.nobiased  thrpt        8.801          ops/s

现在的StringBuffer能做到和StringBuilder一样快,这里面就有锁优化和偏向锁定(biased locking)的功劳了.

对了, 如果不熟悉偏向锁定这一技术的话,报告一个好消息.偏向锁定Java15以后JEP 374废除了[3] ,可以不用背相关面试题了.

伪共享(False Sharing)

最后一个实验,来玩下常见的伪共享问题.

计算机CPU访问内存,是非常慢的.所以中间加了L1,L2,L3等多层缓存(Cache),来加快访问.然而有缓存,就有失效问题.

Cache的读取粒度是缓存行(Cache Line).如果两个变量位于同一个cacheline,一个变量就会遇到因另一个变量写入而导致的缓存失效, 拖慢访问.这个就是伪共享.

这个问题解决起来也很简单, 让两个变量分开住就好,俗称padding.

准备两组值ab.

  • a是只读的, 永远是0, 理论上读取应该非常快.
  • b是在不断变化的.

表面看,理论上a绝对不会被b的写入所影响.但是如果二者位于同一个缓存行,a的读取会被伪共享所影响,明显变慢.

现在,给一组加上从这篇文章学到的@Contended.有了这个注解,JVM会自动进行padding(填充)一些字节,让两个变量分开住,不再位于同一个缓存行.

下面可以来试试了,有和没有伪共享,性能差多少.

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
@Fork(jvmArgsAppend = "-XX:-RestrictContended")
public class FalseSharing {
    @Benchmark
    @Group("sharing")
    public int r(Sharing s) {
        return s.a;
    }

    @Benchmark
    @Group("sharing")
    public void w(Sharing s) {
        s.b++;
    }

    @Benchmark
    @Group("padding")
    public int r(Padding s) {
        return s.a;
    }

    @Benchmark
    @Group("padding")
    public void w(Padding s) {
        s.b++;
    }

    @State(Scope.Group)
    public static class Sharing {
        int a;
        int b;
    }

    @State(Scope.Group)
    public static class Padding {
        @Contended
        int a;
        @Contended
        int b;
    }

}

分两组来运行这个测试, padding组sharing组,运行结果如下

Benchmark                Mode  Cnt          Score          Error  Units
FalseSharing.padding    thrpt    5  858755339.504 ± 20817646.761  ops/s
FalseSharing.padding:r  thrpt    5  400798196.148 ± 15187778.989  ops/s
FalseSharing.padding:w  thrpt    5  457957143.356 ±  6560755.220  ops/s
FalseSharing.sharing    thrpt    5  550583294.132 ± 26454593.710  ops/s
FalseSharing.sharing:r  thrpt    5   70995374.633 ± 14013970.477  ops/s
FalseSharing.sharing:w  thrpt    5  479587919.499 ± 34979714.531  ops/s

这里可以看到,sharing组,读取速度明显慢的不像话,他可只是在读变量啊,确实发生了false sharing.

padding组因为 @Contended注解的存在, 自动进行了padding操作, 两个变量不再共享同一个缓存行,明显读取速度提高了一个数量级.

后话

看到这, 基本上JMH的基础用法应该就算是入门了.如果想进一步熟悉, 还是直接看github, 上面有很多详实专业的例子.

另外, 本文的测试参数都是瞎写的,严重不准,仅供娱乐.

参考资料

[1]

作为一个标尺存在: https://www.merriam-webster.com/dictionary/benchmark

[2]

Java Microbenchmark Harness : https://github.com/openjdk/jmh

[3]

偏向锁定Java15以后JEP 374废除了: https://openjdk.java.net/jeps/374

如何使用JMH进行简单的benchmark?


原文始发于微信公众号(K字的研究):如何使用JMH进行简单的benchmark?

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/24777.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!