Java高并发编程详解:深入理解并发核心库
上QQ阅读APP看书,第一时间看更新

1.3 JMH的基本用法

本节将学习JMH的基本用法,让读者彻底掌握如何使用这套工具集进行代码基准测试的操作。

1.3.1 @Benchmark标记基准测试方法

与Junit4.x版本需要使用@Test注解标记单元测试方法一样,JMH对基准测试的方法需要使用@Benchmark注解进行标记,否则方法将被视为普通方法,并且不会对其执行基准测试。如果一个类中没有任何基准测试方法(被@Benchmark标记的方法),那么对其进行基准测试则会出现异常。下面的代码虽然被include为需要执行基准测试的class,但是并没有一个方法被标注为@Benchmark。

程序代码:JMHExample02.java


package com.wangwenjun.concurrent.jmh;

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;

/**
 * This class not contains any method that be annotated by @Benchmark
 */
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class JMHExample02
{

    /**
     * normal instance method.
     */
    public void normalMethod()
    {
    }
    public static void main(String[] args)
        throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample02.class.getSimpleName())
                .forks(1)
                .measurementIterations(10)
                .warmupIterations(10)
                .build();
        new Runner(opts).run();
    }
}

在JMHExample02中,并没有一个被@Benchmark标记的方法,运行该类的时候会出现异常,如下所示。

程序输出:JMHExample02.java


Exception in thread "main" No benchmarks to run; check the incl ude/exclude regexps.
    at org.openjdk.jmh.runner.Runner.internalRun(Runner.java:261)
    at org.openjdk.jmh.runner.Runner.run(Runner.java:206)
    at com.wangwenjun.concurrent.jmh.JMHExample02.main(JMHExample02.java:35)
        因此请务必使用@Benchmark 标记需要进行基准测试的方法。

1.3.2 Warmup以及Measurement

Warmup以及Measurement的比较与1.2.1节中的ArrayListVSLinkedList的思路是一样的,主要是分批次地执行基准测试方法。在每个批次中,调用基准测试方法的次数受两个因素影响,第一,要根据相关的参数进行设置,第二则是根据该方法具体的CPU时间而定,但是通常情况下,我们更多关注批次数量即可。

Warmup可直译为“预热”的意思,在JMH中,Warmup所做的就是在基准测试代码正式度量之前,先对其进行预热,使得代码的执行是经历过了类的早期优化、JVM运行期编译、JIT优化之后的最终状态,从而能够获得代码真实的性能数据。Measurement则是真正的度量操作,在每一轮的度量中,所有的度量数据会被纳入统计之中(预热数据不会纳入统计之中)。好了,下面我们来看看Warmup和Measurement的用法。

1. 设置全局的Warmup和Measurement

设置全局的Warmup和Measurement执行批次,既可以通过构造Options时设置,也可以在对应的class上用相应的注解进行设置。

(1)构造Options时设置Warmup和Measurement的执行批次


final Options opts = new OptionsBuilder()
        .include(JMHExample03.class.getSimpleName())
        .forks(1)
        .measurementIterations(5) // 度量执行的批次为5,也就是
        // 说在这5个批次中,对基准方法的执行与调用将会纳入统计
        .warmupIterations(3)      // 在真正的度量之前,首先会对代码进行3个批次的热身,
        // 使代码的运行达到JVM已经优化的效果
        .build();
new Runner(opts).run();

除了在构造Options时设置Warmup和Measurement,我们还可以通过注解的方式指定预热和度量各自的批次。

(2)使用@Measurement和@Warmup注解进行设置


@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Measurement(iterations = 5) // 度量5个批次

@Warmup(iterations = 3)      // 预热3个批次
public class JMHExample03

2. 在基准测试方法上设置Warmup和Measurement

我们除了可以设置全局的Warmup和Measurement参数之外,还可以在方法上设置对应基准测试方法的批次参数。

在test2方法上设置Measurement和Warmup


@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Measurement(iterations = 5)
@Warmup(iterations = 2)
public class JMHExample03
{

    @Benchmark
    public void test()
            throws InterruptedException
    {
        TimeUnit.MILLISECONDS.sleep(10);
    }

