JMH基础教程
前言
JMH(Java Microbenchmark Harness)是一个面向 Java 语言或者其他 Java 虚拟机语言的性能基准测试框架。它针对的是纳秒级别、微秒级别、毫秒级别,以及秒级别的性能测试。
快速上手
虽然JMH是随着Java9一起发布的,但是它并没有与任何具体的Java版本绑定,而且JDK中没有支持JMH的工具,所以JMH的安装是非常简单的,我们只需要在Maven项目中引入下面的依赖,就可以轻松愉快地写代码了。
1 | <dependency> |
当然,我们也可以使用命令行的方式创建项目:
1 | $ mvn archetype:generate \ |
一个完整的例子如下所示:
1 | import org.openjdk.jmh.annotations.Benchmark; |
在这个例子中,你既可以通过运行main
方法的方式执行代码,也可以打包为jar
文件,通过命令行的方式执行。
1 | $ cd test/ |
本文笔者在IDEA中使用main
方法的方式执行代码。
首先,JHM会打印本机环境的配置信息,以及本次测试的整体信息:
1 | # JMH version: 1.36 |
然后,以此打印每个测试的过程信息:
1 | # Run progress: 0.00% complete, ETA 00:08:20 |
最终统计信息如下:
1 | Result "com.szc.benchmark.HelloWorldBenchmark.measure": |
我们可以看到,总共有 5 个派生(fork)进程,即 5 次测试。每次测试都在一个独立的JVM中进行。在每个JVM中:JMH默认首先执行了5次预热迭代,每次 10s,然后执行了5次测量迭代,每次 10s,最终报告只统计测量迭代的结果。
其中预热迭代能够让编译器充分优化代码,这里我们后续会展开讲解。
一些解释:
min
,avg
,max
,stdev
分别代表最小值,平均值,最大值和标准差。- CI:在99.9%的置信区间下,我们可以预计数据的平均值在[1960219139.237, 1978029477.794]之间。
Mode:thrpt
代表着测量单位是吞吐量Cnt
代表测量迭代的次数(一共5个派生进程,每个派生进程中测量迭代 5 次)Score
,Errors
,Units
分别代表得分,误差,单位。
可以看出,JMH可以帮助我们进行必要的统计分析,让我们直到特定结果的偏差是否在可以接受的范围内。
JMH基本注解
BenchmarkMode和OutputTimeUnit
1 | //JMHSample_02_BenchmarkModes |
其中:
@OutputTimeUnit
可以指定输出的时间单位,可以传入java.util.concurrent.TimeUnit
中的时间单位,最小可以到纳秒级别;@BenchmarkMode
指明了基准测试的模式Mode.Throughput
:吞吐量,单位时间内执行的次数Mode.AverageTime
:平均时间,一次执行需要的单位时间,其实是吞吐量的倒数Mode.SampleTime
:基于采样的执行时间,采样频率由JMH自动控制,同时结果中也会统计出p50、p90、p99.9之类的时间(pxx即xx%分位数)Mode.SingleShotTime
:单次执行时间,只执行一次,可用于冷启动的测试。
这些模式可以自由组合,甚至可以使用全部。
@BenchmarkMode({Mode.Throughput,Mode.AverageTime})
:自由组合@BenchmarkMode(Mode.All)
:使用全部
Scope范围
1 | //JMHSample_03_States |
另外,注意到,JMH可以像Spring一样自动注入这些变量,十分方便。
JMH允许你指定测试运行时所使用的线程数。通过
.threads()
方法,你可以设置并发执行测试的线程数量。
我们分别在单线程和多线程的条件下运行上面的Benchmark,可以看到,线程共享与否,对于Score的影响是比较大的(约10倍)。
threads=4的情况:
threads=1的情况:
对于注解@State
,为了方便,我们可以在类上标注,从而将基准实例本身标记为@State
。 然后,我们可以像任何其他 Java 程序一样引用它自己的字段。
通过类注解,假如我们需要将类中所有的字段都标记为
@State(Scope.Thread)
,只需要标记一次就可以了。
1 | /* |
Scope分组
使用@State(Scope.Group)
设置作用域为组,可以模拟多线程情景:
1 |
|
@Group
定义了一个线程组, @GroupThreads
可以分配线程给测试用例,可以测试线程执行不均衡的情况,比如三个线程写,一个线程读,这里用 @State(Scope.Group)
定义了counter
作用域是这个线程组。运行结果也是分组展示:
Setup和TearDown
1 |
|
在注解Setup
和TearDown
修饰的方法中,我们可以做一些准备和收尾工作。当然,它们也有等级区分。Level参数表明粒度,粒度从粗到细分别是:
Level.Invocation
:每次方法调用级别Level.Iteration
:执行测量(预热)迭代级别Level.Trial
:Benchmark级别
示例代码:
1 |
|
CompilerControl
JMH提供了可以控制是否使用内联的注解 @CompilerControl
,它的参数有如下可选:
CompilerControl.Mode.DONT_INLINE
:不使用内联CompilerControl.Mode.INLINE
:强制使用内联CompilerControl.Mode.EXCLUDE
:不编译
示例代码:
1 |
|
运行结果:
1 | Benchmark Mode Cnt Score Error Units |
从执行结果可以看到不编译执行最慢,其他方法不明显。
syncIterations
在我们使用JMH的时候, 我们可能会使用多线程对一个Benchmark进行测试,然后分析在多线程环境下Benchmark的执行速度。那么这时就会有一个问题, 线程的创建是有先后顺序的。如果我们一个测试用例, 要创建很多线程, 然后再执行benchmark的方法,就会把很多时间浪费在了初始化线程上。
JMH提供了一个参数.syncIterations(true)
用来控制线程的初始化。
- true(默认):等待所有线程都初始化好了, 再一起执行benchmark。
- false:这些线程, 谁先初始化谁就先执行。
1 | public static void main(String[] args) throws RunnerException { |
Control
在多线程场景下,容易发生活锁,有效使用Control
对象的.stopMeasurement
方法,可以有效规避。cnt.stopMeasurement
是JMH框架在测量过程中判断是否停止测量的标志。
1 |
|
Warmup和Measurement
可以通过注解很方便的添加执行的参数
1 |
|
AuxCounters
@AuxCounters
是一个辅助计数器,可以统计 @State
修饰的对象中的 public
属性被执行的情况,它的参数有两个:
AuxCounters.Type.EVENTS
: 统计发生的次数。AuxCounters.Type.OPERATIONS
:按我们指定的格式统计,如按吞吐量统计。
1 |
|
运行结果:
1 | Benchmark Mode Cnt Score Error Units |
Benchmark继承
JMH在父类中定义了Benchmark,子类也会继承并合成自己的Benchmark,这是JMH在编译期完成的。
1 |
|
Params
@Param 允许使用一份基准测试代码运行多组数据。
1 |
|
BulkWarmup
三种预热方式:
- WarmupMode.INDI:每个Benchmark单独预热
- WarmupMode.BULK:在每个Benchmark执行前都预热所有的Benchmark
- WarmupMode.BULK_INDI:在每个Benchmark执行前都预热所有的Benchmark,且需要再预热本次执行的Benchmark
Profilers
JMH内置了一些性能剖析工具,可以帮助我们查看基准测试消耗在什么地方,具体的剖析方式内置的有如下几种:
- ClassloaderProfiler:类加载剖析
- CompilerProfiler:JIT编译剖析
- GCProfiler:GC剖析
- StackProfiler:栈剖析
- PausesProfiler:停顿剖析
- HotspotThreadProfiler:Hotspot线程剖析
- HotspotRuntimeProfiler:Hotspot运行时剖析
- HotspotMemoryProfiler:Hotspot内存剖析
- HotspotCompilationProfiler:Hotspot编译剖析
- HotspotClassloadingProfiler:Hotspot 类加载剖析
1 | public static void main(String[] args) throws RunnerException { |
陷阱
死码消除(DCE)和Blackhole
编译器原理中,死码消除(Dead code elimination)是一种编译优化技术,它的用途是移除对程序执行结果没有任何影响的代码。移除这类的代码有两种优点,不但可以减少程序的大小,还可以避免程序在执行中进行不相关的运算行为,减少它执行的时间。不会被执行到的代码(unreachable code)以及只会影响到无关程序执行结果的变量(Dead Variables),都是死码(Dead code)的范畴。
编译器自动消除死码,很有可能会对我们的测试产生影响,比如下面的例子。
1 | private double x = Math.PI; |
在这种情况下,JMH向我们提供了一个类Blackhole
。我们将计算出的变量传递给Blackhole
中的consume
方法,consume
方法可以防止即时编译器将所传入的值给优化掉。
1 | private double compute() { |
需要注意的是,它并不会阻止对传入值的计算的优化。在下面这段代码中,将3+4
的值传入Blackhole.consume
方法中。即时编译器仍旧会进行常量折叠,而Blackhole
将阻止即时编译器把所得到的常量值 7
给优化消除掉。
1 |
|
除了防止死代码消除的consume
之外,Blackhole
类还提供了一个静态方法consumeCPU
,来消耗 CPU 时间。该方法将接收一个 long
类型的参数,这个参数与所消耗的 CPU 时间呈线性相关。
1 |
|
使用fork隔离多个测试方法
在同一个 JVM 中运行的测评代码有时候会互相影响,所以我们需要 fork 出一个新的 Java 虚拟机,来运行性能基准测试。
- 获得一个相对干净的虚拟机环境
- 避免 JVM 优化带来的不确定性
1 |
|
我们来看一个很有意思的示例:
1 | public class JMHSample_13_RunToRun { |
可以看到,我们使用@State(Scope.Thread)
标记了一个类SleepyState
,在这个类内部的setup
方法中,我们得到了一个随机数sleepTime
(1-1000中随机),然后,在每个 benchmark 中,我们所做的事情就是睡眠sleepTime
这么长时间。因为我们在benchmark函数中,只做了睡眠这一件事情,所以得分s/ops
应该与sleepTime
相差无几。
不难想到,如果我们运行很多次测试(1-1000内随机),那么平均值应该接近 500
,运行次数越多,距离500越近。
但是,直接运行这段代码,你会发现,sleepTime
永远是一个固定的数值。原因在于,我们只有一个JVM,JVM中只有一个线程,而sleepTime
在线程创建之后就被确定了(根据Scope.Thread
)。
有两种解决方法:
- 修改
fork()
大小,即派生出的新的JVM实例数量 - 修改
.threads()
大小,即单个JVM中用于执行benchmark的线程数。
修改fork(50)
,或者修改.threads(50)
,得到 Score 为:
1 | Benchmark Mode Cnt Score Error Units |
常量折叠与常量传播
常量折叠(Constant folding)以及常量传播(constant propagation)都是编译器优化技术,他们被使用在现代的编译器中。高级的常量传播形式,或称之为稀疏有条件的常量传播,可以更精确地传播常量及无缝的移除无用的代码。
常量折叠是一个在编译时期简化常量的一个过程,常量在表示式中仅仅代表一个简单的数值,就像是整数 2
,若是一个变量从未被修改也可作为常量,或者直接将一个变量被明确地被标注为常量,例如下面的描述:
1 | i = 320 * 200 * 32; |
多数的现代编译器不会真的产生两个乘法的指令再将结果存储下来,取而代之的,他们会识别出语句的结构,并在编译时期将数值计算出来(在这个例子,结果为2,048,000)。
常量传播是一个替代表示式中已知常量的过程,也是在编译时期进行,包含前述所定义,内置函数也适用于常量,以下列描述为例:
1 | int x = 14; |
传播x变量将会变成:
1 | int x = 14; |
持续传播,则会变成:(还可以再进一步的消除无用代码x及y来进行优化)
1 | int x = 14; |
常量折叠与常量传播很可能对我们的真实意图产生影响,所以我们要避免在Benchmark中存在这类行为。下面是一个例子:
1 | private double x = Math.PI; |
运行后,会发现measureWrong_1
、measureWrong_2
和measureRight
得分相同,这也再次印证了该过程存在常量折叠。
1 | Benchmark Mode Cnt Score Error Units |
永远不要在测试中写循环
测试一个耗时较短的方法,我们经常会这样写,通过循环放大,再求均值。
1 | public class BadMicrobenchmark { |
但是这样是不可取的,因为循环被执行时存在很多复杂的优化。
(1)循环展开(Loop unwinding),是一种牺牲程序的大小来加快程序执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。也有利于指令流水线的调度。
举例:
1 | for (i = 1; i <= 60; i++) |
可以如此循环展开:
1 | for (i = 1; i <= 58; i+=3) |
这被称为展开了两次。
(2)OSR(On-Stack Replacement)对循环的优化。
这里不详细展开,读者可以自行搜索。总之,循环代码的结果是不可预测的。
我们来看一个JMH官方的示例:
1 |
|
运行结果如下,可以看到,运行结果并没有什么规律可言。
1 | Benchmark Mode Cnt Score Error Units |
但是,在一些场景下,比如遍历数据集合中的所有元素,我们很难不使用循环,因此,应该设计一个安全循环的方式。接下来看一个正确的做法:
1 | static final int BASE = 42; |
伪共享(False Shareing)
在计算机科学中,伪共享(False sharing)是一种会导致性能下降的使用模式,它可能出现在具有分布式缓存且维持缓存一致性快取一致性)的系统中。当系统参与者尝试定期访问未被另一方更改的数据,但欲访问的数据与正被更改的另一数据共享同一缓存块时,缓存协议可能会强制第一个参与者重新加载整个缓存块,尽管在逻辑上这么做是不必要的。 缓存系统不知道此块内的活动,所以强制第一个参与者承担资源的真共享访问(true shared access)所需的缓存系统开销。
1 |
|
运行结果如下:
1 | Benchmark Mode Cnt Score Error Units |
可以看出来,差距还是很明显的。
分支预测
条件分支指令通常具有两路后续执行分支。即不采取(not taken)跳转,顺序执行后面紧挨JMP的指令;以及采取(taken)跳转到另一块程序内存去执行那里的指令。
是否条件跳转,只有在该分支指令在指令流水线中通过了执行阶段(execution stage)才能确定下来。
如果没有分支预测器,处理器将会等待分支指令通过了指令流水线的执行阶段,才把下一条指令送入流水线的第一个阶段—取指令阶段(fetch stage)。这种技术叫做流水线停顿(pipeline stalled)或者流水线冒泡(pipeline bubbling)或者分支延迟间隙。这是早期的RISC体系结构处理器采用的应对分支指令的流水线执行的办法。
分支预测器猜测条件表达式两路分支中哪一路最可能发生,然后推测执行这一路的指令,来避免流水线停顿造成的时间浪费。如果后来发现分支预测错误,那么流水线中推测执行的那些中间结果全部放弃,重新获取正确的分支路线上的指令开始执行,这招致了程序执行的延迟。
简而言之,CPU在处理有规律的数据比没有规律的数据要快,CPU可以“预测”这种规律。我们在基准测试时需要注意样本数据的规律性对结果也会产生影响。
1 | private static final int COUNT = 1024 * 1024; |
运行结果如下,差距挺明显的。
1 | Benchmark Mode Cnt Score Error Units |
顺序访问与非顺序访问
因为CPU存在缓存行的缘故,对内存的顺序访问与非顺序访问也会对测试结果产生影响。
1 | private final static int COUNT = 4096; |
运行结果同样差距挺明显的(大约6倍)。
1 | Benchmark Mode Cnt Score Error Units |
内存布局的影响
1 | public static final int N = 20_000_000; |
运行结果如下:
1 | Benchmark Mode Cnt Score Error Units |