前言
JMH(Java Microbenchmark Harness)是一个面向 Java 语言或者其他 Java 虚拟机语言的性能基准测试框架。它针对的是纳秒级别、微秒级别、毫秒级别,以及秒级别的性能测试。
快速上手
虽然JMH是随着Java9一起发布的,但是它并没有与任何具体的Java版本绑定,而且JDK中没有支持JMH的工具,所以JMH的安装是非常简单的,我们只需要在Maven项目中引入下面的依赖,就可以轻松愉快地写代码了。
xml
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.36</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.36</version>
</dependency>
当然,我们也可以使用命令行的方式创建项目:
ini
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0
一个完整的例子如下所示:
java
import org.openjdk.jmh.annotations.Benchmark;
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;
public class HelloWorld {
@Benchmark
public void wellHelloThere() {
// 这里书写你需要测试的代码
}
//运行JMH代码的一种方式
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(HelloWorld.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}
在这个例子中,你既可以通过运行main
方法 的方式执行代码,也可以打包为jar
文件,通过命令行的方式执行。
shell
$ cd test/
$ mvn clean verify
$ java -jar target/benchmarks.jar
本文笔者在IDEA中使用main
方法的方式执行代码。
首先,JHM会打印本机环境的配置信息,以及本次测试的整体信息:
shell
# JMH version: 1.36
# VM version: JDK 11.0.12, Java HotSpot(TM) 64-Bit Server VM, 11.0.12+8-LTS-237
# VM invoker: C:\Program Files\Java\jdk-11.0.12\bin\java.exe
# VM options: -javaagent:C:\Users\chao\AppData\Local\JetBrains\Toolbox\apps\IDEA-U\ch-1\232.8660.185\lib\idea_rt.jar=60334:C:\Users\chao\AppData\Local\JetBrains\Toolbox\apps\IDEA-U\ch-1\232.8660.185\bin -Dfile.encoding=UTF-8
# Blackhole mode: full + dont-inline hint (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: com.szc.benchmark.HelloWorldBenchmark.measure
然后,以此打印每个测试的过程信息:
bash
# Run progress: 0.00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration 1: 2635805003.372 ops/s
# Warmup Iteration 2: 2616635602.687 ops/s
# Warmup Iteration 3: 2618041383.077 ops/s
# Warmup Iteration 4: 1970496590.693 ops/s
# Warmup Iteration 5: 1976220421.389 ops/s
Iteration 1: 1972121906.389 ops/s
Iteration 2: 1951305842.294 ops/s
Iteration 3: 1963541171.486 ops/s
Iteration 4: 1965423944.882 ops/s
Iteration 5: 1972175221.548 ops/s
最终统计信息如下:
ini
Result "com.szc.benchmark.HelloWorldBenchmark.measure":
1969124308.515 ±(99.9%) 8905169.278 ops/s [Average]
(min, avg, max) = (1951305842.294, 1969124308.515, 2001098858.414), stdev = 11888146.208
CI (99.9%): [1960219139.237, 1978029477.794] (assumes normal distribution)
# Run complete. Total time: 00:08:24
Benchmark Mode Cnt Score Error Units
HelloWorldBenchmark.measure thrpt 25 1969124308.515 ± 8905169.278 ops/s
我们可以看到,总共有 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
less
//JMHSample_02_BenchmarkModes
@Benchmark
@BenchmarkMode(Mode.Throughput) //指定基准测试的模式(吞吐量)
@OutputTimeUnit(TimeUnit.SECONDS) //指定测量单位(秒)
public void measureThroughput() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(100);
}
其中:
@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范围
java
//JMHSample_03_States
//所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能
@State(Scope.Benchmark)
public static class BenchmarkState {
volatile double x = Math.PI;
}
@Benchmark
public void measureShared(BenchmarkState state) {
// All benchmark threads will call in this method.
//
// Since BenchmarkState is the Scope.Benchmark, all threads
// will share the state instance, and we will end up measuring
// shared case.
state.x++;
}
//每个测试线程分配一个实例,相当于ThreadLocal变量
@State(Scope.Thread)
public static class ThreadState {
volatile double x = Math.PI;
}
@Benchmark
public void measureUnshared(ThreadState state) {
// All benchmark threads will call in this method.
//
// However, since ThreadState is the Scope.Thread, each thread
// will have it's own copy of the state, and this benchmark
// will measure unshared case.
state.x++;
}
//每个线程组共享一个实例
@State(Scope.Group)
public static class GroupState {
//your code
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_03_States.class.getSimpleName())
.threads(4) //设置测试运行时使用的线程数为4,模拟多线程的情景
.forks(1)
.build();
new Runner(opt).run();
}
另外,注意到,JMH可以像Spring一样自动注入这些变量,十分方便。
JMH允许你指定测试运行时所使用的线程数。通过
.threads()
方法,你可以设置并发执行测试的线程数量。
我们分别在单线程和多线程的条件下运行上面的Benchmark,可以看到,线程共享与否,对于Score的影响是比较大的(约10倍)。
threads=4的情况:
threads=1的情况:
对于注解@State
,为了方便,我们可以在类上标注,从而将基准实例本身标记为@State
。 然后,我们可以像任何其他 Java 程序一样引用它自己的字段。
通过类注解,假如我们需要将类中所有的字段都标记为
@State(Scope.Thread)
,只需要标记一次就可以了。
java
/*
* Fortunately, in many cases you just need a single state object.
* In that case, we can mark the benchmark instance itself to be
* the @State. Then, we can reference its own fields as any
* Java program does.
*/
@State(Scope.Thread)
public class JMHSample_04_DefaultState {
double x = Math.PI;
@Benchmark
public void measure() {
x++;
}
}
Scope分组
使用@State(Scope.Group)
设置作用域为组,可以模拟多线程情景:
java
@State(Scope.Group)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class JMHSample_15_Asymmetric {
private AtomicInteger counter;
@Setup
public void up() {
counter = new AtomicInteger();
}
@Benchmark
@Group("g")
@GroupThreads(3)
public int inc() {
return counter.incrementAndGet();
}
@Benchmark
@Group("g")
@GroupThreads(1)
public int get() {
return counter.get();
}
}
@Group
定义了一个线程组, @GroupThreads
可以分配线程给测试用例,可以测试线程执行不均衡的情况,比如三个线程写,一个线程读,这里用 @State(Scope.Group)
定义了counter
作用域是这个线程组。运行结果也是分组展示:
Setup和TearDown
typescript
@State(Scope.Thread)
public class JMHSample_05_StateFixtures {
double x;
@Setup //基准测试前的初始化动作
public void prepare() {
x = Math.PI;
}
@TearDown //基准测试结束后的收尾工作
public void check() {
assert x > Math.PI : "Nothing changed?";
}
}
在注解Setup
和TearDown
修饰的方法中,我们可以做一些准备和收尾工作。当然,它们也有等级区分。Level参数表明粒度,粒度从粗到细分别是:
Level.Invocation
:每次方法调用级别Level.Iteration
:执行测量(预热)迭代级别Level.Trial
:Benchmark级别
示例代码:
csharp
@Setup(Level.Invocation)
public void setup() {
System.out.println("setup......");
}
CompilerControl
JMH提供了可以控制是否使用内联的注解 @CompilerControl
,它的参数有如下可选:
CompilerControl.Mode.DONT_INLINE
:不使用内联CompilerControl.Mode.INLINE
:强制使用内联CompilerControl.Mode.EXCLUDE
:不编译
示例代码:
less
public void target_blank() {
// this method was intentionally left blank
}
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void target_dontInline() {
// this method was intentionally left blank
}
@CompilerControl(CompilerControl.Mode.INLINE)
public void target_inline() {
// this method was intentionally left blank
}
@CompilerControl(CompilerControl.Mode.EXCLUDE)
public void target_exclude() {
// this method was intentionally left blank
}
@Benchmark
public void baseline() {
// this method was intentionally left blank
}
@Benchmark
public void blank() {
target_blank();
}
@Benchmark
public void dontinline() {
target_dontInline();
}
@Benchmark
public void inline() {
target_inline();
}
@Benchmark
public void exclude() {
target_exclude();
}
运行结果:
bash
Benchmark Mode Cnt Score Error Units
JMHSample_16_CompilerControl.baseline avgt 3 0.435 ± 1.228 ns/op
JMHSample_16_CompilerControl.blank avgt 3 0.426 ± 1.342 ns/op
JMHSample_16_CompilerControl.dontinline avgt 3 0.426 ± 1.339 ns/op
JMHSample_16_CompilerControl.exclude avgt 3 0.388 ± 0.108 ns/op
JMHSample_16_CompilerControl.inline avgt 3 0.425 ± 1.351 ns/op
从执行结果可以看到不编译执行最慢,其他方法不明显。
syncIterations
在我们使用JMH的时候, 我们可能会使用多线程对一个Benchmark进行测试,然后分析在多线程环境下Benchmark的执行速度。那么这时就会有一个问题, 线程的创建是有先后顺序的。如果我们一个测试用例, 要创建很多线程, 然后再执行benchmark的方法,就会把很多时间浪费在了初始化线程上。
JMH提供了一个参数.syncIterations(true)
用来控制线程的初始化。
- true(默认):等待所有线程都初始化好了, 再一起执行benchmark。
- false:这些线程, 谁先初始化谁就先执行。
scss
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_17_SyncIterations.class.getSimpleName())
.threads(Runtime.getRuntime().availableProcessors()*16)
.forks(1)
.syncIterations(true) // try to switch to "false"
.build();
new Runner(opt).run();
}
Control
在多线程场景下,容易发生活锁,有效使用Control
对象的.stopMeasurement
方法,可以有效规避。cnt.stopMeasurement
是JMH框架在测量过程中判断是否停止测量的标志。
less
@Benchmark
@Group("pingpong")
public void ping(Control cnt) {
while (!cnt.stopMeasurement && !flag.compareAndSet(false, true)) {
// this body is intentionally left blank
}
}
@Benchmark
@Group("pingpong")
public void pong(Control cnt) {
while (!cnt.stopMeasurement && !flag.compareAndSet(true, false)) {
// this body is intentionally left blank
}
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_18_Control.class.getSimpleName())
.threads(2)
.forks(1)
.build();
new Runner(opt).run();
}
Warmup和Measurement
可以通过注解很方便的添加执行的参数
less
@Benchmark
@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
public double measure() {
return Math.log(x1);
}
AuxCounters
@AuxCounters
是一个辅助计数器,可以统计 @State
修饰的对象中的 public
属性被执行的情况,它的参数有两个:
AuxCounters.Type.EVENTS
: 统计发生的次数。AuxCounters.Type.OPERATIONS
:按我们指定的格式统计,如按吞吐量统计。
java
@State(Scope.Thread)
@AuxCounters(AuxCounters.Type.OPERATIONS)
public static class OpCounters {
// These fields would be counted as metrics
public int case1;
public int case2;
// This accessor will also produce a metric
public int total() {
return case1 + case2;
}
}
@State(Scope.Thread)
@AuxCounters(AuxCounters.Type.EVENTS)
public static class EventCounters {
// This field would be counted as metric
public int wows;
}
@Benchmark
public void splitBranch(OpCounters counters) {
if (Math.random() < 0.1) {
counters.case1++;
} else {
counters.case2++;
}
}
@Benchmark
public void runSETI(EventCounters counters) {
float random = (float) Math.random();
float wowSignal = (float) Math.PI / 4;
if (random == wowSignal) {
// WOW, that's unusual.
counters.wows++;
}
}
运行结果:
bash
Benchmark Mode Cnt Score Error Units
JMHSample_23_AuxCounters.runSETI thrpt 3 55869036.957 ± 6513518.130 ops/s
JMHSample_23_AuxCounters.runSETI:wows thrpt 3 6.000 #
JMHSample_23_AuxCounters.splitBranch thrpt 3 54936208.691 ± 3440716.525 ops/s
JMHSample_23_AuxCounters.splitBranch:case1 thrpt 3 5490316.306 ± 388208.981 ops/s
JMHSample_23_AuxCounters.splitBranch:case2 thrpt 3 49445892.385 ± 3053612.103 ops/s
JMHSample_23_AuxCounters.splitBranch:total thrpt 3 54936208.691 ± 3440716.525 ops/s
Benchmark继承
JMH在父类中定义了Benchmark,子类也会继承并合成自己的Benchmark,这是JMH在编译期完成的。
java
@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@State(Scope.Thread)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public static abstract class AbstractBenchmark {
int x;
@Setup
public void setup() {
x = 42;
}
@Benchmark
@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
public double bench() {
return doWork() * doWork();
}
protected abstract double doWork();
}
public static class BenchmarkLog extends AbstractBenchmark {
@Override
protected double doWork() {
return Math.log(x);
}
}
Params
@Param 允许使用一份基准测试代码运行多组数据。
kotlin
@Param({"1", "31", "65", "101", "103"})
public int arg;
@Param({"0", "1", "2", "4", "8", "16", "32"})
public int certainty;
@Benchmark
public boolean bench() {
return BigInteger.valueOf(arg).isProbablePrime(certainty);
}
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 类加载剖析
scss
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Maps.class.getSimpleName())
.addProfiler(StackProfiler.class)
// .addProfiler(GCProfiler.class)
.build();
new Runner(opt).run();
}
陷阱
死码消除(DCE)和Blackhole
编译器原理中,死码消除 (Dead code elimination)是一种编译优化技术,它的用途是移除对程序执行结果没有任何影响的代码。移除这类的代码有两种优点,不但可以减少程序的大小,还可以避免程序在执行中进行不相关的运算行为,减少它执行的时间。不会被执行到的代码(unreachable code )以及只会影响到无关程序执行结果的变量(Dead Variables ),都是死码(Dead code)的范畴。
编译器自动消除死码,很有可能会对我们的测试产生影响,比如下面的例子。
typescript
private double x = Math.PI;
private double compute(double d) {
for (int c = 0; c < 10; c++) {
d = d * d / Math.PI;
}
return d;
}
@Benchmark
public void baseline() {
// 空方法
}
@Benchmark
public void measureWrong() {
// 错误的例子,会被JIT优化成空方法
compute(x);
}
@Benchmark
public double measureRight() {
// 正确的例子,把结果返回,让JVM认为计算不能省略
return compute(x);
}
在这种情况下,JMH向我们提供了一个类Blackhole
。我们将计算出的变量传递给Blackhole
中的consume
方法,consume
方法可以防止即时编译器将所传入的值给优化掉。
typescript
private double compute() {
//your code
}
@Benchmark
public void measure(Blackhole bh) {
bh.consume(compute());
}
需要注意的是,它并不会阻止对传入值的计算的优化 。在下面这段代码中,将3+4
的值传入Blackhole.consume
方法中。即时编译器仍旧会进行常量折叠,而Blackhole
将阻止即时编译器把所得到的常量值 7
给优化消除掉。
typescript
@Benchmark
public void testMethod(Blackhole bh) {
bh.consume(3+4);
}
除了防止死代码消除的consume
之外,Blackhole
类还提供了一个静态方法consumeCPU
,来消耗 CPU 时间。该方法将接收一个 long
类型的参数,这个参数与所消耗的 CPU 时间呈线性相关。
typescript
@Benchmark
public void consume() {
Blackhole.consumeCPU(1000);
}
使用fork隔离多个测试方法
在同一个 JVM 中运行的测评代码有时候会互相影响,所以我们需要 fork 出一个新的 Java 虚拟机,来运行性能基准测试。
- 获得一个相对干净的虚拟机环境
- 避免 JVM 优化带来的不确定性
less
@Benchmark
@Fork(n)
public int measure() {
//your code
}
我们来看一个很有意思的示例:
java
public class JMHSample_13_RunToRun {
@State(Scope.Thread)
public static class SleepyState {
public long sleepTime;
@Setup
public void setup() {
sleepTime = (long) (Math.random() * 1000);
}
}
@Benchmark
@Fork(1)
public void baseline(SleepyState s) throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(s.sleepTime);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_13_RunToRun.class.getSimpleName())
.threads(1)
.build();
new Runner(opt).run();
}
}
可以看到,我们使用@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 为:
bash
Benchmark Mode Cnt Score Error Units
JMHSample_13_RunToRun.baseline avgt 50 514.249 ± 138.246 ms/op
JMHSample_13_RunToRun.baseline avgt 484.893 ms/op
常量折叠与常量传播
常量折叠 (Constant folding )以及常量传播 (constant propagation)都是编译器优化技术,他们被使用在现代的编译器中。高级的常量传播形式,或称之为稀疏有条件的常量传播,可以更精确地传播常量及无缝的移除无用的代码。
常量折叠 是一个在编译时期简化常量的一个过程,常量在表示式中仅仅代表一个简单的数值,就像是整数 2
,若是一个变量从未被修改也可作为常量,或者直接将一个变量被明确地被标注为常量,例如下面的描述:
ini
i = 320 * 200 * 32;
多数的现代编译器不会真的产生两个乘法的指令再将结果存储下来,取而代之的,他们会识别出语句的结构,并在编译时期将数值计算出来(在这个例子,结果为2,048,000)。
常量传播是一个替代表示式中已知常量的过程,也是在编译时期进行,包含前述所定义,内置函数也适用于常量,以下列描述为例:
ini
int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);
传播x变量将会变成:
ini
int x = 14;
int y = 7 - 14 / 2;
return y * (28 / 14 + 2);
持续传播,则会变成:(还可以再进一步的消除无用代码x及y来进行优化)
ini
int x = 14;
int y = 0;
return 0;
常量折叠与常量传播很可能对我们的真实意图产生影响,所以我们要避免在Benchmark中存在这类行为。下面是一个例子:
java
private double x = Math.PI;
private final double wrongX = Math.PI;
private double compute(double d) {
for (int c = 0; c < 10; c++) {
d = d * d / Math.PI;
}
return d;
}
@Benchmark
public double baseline() {
return Math.PI;
}
@Benchmark
public double measureWrong_1() {
//结果是已知的,可以提前计算,所以计算过程被折叠了
return compute(Math.PI);
}
@Benchmark
public double measureWrong_2() {
//wrongX是不可变的,所以结果同样已知
return compute(wrongX);
}
@Benchmark
public double measureRight() {
//这样是正确的。
return compute(x);
}
运行后,会发现measureWrong_1
、measureWrong_2
和measureRight
得分相同,这也再次印证了该过程存在常量折叠。
bash
Benchmark Mode Cnt Score Error Units
JMHSample_10_ConstantFold.baseline avgt 3 2.221 ± 5.661 ns/op
JMHSample_10_ConstantFold.measureRight avgt 3 13.597 ± 1.710 ns/op
JMHSample_10_ConstantFold.measureWrong_1 avgt 3 2.202 ± 5.143 ns/op
JMHSample_10_ConstantFold.measureWrong_2 avgt 3 2.191 ± 5.428 ns/op
永远不要在测试中写循环
测试一个耗时较短的方法,我们经常会这样写,通过循环放大,再求均值。
ini
public class BadMicrobenchmark {
public static void main(String[] args) {
long startTime = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) {
reps();
}
long endTime = System.nanoTime();
System.out.println("score :" + (endTime - startTime));
}
}
但是这样是不可取的,因为循环被执行时存在很多复杂的优化。
(1)循环展开(Loop unwinding),是一种牺牲程序的大小来加快程序执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。也有利于指令流水线的调度。
举例:
css
for (i = 1; i <= 60; i++)
a[i] = a[i] * b + c;
可以如此循环展开:
css
for (i = 1; i <= 58; i+=3)
{
a[i] = a[i] * b + c;
a[i+1] = a[i+1] * b + c;
a[i+2] = a[i+2] * b + c;
}
这被称为展开了两次。
(2)OSR(On-Stack Replacement)对循环的优化。
这里不详细展开,读者可以自行搜索。总之,循环代码的结果是不可预测的。
我们来看一个JMH官方的示例:
java
int x = 1;
int y = 2;
@Benchmark
public int measureRight() {
return (x + y);
}
private int reps(int reps) {
int s = 0;
for (int i = 0; i < reps; i++) {
s += (x + y);
}
return s;
}
@Benchmark
//该注解意思是这一个benchmark一共执行了多少次measure,最后会除以次数,得到最终的score
@OperationsPerInvocation(1)
public int measureWrong_1() {
return reps(1);
}
@Benchmark
@OperationsPerInvocation(10)
public int measureWrong_10() {
return reps(10);
}
@Benchmark
@OperationsPerInvocation(100)
public int measureWrong_100() {
return reps(100);
}
@Benchmark
@OperationsPerInvocation(1_000)
public int measureWrong_1000() {
return reps(1_000);
}
@Benchmark
@OperationsPerInvocation(10_000)
public int measureWrong_10000() {
return reps(10_000);
}
@Benchmark
@OperationsPerInvocation(100_000)
public int measureWrong_100000() {
return reps(100_000);
}
运行结果如下,可以看到,运行结果并没有什么规律可言。
bash
Benchmark Mode Cnt Score Error Units
JMHSample_11_Loops.measureRight avgt 3 2.052 ± 0.238 ns/op
JMHSample_11_Loops.measureWrong_1 avgt 3 2.058 ± 0.065 ns/op
JMHSample_11_Loops.measureWrong_10 avgt 3 0.231 ± 0.045 ns/op
JMHSample_11_Loops.measureWrong_100 avgt 3 0.026 ± 0.011 ns/op
JMHSample_11_Loops.measureWrong_1000 avgt 3 0.022 ± 0.002 ns/op
JMHSample_11_Loops.measureWrong_10000 avgt 3 0.018 ± 0.007 ns/op
JMHSample_11_Loops.measureWrong_100000 avgt 3 0.017 ± 0.002 ns/op
但是,在一些场景下,比如遍历数据集合中的所有元素,我们很难不使用循环,因此,应该设计一个安全循环的方式。接下来看一个正确的做法:
java
static final int BASE = 42;
static int work(int x) {
return BASE + x;
}
@Param({"1", "10", "100", "1000"})
int size;
int[] xs;
@Setup
public void setup() {
xs = new int[size];
for (int c = 0; c < size; c++) {
xs[c] = c;
}
}
@Benchmark
public int measureWrong_1() {
int acc = 0;
for (int x : xs) {
acc = work(x);
}
return acc;
}
@Benchmark
public int measureWrong_2() {
int acc = 0;
for (int x : xs) {
acc += work(x);
}
return acc;
}
@Benchmark
public void measureRight_1(Blackhole bh) {
for (int x : xs) {
bh.consume(work(x));
}
}
@Benchmark
public void measureRight_2() {
for (int x : xs) {
sink(work(x));
}
}
//告诉编译器不要内联
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public static void sink(int v) {
// 匹配签名以避免自动装箱非常重要
// The method intentionally does nothing.
}
伪共享(False Shareing)
在计算机科学中,伪共享 (False sharing)是一种会导致性能下降的使用模式,它可能出现在具有分布式缓存且维持缓存一致性快取一致性)的系统中。当系统参与者尝试定期访问未被另一方更改的数据,但欲访问的数据与正被更改的另一数据共享同一缓存块时,缓存协议可能会强制第一个参与者重新加载整个缓存块,尽管在逻辑上这么做是不必要的。 缓存系统不知道此块内的活动,所以强制第一个参与者承担资源的真共享访问(true shared access)所需的缓存系统开销。
java
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(5)
public class JMHSample_22_FalseSharing {
@State(Scope.Group)
public static class StateBaseline {
int readOnly;
int writeOnly;
}
@Benchmark
@Group("baseline")
public int reader(StateBaseline s) {
return s.readOnly;
}
@Benchmark
@Group("baseline")
public void writer(StateBaseline s) {
s.writeOnly++;
}
/*
* APPROACH 1: PADDING
*
* We can try to alleviate some of the effects with padding.
* This is not versatile because JVMs can freely rearrange the
* field order, even of the same type.
*/
@State(Scope.Group)
public static class StatePadded {
int readOnly;
int p01, p02, p03, p04, p05, p06, p07, p08;
int p11, p12, p13, p14, p15, p16, p17, p18;
int writeOnly;
int q01, q02, q03, q04, q05, q06, q07, q08;
int q11, q12, q13, q14, q15, q16, q17, q18;
}
@Benchmark
@Group("padded")
public int reader(StatePadded s) {
return s.readOnly;
}
@Benchmark
@Group("padded")
public void writer(StatePadded s) {
s.writeOnly++;
}
/*
* APPROACH 2: CLASS HIERARCHY TRICK
*
* We can alleviate false sharing with this convoluted hierarchy trick,
* using the fact that superclass fields are usually laid out first.
* In this construction, the protected field will be squashed between
* paddings.
* It is important to use the smallest data type, so that layouter would
* not generate any gaps that can be taken by later protected subclasses
* fields. Depending on the actual field layout of classes that bear the
* protected fields, we might need more padding to account for "lost"
* padding fields pulled into in their superclass gaps.
*/
public static class StateHierarchy_1 {
int readOnly;
}
public static class StateHierarchy_2 extends StateHierarchy_1 {
byte p01, p02, p03, p04, p05, p06, p07, p08;
byte p11, p12, p13, p14, p15, p16, p17, p18;
byte p21, p22, p23, p24, p25, p26, p27, p28;
byte p31, p32, p33, p34, p35, p36, p37, p38;
byte p41, p42, p43, p44, p45, p46, p47, p48;
byte p51, p52, p53, p54, p55, p56, p57, p58;
byte p61, p62, p63, p64, p65, p66, p67, p68;
byte p71, p72, p73, p74, p75, p76, p77, p78;
}
public static class StateHierarchy_3 extends StateHierarchy_2 {
int writeOnly;
}
public static class StateHierarchy_4 extends StateHierarchy_3 {
byte q01, q02, q03, q04, q05, q06, q07, q08;
byte q11, q12, q13, q14, q15, q16, q17, q18;
byte q21, q22, q23, q24, q25, q26, q27, q28;
byte q31, q32, q33, q34, q35, q36, q37, q38;
byte q41, q42, q43, q44, q45, q46, q47, q48;
byte q51, q52, q53, q54, q55, q56, q57, q58;
byte q61, q62, q63, q64, q65, q66, q67, q68;
byte q71, q72, q73, q74, q75, q76, q77, q78;
}
@State(Scope.Group)
public static class StateHierarchy extends StateHierarchy_4 {
}
@Benchmark
@Group("hierarchy")
public int reader(StateHierarchy s) {
return s.readOnly;
}
@Benchmark
@Group("hierarchy")
public void writer(StateHierarchy s) {
s.writeOnly++;
}
/*
* APPROACH 3: ARRAY TRICK
*
* This trick relies on the contiguous allocation of an array.
* Instead of placing the fields in the class, we mangle them
* into the array at very sparse offsets.
*/
@State(Scope.Group)
public static class StateArray {
int[] arr = new int[128];
}
@Benchmark
@Group("sparse")
public int reader(StateArray s) {
return s.arr[0];
}
@Benchmark
@Group("sparse")
public void writer(StateArray s) {
s.arr[64]++;
}
/*
* APPROACH 4:
*
* @Contended (since JDK 8):
* Uncomment the annotation if building with JDK 8.
* Remember to flip -XX:-RestrictContended to enable.
*/
@State(Scope.Group)
public static class StateContended {
int readOnly;
// @sun.misc.Contended
int writeOnly;
}
@Benchmark
@Group("contended")
public int reader(StateContended s) {
return s.readOnly;
}
@Benchmark
@Group("contended")
public void writer(StateContended s) {
s.writeOnly++;
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHSample_22_FalseSharing.class.getSimpleName())
.threads(Runtime.getRuntime().availableProcessors())
.build();
new Runner(opt).run();
}
}
运行结果如下:
bash
Benchmark Mode Cnt Score Error Units
JMHSample_22_FalseSharing.baseline thrpt 25 4308.413 ± 244.357 ops/us
JMHSample_22_FalseSharing.baseline:reader thrpt 25 553.168 ± 49.802 ops/us
JMHSample_22_FalseSharing.baseline:writer thrpt 25 3755.246 ± 259.901 ops/us
JMHSample_22_FalseSharing.contended thrpt 25 3803.586 ± 288.437 ops/us
JMHSample_22_FalseSharing.contended:reader thrpt 25 610.349 ± 44.564 ops/us
JMHSample_22_FalseSharing.contended:writer thrpt 25 3193.237 ± 267.186 ops/us
JMHSample_22_FalseSharing.hierarchy thrpt 25 4408.923 ± 242.622 ops/us
JMHSample_22_FalseSharing.hierarchy:reader thrpt 25 1109.683 ± 63.995 ops/us
JMHSample_22_FalseSharing.hierarchy:writer thrpt 25 3299.239 ± 188.135 ops/us
JMHSample_22_FalseSharing.padded thrpt 25 4344.750 ± 311.144 ops/us
JMHSample_22_FalseSharing.padded:reader thrpt 25 1101.085 ± 83.329 ops/us
JMHSample_22_FalseSharing.padded:writer thrpt 25 3243.665 ± 233.775 ops/us
JMHSample_22_FalseSharing.sparse thrpt 25 3844.397 ± 103.099 ops/us
JMHSample_22_FalseSharing.sparse:reader thrpt 25 1013.405 ± 63.424 ops/us
JMHSample_22_FalseSharing.sparse:writer thrpt 25 2830.991 ± 93.341 ops/us
可以看出来,差距还是很明显的。
分支预测
条件分支指令通常具有两路后续执行分支。即不采取(not taken)跳转,顺序执行后面紧挨JMP的指令;以及采取(taken)跳转到另一块程序内存去执行那里的指令。
是否条件跳转,只有在该分支指令在指令流水线中通过了执行阶段(execution stage)才能确定下来。
如果没有分支预测器,处理器将会等待分支指令通过了指令流水线的执行阶段,才把下一条指令送入流水线的第一个阶段---取指令阶段(fetch stage)。这种技术叫做流水线停顿(pipeline stalled)或者流水线冒泡(pipeline bubbling)或者分支延迟间隙。这是早期的RISC体系结构处理器采用的应对分支指令的流水线执行的办法。
分支预测器猜测条件表达式两路分支中哪一路最可能发生,然后推测执行这一路的指令,来避免流水线停顿造成的时间浪费。如果后来发现分支预测错误,那么流水线中推测执行的那些中间结果全部放弃,重新获取正确的分支路线上的指令开始执行,这招致了程序执行的延迟。
简而言之,CPU在处理有规律的数据比没有规律的数据要快,CPU可以"预测"这种规律。我们在基准测试时需要注意样本数据的规律性对结果也会产生影响。
java
private static final int COUNT = 1024 * 1024;
private byte[] sorted;
private byte[] unsorted;
@Setup
public void setup() {
sorted = new byte[COUNT];
unsorted = new byte[COUNT];
Random random = new Random(1234);
random.nextBytes(sorted);
random.nextBytes(unsorted);
Arrays.sort(sorted);
}
@Benchmark
@OperationsPerInvocation(COUNT)
public void sorted(Blackhole bh1, Blackhole bh2) {
for (byte v : sorted) {
if (v > 0) {
bh1.consume(v);
} else {
bh2.consume(v);
}
}
}
@Benchmark
@OperationsPerInvocation(COUNT)
public void unsorted(Blackhole bh1, Blackhole bh2) {
for (byte v : unsorted) {
if (v > 0) {
bh1.consume(v);
} else {
bh2.consume(v);
}
}
}
运行结果如下,差距挺明显的。
bash
Benchmark Mode Cnt Score Error Units
JMHSample_36_BranchPrediction.sorted avgt 25 2.249 ± 0.023 ns/op
JMHSample_36_BranchPrediction.unsorted avgt 25 6.201 ± 0.121 ns/op
顺序访问与非顺序访问
因为CPU存在缓存行的缘故,对内存的顺序访问与非顺序访问也会对测试结果产生影响。
ini
private final static int COUNT = 4096;
private final static int MATRIX_SIZE = COUNT * COUNT;
private int[][] matrix;
@Setup
public void setup() {
matrix = new int[COUNT][COUNT];
Random random = new Random(1234);
for (int i = 0; i < COUNT; i++) {
for (int j = 0; j < COUNT; j++) {
matrix[i][j] = random.nextInt();
}
}
}
@Benchmark
@OperationsPerInvocation(MATRIX_SIZE)
public void colFirst(Blackhole bh) {
for (int c = 0; c < COUNT; c++) {
for (int r = 0; r < COUNT; r++) {
bh.consume(matrix[r][c]);
}
}
}
@Benchmark
@OperationsPerInvocation(MATRIX_SIZE)
public void rowFirst(Blackhole bh) {
for (int r = 0; r < COUNT; r++) {
for (int c = 0; c < COUNT; c++) {
bh.consume(matrix[r][c]);
}
}
}
运行结果同样差距挺明显的(大约6倍)。
bash
Benchmark Mode Cnt Score Error Units
JMHSample_37_CacheAccess.colFirst avgt 25 12.284 ± 0.149 ns/op
JMHSample_37_CacheAccess.rowFirst avgt 25 2.654 ± 0.024 ns/op
内存布局的影响
ini
public static final int N = 20_000_000;
private int[] intArray = new int[N];
private List<Integer> intList = new ArrayList<>(N);
private List<Integer> shuffledIntList = new ArrayList<>(N);
@Setup
public void setup() {
Random random = new Random(1234);
for (int i = 0; i < N; i++) {
intArray[i] = random.nextInt();
intList.add(intArray[i]);
shuffledIntList.add(intArray[i]);
}
Collections.shuffle(shuffledIntList);
}
@Benchmark
public long sumArray() {
long sum = 0;
for (int i = 0; i < N; i++) {
sum += intArray[i];
}
return sum;
}
@Benchmark
public long sumArrayList() {
long sum = 0;
for (int i = 0; i < N; i++) {
sum += intList.get(i);
}
return sum;
}
@Benchmark
public long sumArrayListShuffled() {
long sum = 0;
for (int i = 0; i < N; i++) {
sum += shuffledIntList.get(i);
}
return sum;
}
运行结果如下:
bash
Benchmark Mode Cnt Score Error Units
JMHSample_39_MemoryAccess.sumArray avgt 25 6.183 ± 0.193 ms/op
JMHSample_39_MemoryAccess.sumArrayList avgt 25 40.094 ± 0.612 ms/op
JMHSample_39_MemoryAccess.sumArrayListShuffled avgt 25 290.487 ± 11.585 ms/op