    /**
    * 预热5个批次
    * 度量10个批次
    */
    @Measurement(iterations = 10)
    @Warmup(iterations = 5)
    @Benchmark
    public void test2()
            throws InterruptedException
    {
        TimeUnit.MILLISECONDS.sleep(1);
    }

运行基准测试我们会发现,test()基准方法执行了2个批次的预热和5个批次的度量,而test2()方法则执行了10个批次的度量和5个批次的预热操作,也就是说test2通过注解的方式覆盖了全局的设置。


Benchmark           Mode  Cnt   Score    Error  Units
JMHExample03.test   avgt    5  10.228 ± 0.676   ms/op
JMHExample03.test2  avgt   10   1.146 ± 0.080   ms/op

注意

笔者经过测试发现,通过类注解的方式设置的全局Measurement和Warmup参数是可以被基准测试方法通过同样的方式覆盖的,但是通过Options进行的全局设置则无法被覆盖,也就是说,通过Options设置的参数会应用于所有的基准测试方法且无法被修改(当然不同的版本可能会存在差异)。

3. Warmup和Measurement执行相关的输出

每次执行微基准测试都会输出有关Warmup和Measurement的详细信息,笔者对下面的输出信息增加了中文的描述(下面选取了test()方法的微基准测试执行输出):


# 使用的JMH版本是1.19
# JMH version: 1.19
#下面是JDK的版本信息
# VM version: JDK 1.8.0_40-ea, VM 25.40-b11
# Java命令的目录
# VM invoker: D:\Program Files\Java\jdk1.8.0_40\jre\bin\java.exe
# JVM运行时指定的参数
# VM options: -javaagent:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2017.2\lib\idea_rt.jar=53261:C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2017.2\bin -Dfile.encoding=UTF-8
# 热身的批次为2,每一个批次都将会不断地调用test方法,每一个批次的执行时间均为1秒
# Warmup: 2 iterations, 1 s each
# 真正度量的批次为5,这5个批次的调用产生的性能数据才会真正地纳入统计中,同样每一个批次的度量执行的时间也为1秒
# Measurement: 5 iterations, 1 s each
# 每一个批次的超时时间(在后文中还会继续解释)
# Timeout: 10 min per iteration
# 执行基准测试的线程数量
# Threads: 1 thread, will synchronize iterations
# Benchmark 的Mode,这里表明统计的是方法调用一次所耗费的单位时间
# Benchmark mode: Average time, time/op
# Benchmark方法的绝对路径
# Benchmark: com.wangwenjun.concurrent.jmh.JMHExample03.test
# 执行进度
# Run progress: 0.00% complete, ETA 00:00:22
# Fork: 1 of 1
# 执行两个批次的热身,第一批次调用方法的平均耗时为11.003毫秒,第二批次调用方法的平均耗时为10.306毫秒
# Warmup Iteration   1: 11.003 ms/op
# Warmup Iteration   2: 10.306 ms/op
#执行五个批次的度量
Iteration   1: 10.165 ms/op
Iteration   2: 10.404 ms/op
Iteration   3: 10.056 ms/op
Iteration   4: 10.426 ms/op
Iteration   5: 10.087 ms/op

#最终的统计结果
Result "com.wangwenjun.concurrent.jmh.JMHExample03.test":
    10.228 ±(99.9%) 0.676 ms/op [Average]
    #最小、平均、最大以及标准误差
  (min, avg, max) = (10.056, 10.228, 10.426), stdev = 0.176
  CI (99.9%): [9.551, 10.904] (assumes normal distribution)

1.3.3 四大BenchmarkMode

JMH使用@BenchmarkMode这个注解来声明使用哪一种模式来运行,JMH为我们提供了四种运行模式,当然它还允许若干个模式同时存在,在笔者看来,Mode无非就是统计基准测试数据的不同方式和纬度口径,本节将逐一探讨JMH为我们提供的四种运行模式。

1. AverageTime

AverageTime(平均响应时间)模式在前文中已经出现过几次了,它主要用于输出基准测试方法每调用一次所耗费的时间,也就是elapsed time/operation。

程序代码:AverageTime Mode


@BenchmarkMode(Mode.AverageTime)
    @Benchmark
    public void testAverageTime() throws InterruptedException
    {
        TimeUnit.MILLISECONDS.sleep(1);
    }

运行上面的基准测试方法,我们会得出如下的测试结果。

程序输出:AverageTime Mode


Benchmark                     Mode  Cnt  Score   Error  Units
JMHExample04.testAverageTime  avgt    5  1.228 ± 0.971  ms/op

testAverageTime方法的平均执行耗时为1.228毫秒。

2. Throughput

Throughput(方法吞吐量)则刚好与AverageTime相反,它的输出信息表明了在单位时间内可以对该方法调用多少次。

程序代码:Throughput Mode


@BenchmarkMode(Mode.Throughput)
@Benchmark
public void testThroughput() throws InterruptedException
{
    TimeUnit.MILLISECONDS.sleep(1);
}

运行上面的基准测试方法,我们将会得到如下的统计结果。

程序输出:Throughput Mode


Benchmark                     Mode  Cnt  Score   Error   Units
JMHExample04.testThroughput  thrpt    5  0.818 ± 0.673  ops/ms

可以看到在1毫秒内,testThroughput方法只会被调用0.818次。

3. SampleTime

SampleTime(时间采样)的方式是指采用一种抽样的方式来统计基准测试方法的性能结果,与我们常见的Histogram图(直方图)几乎是一样的,它会收集所有的性能数据,并且将其分布在不同的区间中。

程序代码:SimpleTime Mode


@BenchmarkMode(Mode.SampleTime)
@Benchmark
public void testSampleTime() throws InterruptedException
{
    TimeUnit.MILLISECONDS.sleep(1);
}

运行上面的结果我们会看到非常多的统计数据,具体如下所示。

程序输出:SimpleTime Mode


  Histogram, ms/op:
    [ 0.000,  2.500) = 4452
    [ 2.500,  5.000) = 68
    [ 5.000,  7.500) = 9
    [ 7.500, 10.000) = 5
    [10.000, 12.500) = 3
    [12.500, 15.000) = 3
    [15.000, 17.500) = 0
    [17.500, 20.000) = 2
    [20.000, 22.500) = 0
    [22.500, 25.000) = 2
    [25.000, 27.500) = 0
    [27.500, 30.000) = 0
    [30.000, 32.500) = 0
    [32.500, 35.000) = 0
    [35.000, 37.500) = 0

Percentiles, ms/op:
     p(0.0000) =       0.041 ms/op
     p(50.0000) =      0.996 ms/op
     p(90.0000) =      1.103 ms/op
     p(95.0000) =      1.485 ms/op
     p(99.0000) =      3.255 ms/op
     p(99.9000) =     17.721 ms/op
     p(99.9900) =     38.601 ms/op
     p(99.9990) =     38.601 ms/op
     p(99.9999) =     38.601 ms/op
    p(100.0000) =     38.601 ms/op

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

Benchmark                                             Mode   Cnt   Score   Error  Units
JMHExample04.testSampleTime                         sample  4545   1.100 ± 0.051  ms/op
JMHExample04.testSampleTime:testSampleTime·p0.00    sample         0.041          ms/op
JMHExample04.testSampleTime:testSampleTime·p0.50    sample         0.996          ms/op
JMHExample04.testSampleTime:testSampleTime·p0.90    sample         1.103          ms/op
JMHExample04.testSampleTime:testSampleTime·p0.95    sample         1.485          ms/op
JMHExample04.testSampleTime:testSampleTime·p0.99    sample         3.255          ms/op
JMHExample04.testSampleTime:testSampleTime·p0.999   sample        17.721          ms/op
JMHExample04.testSampleTime:testSampleTime·p0.9999  sample        38.601          ms/op
JMHExample04.testSampleTime:testSampleTime·p1.00    sample        38.601          ms/op

从输出结果中不难发现,对testSampleTime方法总共进行了4545次的调用,该方法的平均响应时间为1.100毫秒,并且有4452次的性能数据落点在0~2.5毫秒这个区间之中。

4. SingleShotTime

SingleShotTime主要可用来进行冷测试,不论是Warmup还是Measurement,在每一个批次中基准测试方法只会被执行一次,一般情况下,我们会将Warmup的批次设置为0。

程序代码:SingleShotTime Mode


@Warmup(iterations = 0)
@BenchmarkMode(Mode.SingleShotTime)
@Benchmark
public void testSingleShotTime() throws InterruptedException
{
    TimeUnit.MILLISECONDS.sleep(1);
}

运行上面的基准测试代码,与SampleTime非常类似,Single Shot Time也是采用Histogram的方式进行统计的。

程序输出:SingleShotTime Mode


    Histogram, ms/op:
    [0.200, 0.250) = 0
    [0.250, 0.300) = 1
    [0.300, 0.350) = 0
    [0.350, 0.400) = 0
    [0.400, 0.450) = 1
    [0.450, 0.500) = 1
    [0.500, 0.550) = 0
    [0.550, 0.600) = 0
    [0.600, 0.650) = 0
    [0.650, 0.700) = 0
    [0.700, 0.750) = 0
    [0.750, 0.800) = 0
    [0.800, 0.850) = 0
    [0.850, 0.900) = 0
    [0.900, 0.950) = 1
    [0.950, 1.000) = 0
    [1.000, 1.050) = 0

Percentiles, ms/op:
     p(0.0000)  =      0.257 ms/op
     p(50.0000) =      0.486 ms/op
     p(90.0000) =      1.052 ms/op
     p(95.0000) =      1.052 ms/op
     p(99.0000) =      1.052 ms/op
     p(99.9000) =      1.052 ms/op
     p(99.9900) =      1.052 ms/op
     p(99.9990) =      1.052 ms/op
     p(99.9999) =      1.052 ms/op
    p(100.0000) =      1.052 ms/op

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

Benchmark                        Mode  Cnt  Score   Error  Units
JMHExample04.testSingleShotTime    ss    5  0.629 ± 1.328  ms/op

5. 多Mode以及All

我们除了对某个基准测试方法设置上述四个模式中的一个之外,还可以为其设置多个模式的方式运行基准测试方法,如果你愿意,甚至可以设置全部的Mode。

程序代码:SingleShotTime Mode


@BenchmarkMode({Mode.AverageTime, Mode.Throughput})
@Benchmark
public void testThroughputAndAverageTime()
            throws InterruptedException
{
    TimeUnit.MILLISECONDS.sleep(1);
}

@BenchmarkMode(Mode.All)
@Benchmark
public void testAll()
            throws InterruptedException
{
    TimeUnit.MILLISECONDS.sleep(1);
}

BenchmarkMode既可以在class上进行注解设置,也可以在基准方法上进行注解设置,方法中设置的模式将会覆盖class注解上的设置,同样,在Options中也可以进行设置,它将会覆盖所有基准方法上的设置。

1.3.4 OutputTimeUnit

OutputTimeUnit提供了统计结果输出时的单位,比如,调用一次该方法将会耗费多少个单位时间,或者在单位时间内对该方法进行了多少次的调用,同样,OutputTimeUnit既可以设置在class上,也可以设置在method上,还可以在Options中进行设置,它们的覆盖次序与BenchmarkMode一致,这里就不再赘述了。

程序代码:JMHExample05.java


package com.wangwenjun.concurrent.jmh;

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;
// 在class上设置
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Measurement(iterations = 5)
@Warmup(iterations = 2)
public class JMHExample05
{
    // 在基准方法上设置
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    @Benchmark
    public void test()
            throws InterruptedException
    {
        TimeUnit.SECONDS.sleep(1);
    }

    public static void main(String[] args) throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample05.class.getSimpleName())
                // 在Options上设置
                .timeUnit(TimeUnit.NANOSECONDS)
                .forks(1)
                .build();
        new Runner(opts).run();
    }
}

1.3.5 三大State的使用

在JMH中,有三大State分别对应于Scope的三个枚举值。

▪ Benchmark

▪ Thread

▪ Group

这三个Scope是非常重要的State,在本节中,我们将对其逐一进行介绍,然后分析掌握每一个State的特性。

1. Thread独享的State

所谓线程独享的State是指,每一个运行基准测试方法的线程都会持有一个独立的对象实例,该实例既可能是作为基准测试方法参数传入的,也可能是运行基准方法所在的宿主class,将State设置为Scope.Thread一般主要是针对非线程安全的类。

程序代码:JMHExample06.java


package com.wangwenjun.concurrent.jmh;

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;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// 设置5个线程运行基准测试方法
@Threads(5)
public class JMHExample06
{
    // 5个运行线程,每一个线程都会持有一个Test的实例
    @State(Scope.Thread)
    public static class Test
    {
        public Test()
        {
            System.out.println("create instance");
        }

        public void method()
        {
        }
    }

   // 通过基准测试将State引用传入
    @Benchmark
    public void test(Test test)
    {
        test.method();
    }

    public static void main(String[] args) throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample06.class.getSimpleName())
                .build();
        new Runner(opts).run();
    }
}

运行上面的程序,我们会看到“create instance”字样出现了5次,由于此处不想占用太多篇幅,因此下面的程序输出将只展示关键的地方。

程序输出:JMHExample06.java


.................省略
# Threads: 5 threads, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.wangwenjun.concurrent.jmh.JMHExample06.test

# Run progress: 0.00% complete, ETA 00:00:15
# Fork: 1 of 1
# Warmup Iteration   1: create instance
create instance
create instance
create instance
create instance
0.003 ±(99.9%) 0.004 us/op
# Warmup Iteration   2: 0.003 ±(99.9%) 0.007 us/op
# Warmup Iteration   3: 0.003 ±(99.9%) 0.004 us/op
.................省略

2. Thread共享的State

有时候,我们需要测试在多线程的情况下某个类被不同线程操作时的性能,比如,多线程访问某个共享数据时,我们需要让多个线程使用同一个实例才可以。因此JMH提供了多线程共享的一种状态Scope.Benchmark,下面来看具体的示例。

程序代码:JMHExample07.java


package com.wangwenjun.concurrent.jmh;

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;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// 设置5个线程运行基准测试方法
@Threads(5)
public class JMHExample07
{
// Test的实例将会被多个线程共享,也就是说只有一份Test的实例
    @State(Scope.Benchmark)
    public static class Test
    {
        public Test()
        {
            System.out.println("create instance");
        }

        public void method()
        {
        }
    }

   // 通过基准测试将State引用传入
    @Benchmark
    public void test(Test test)
    {
        test.method();
    }

    public static void main(String[] args) throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample07.class.getSimpleName())
                .build();
        new Runner(opts).run();
    }
}

运行上面的程序,我们会看到“create instance”字样只出现了1次,由于此处不想占用太多篇幅,因此下面的程序输出将只展示关键的地方。

程序输出:JMHExample07.java


................省略
# Threads: 5 threads, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.wangwenjun.concurrent.jmh.JMHExample07.test

# Run progress: 0.00% complete, ETA 00:00:15
# Fork: 1 of 1
# Warmup Iteration   1: create instance
0.004 ±(99.9%) 0.005 us/op
.................省略

3. 线程组共享的State

截至目前,我们所编写的基准测试方法都会被JMH框架根据方法名的字典顺序排序后按照顺序逐个地调用执行,因此不存在两个方法同时运行的情况,如果想要测试某个共享数据或共享资源在多线程的情况下同时被读写的行为,是没有办法进行的,比如,在多线程高并发的环境中,多个线程同时对一个ConcurrentHashMap进行读写。

通过上面这段文字可以简单地归纳出我们的诉求,第一,是在多线程情况下的单个实例;第二,允许一个以上的基准测试方法并发并行地运行。

所幸的是,Scope.Group可以帮助我们实现这一点,先来看一个简单的例子,后文在针对BlockingQueue进行测试时,还会使用Scope.Group来实现,所以在本节中,我们知道其大概的用法即可。

程序代码:JMHExample08.java


package com.wangwenjun.concurrent.jmh;

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;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class JMHExample08
{
    // 将Test设置为线程组共享的
    @State(Scope.Group)
    public static class Test
    {
        public Test()
        {
            System.out.println("create instance");
        }

        public void write()
        {
            System.out.println("write");
        }

        public void read()
        {
            System.out.println("read");
        }
    }

    // 在线程组"test"中,有三个线程将不断地对Test实例的write方法进行调用
    @GroupThreads(3)
    @Group("test")
    @Benchmark
    public void testWrite(Test test)
{
        // 调用write方法
        test.write();
    }

    // 在线程组"test"中,有三个线程将不断地对Test实例的read方法进行调用
    @GroupThreads(3)
    @Group("test")
    @Benchmark
    public void testRead(Test test)
{
        // 调用read方法
        test.read();
    }

    public static void main(String[] args) throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample08.class.getSimpleName())
                .build();
        new Runner(opts).run();
    }
}

执行上面的基准测试,我们会得到一些比较关键的信息输出,由于篇幅有限,下面只展示部分输出,并且笔者在输出的信息上添加了相关的说明以便理解。

程序输出:JMHExample07.java


................省略
# Warmup: 5 iterations, 1 s each
# Measurement: 10 iterations, 1 s each
# Timeout: 10 min per iteration
总共6个线程会执行基准测试方法,这6个线程都在同一个group中,其中,testRead方法会被3个线程执行,testWrite方法会被3个线程执行
# Threads: 6 threads (1 group; 3x "testRead", 3x "testWrite" in each group), will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.wangwenjun.concurrent.jmh.JMHExample08.test

# Run progress: 0.00% complete, ETA 00:00:15
# Fork: 1 of 1
# Warmup Iteration   1: create instance
read和write分别交替输出,因此testRead和testWrite是交替执行的
.................
read
read
read
write
write
.................
write
write
read
read
read
read
.................

Benchmark                    Mode  Cnt  Score    Error  Units
JMHExample08.test            avgt   10  0.003 ±  0.001  us/op
JMHExample08.test:testRead   avgt   10  0.003 ±  0.001  us/op
JMHExample08.test:testWrite  avgt   10  0.003 ±  0.001  us/op

1.3.6 @Param的妙用

假设你在编写代码的过程中需要用到一个Map容器,第一,需要保证使用过程中线程的安全性,第二,该容器需要有比较好的性能,比如,执行put方法最快,执行get方法最快等。作为Java程序员,JDK可供我们选择的方案其实有不少,比如ConcurrentHashMap、Hashtable、ConcurrentSkipListMap以及SynchronizedMap等,虽然它们都能够保证在多线程操作下的数据一致性,但是各自的性能表现又是怎样的呢?这就需要我们对其进行微基准测试(我们的测试相对来说比较片面,只在多线程的情况下对其进行put操作,也就是说并未涉及读取以及删除的操作)。

1. 对比ConcurrentHashMap和SynchronizedMap的性能

根据前面所学的知识,我们只需要写两个基准测试方法,其中第一个针对ConcurrentHashMap,第二个针对SynchronizedMap即可,代码如下所示。

程序代码:JMHExample09.java


package com.wangwenjun.concurrent.jmh;

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.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// 5个线程同时对共享资源进行操作
@Threads(5)
// 设置为线程间共享的资源
@State(Scope.Benchmark)
public class JMHExample09
{

    private Map<Long, Long> concurrentMap;
    private Map<Long, Long> synchronizedMap;

    // 关于Setup详见1.3.7节
    @Setup
    public void setUp()
    {
        concurrentMap = new ConcurrentHashMap<>();
        synchronizedMap = Collections.synchronizedMap(
                          new HashMap<>());
    }

    @Benchmark
    public void testConcurrencyMap()
    {
        this.concurrentMap.put(System.nanoTime(),
                               System.nanoTime());
    }

    @Benchmark
    public void testSynchronizedMap()
    {
        this.synchronizedMap.put(System.nanoTime(),
                        System.nanoTime());
    }

public static void main(String[] args)
                throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample09.class.getSimpleName())
                .build();
        new Runner(opts).run();
    }
}

上面的代码足够简单,关键的地方笔者也添加了注释进行说明,那么我们来看一下最终这两个方法所得出的结果会是怎样的呢?

程序输出:JMHExample09.java


Benchmark                         Mode  Cnt    Score      Error  Units
JMHExample09.testConcurrencyMap   avgt   10   26.209 ±   40.517  us/op
JMHExample09.testSynchronizedMap  avgt   10  316.254 ± 1451.240  us/op

通过基准测试,我们不难发现,ConcurrentHashMap比SynchronizedMap的表现要优秀很多(在多线程同时对其进行put操作时)。

2. 使用@Param

正如本节开始时所说的那样,Java提供的具备线程安全的Map接口实现并非只有ConcurrentHashMap和SynchronizedMap,同样,ConcurrentSkipListMap和Hashtable也可供我们选择,如果我们要对其进行测试,那么这里需要再增加两个不同类型的Map和两个针对这两个Map实现的基准测试方法。但是很显然,这种方式存在大量的代码冗余,因此JMH为我们提供了一个@Param的注解,它使得参数可配置,也就是说一个参数在每一次的基准测试时都会有不同的值与之对应。

程序代码:JMHExample10.java


package com.wangwenjun.concurrent.jmh;

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.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// 5个线程通知对共享资源进行操作
@Threads(5)
// 多个线程使用同一个实例
@State(Scope.Benchmark)
public class JMHExample10
{

    // 为type提供了四种可配置的参数值
    @Param({"1", "2", "3", "4"})
    private int type;

    private Map<Long, Long> map;

    @Setup
    public void setUp()
    {
        switch (type)
        {
            case 1:
                this.map = new ConcurrentHashMap<>();
                break;
            case 2:
                this.map = new ConcurrentSkipListMap<>();
                break;
            case 3:
                this.map = new Hashtable<>();
                break;
            case 4:
                this.map = Collections.synchronizedMap(
                                    new HashMap<>());
                break;
            default:
                throw new IllegalArgumentException("Illegal map type.");
        }
    }

   // 只需要一个基准测试方法即可
    @Benchmark
    public void test()
    {
        this.map.put(System.nanoTime(), System.nanoTime());
    }

    public static void main(String[] args) throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample10.class.getSimpleName())
                .build();
        new Runner(opts).run();
    }
}

如上述代码所示,由于引进了@Param对变量的可配置化,因此我们只需要写一个基准测试方法即可,JMH会根据@Param所提供的参数值,对test方法分别进行基准测试的运行与统计,这样我们就不需要为每一个map容器都写一个基准测试方法了。

在setUp方法中,我们分别实例化了四种不同类型的Map实现类,分别对应于@Param的不同参数。Param与不同类型Map的对应关系具体见表1-1。

表1-1 Param与不同类型Map的对应关系

运行上面的基准测试,我们会发现输出结果中多了type这样一列信息。

程序输出:JMHExample10.java


Benchmark          (type)  Mode  Cnt   Score     Error  Units
JMHExample10.test       1  avgt    5  25.787 ± 143.674  us/op
JMHExample10.test       2  avgt    5  20.273 ± 121.357  us/op
JMHExample10.test       3  avgt    5  42.431 ± 170.285  us/op
JMHExample10.test       4  avgt    5  25.513 ± 150.516  us/op

在本节中我们对所有线程安全Map的基准测试都是基于put方法进行的,也就是说并没有同时进行读写、修改、删除等动作,因此单凭对一个方法的基准测试就下定论说哪个性能好,哪个性能不好这种说法是不够严谨的,希望读者能够注意到这一点。

@Param与TestNg所提供的DDD(Data Driven Development)非常类似,如果大家对TestNg比较了解的话相信下面的代码会很容易看懂。

程序代码:TestNG的DDD


import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

public class SameClassDataProvider
{
    @DataProvider(name = "data-provider")
    public Object[][] dataProviderMethod() {
        return new Object[][] { { "data one" }, { "data two" } };
    }

    @Test(dataProvider = "data-provider")
    public void testMethod(String data) {
        System.out.println("Data is: " + data);
    }
}

运行上面的TestNG单元测试,testMethod方法会被运行两次,这取决于data provider所提供的数据。

程序输出:TestNG的输出结果


Data is: data one
Data is: data two

PASSED: testMethod("data one")
PASSED: testMethod("data two")

1.3.7 JMH的测试套件(Fixture)

在使用Junit编写单元测试的时候,我们可以使用的套件有@Before、@After、@BeforeClass、@AfterClass等。在JMH中,有没有哪些套件方法可以支持对基准测试方法的初始化以及资源回收呢?答案是有的,本节将为大家介绍一下JMH有哪些测试套件以及具体的用法。

1. Setup以及TearDown

JMH提供了两个注解@Setup和@TearDown用于套件测试,其中@Setup会在每一个基准测试方法执行前被调用,通常用于资源的初始化,@TearDown则会在基准测试方法被执行之后被调用,通常可用于资源的回收清理工作,下面我们来看具体的示例。

程序代码:JMHExample11.java


package com.wangwenjun.concurrent.jmh;

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.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class JMHExample11
{
    // 定义了一个List<String>,但是没有对其进行初始化
    private List<String> list;

    // 将方法标记为@Setup,执行初始化操作
    @Setup
    public void setUp()
    {
        this.list = new ArrayList<>();
    }

    // 简单地调用list的add 方法
    @Benchmark
    public void measureRight()
    {
        this.list.add("Test");
    }

    // 该方法什么都不做
    @Benchmark
    public void measureWrong()
    {
        // do nothing
    }

    // 将方法标记为@TearDown,运行资源回收甚至断言的操作
    @TearDown
    public void tearDown()
{
    // 断言list中的元素个数大于0,很明显,measureWrong基准测试将会失败
        assert this.list.size() > 0 : "The list elements must greater than zero";
    }

    public static void main(String[] args) throws RunnerException
    {
        final Options opts = new OptionsBuilder()
                .include(JMHExample11.class.getSimpleName())
                .jvmArgs("-ea") // 激活断言,enable assertion的意思
                .build();
        new Runner(opts).run();
    }
}

运行上面的基准测试程序,我们会发现measureRight基准测试方法能够正确地执行,但是measureWrong却会失败。

程序输出:JMHExample11.java


java.lang.AssertionError: The list elements must greater than zero
    at com.wangwenjun.concurrent.jmh.JMHExample11.tearDown(JMHExample11.java:44)
    at com.wangwenjun.concurrent.jmh.generated.JMHExample11_measureWrong_jmhTest.measureWrong_AverageTime(JMHExample11_measureWrong_jmhTest.java:165)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:497)
    at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:453)
    at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:437)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
    at java.lang.Thread.run(Thread.java:745)

Result "com.wangwenjun.concurrent.jmh.JMHExample11.measureWrong":
  0.001 ±(99.9%) 0.001 us/op [Average]
  (min, avg, max) = (0.001, 0.001, 0.002), stdev = 0.001
  CI (99.9%): [≈ 10<sup>-4</sup>, 0.003] (assumes normal distribution)

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

Benchmark                  Mode  Cnt  Score   Error  Units
JMHExample11.measureRight  avgt    2  4.171          us/op
JMHExample11.measureWrong  avgt    4  0.001 ± 0.001  us/op

2. Level

1.3.7节使用Setup和TearDown时,在默认情况下,Setup和TearDown会在一个基准方法的所有批次执行前后分别执行,如果需要在每一个批次或者每一次基准方法调用执行的前后执行对应的套件方法,则需要对@Setup和@TearDown进行简单的配置。

▪ Trial:Setup和TearDown默认的配置,该套件方法会在每一个基准测试方法的所有批次执行的前后被执行。


@Setup(Level.Trial)
public void setUp()

▪ Iteration:由于我们可以设置Warmup和Measurement,因此每一个基准测试方法都会被执行若干个批次,如果想要在每一个基准测试批次执行的前后调用套件方法,则可以将Level设置为Iteration。


@Setup(Level.Iteration)
public void setUp()

▪ Invocation:将Level设置为Invocation意味着在每一个批次的度量过程中,每一次对基准方法的调用前后都会执行套件方法。


@Setup(Level.Invocation)
public void setUp()

需要注意的是,套件方法的执行也会产生CPU时间的消耗,但是JMH并不会将这部分时间纳入基准方法的统计之中,这一点更进一步地说明了JMH的严谨之处。

1.3.8 CompilerControl

讲到这里,相信大家应该能够知道如何使用JMH对某些API方法进行微基准测试了吧,也许有些读者还会存在这样的疑惑:JVM真的会对我们的代码进行相关的优化吗?下面通过一个简单的例子来验证一下优化是否存在。

程序代码:JMHExample12.java


package com.wangwenjun.concurrent.jmh;

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;

import static java.lang.Math.PI;
import static java.lang.Math.log;

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class JMHExample12
{

    @Benchmark
    public void test1()
    {
    }

    @Benchmark
    public void test2()
    {
        log(PI);
    }

    public static void main(String[] args) throws RunnerException
    {

        final Options opts = new OptionsBuilder()
                .include(JMHExample12.class.getSimpleName())
                .build();
        new Runner(opts).run();
    }
}

JMHExample12包含两个基准测试方法test1和test2,但是test1方法中并未运行任何计算,而test2方法中进行了Math.log的运算,根据我们的常识很明显可以知道,Math.log方法的CPU耗时肯定要高于一个空方法,但是运行上面的基准测试方法之后得出的性能数据表明的结果是两者几乎不相上下。

程序输出:JMHExample12.java


Benchmark           Mode  Cnt  Score    Error  Units
JMHExample12.test1  avgt    5  0.001 ±  0.001  us/op
JMHExample12.test2  avgt    5  0.001 ±  0.001  us/op

由于test2方法中存在Dead Code(关于Dead Code,我们会在1.4.1节的第1小节中进行详细的介绍),JVM在运行test2方法时对我们的程序进行了优化,具体来说就是将log运算的相关代码进行了运行期擦除,下面我们通过CompilerControl禁止JVM运行时优化和编译之后再来执行一下基准测试方法,然后进行对比。


// 禁止优化
@CompilerControl(CompilerControl.Mode.EXCLUDE)
@Benchmark
public void test1()
{
}
// 禁止优化
@CompilerControl(CompilerControl.Mode.EXCLUDE)
@Benchmark
public void test2()
{
    log(PI);
}

运行上面的基准测试方法之后,结果表明两者的差别就很大了(2.5倍的差距了)。


Benchmark           Mode  Cnt  Score   Error  Units
JMHExample12.test1  avgt    5  0.042 ± 0.009  us/op
JMHExample12.test2  avgt    5  0.109 ± 0.058  us/op

如果你想在自己的应用程序中杜绝JVM运行期的优化,那么我们可以通过如下的方式来实现(虽然这种情况我们并不推荐)。

▪ 通过编写程序的方式禁止JVM运行期动态编译和优化java.lang.Compiler.disable();

▪ 在启动JVM时增加参数 -Djava.compiler=NONE。