封面来源:本文封面来源于网络,如有侵权,请联系删除。
本文参考:
本文涉及的代码:mofan212/jmh-study
1. 下载构建官方源码
GitHub 搜索 jmh
,进入 openjdk/jmh 页面,clone 官方源码。
用 IDEA 打开项目,执行以下命令(二选一):
1 2 3 4 # 不执行单元测试,也不编译测试类 mvn install -Dmaven.test.skip=true # 或者:不执行单元测试,但编译测试类 mvn install -DskipTests=true
也可以使用 IDEA 的 Maven 图形化界面完成。
进入 jmh-samples
模块,打开 JMHSample_01_HelloWorld
类,运行其中的 main()
方法。
运行过程中可能出现以下错误:
java: 程序包sun.misc不存在
需要手动调整 IDEA 的 Java Compiler 设置,将它们设置成当前使用的 JDK 版本,之后便可成功运行。
2. Hello World
从官方示例入手,执行以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class JMHSample_01_HelloWorld { @Benchmark public void wellHelloThere () { } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(JMHSample_01_HelloWorld.class.getSimpleName()) .forks(1 ) .build(); new Runner (opt).run(); } }
执行时间比较长,需要耐心等待。
wellHelloThere()
被 @Benchmark
注解标记,表示本次测试的就是 wellHelloThere()
方法,尽管它是一个空方法。
main()
运行完成后,控制台会打印出测试报告。前几行是 JMH 与当前测试环境的信息,之后有:
# 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: indi.mofan.JMHSample_01_HelloWorld.wellHelloThere
Warmup
表示测试前的预热信息,此处进行 5 轮预热,每次 10 秒。
Measurement
表示测试维度,此处进行 5 轮测试,每次 10 秒。
Threads
表示测试时的线程信息,此处表示测试时在 1 个线程中执行。
Benchmark mode
表示基准模式,Throughput
表示将测试结果以吞吐量的信息显示出来。
再来看测试结果:
Result "indi.mofan.JMHSample_01_HelloWorld.wellHelloThere":
2651579229.605 ±(99.9%) 18997454.078 ops/s [Average]
(min, avg, max) = (2644241675.367, 2651579229.605, 2657354438.867), stdev = 4933578.495
CI (99.9%): [2632581775.527, 2670576683.683] (assumes normal distribution)
99.9% 的情况下,平均吞吐量为 2651579229.605 ops/s
,误差 ±18997454.078 ops/s
。
之后列举出最小值、平均值、最大值,stdev
表示标准差,也就是方差开根号。
CI
表示置信区间,这里表示 99.9% 的概率下,测试结果在区间 [2632581775.527, 2670576683.683]
范围内。
3. 使用示例
使用 JMH 时需要导入以下依赖:
1 2 3 4 5 6 7 8 9 10 11 <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 >
3.1 @Warmup 与 @Measurement
在执行第一个示例时,耗时较长,这主要是因为测试过程中的预热次数与测试次数较长,这些信息可以通过 @Warmup
与 @Measurement
注解进行修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Warmup(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS) @Measurement(iterations = 1, time = 1, timeUnit = TimeUnit.SECONDS) public class Sample_01_2_Warmup { @Benchmark public void wellHelloThere () { } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_01_2_Warmup.class.getSimpleName()) .forks(1 ) .build(); new Runner (opt).run(); } }
运行上述代码,发现相比于第一个示例的运行时间,快了许多。
至于最终结果到底准不准,那就另当别论了。
为了能够更快地看到测试结果,后续测试都将采用这两个配置。
3.2 @BenchmarkMode
JMH 提供了多种 Benchmark Mode,使用 @BenchmarkMode
注解来指定采用的 Benchmark Mode。
@BenchmarkMode
注解经常与 @OutputTimeUnit
注解搭配使用,使用 @OutputTimeUnit
指定输出报告的时间单位。
当前 JMH 可以采用的 Benchmark Mode:
Mode.Throughput
:吞吐量测试,每单位时间测试方法会执行多少次
Mode.AverageTime
:平均耗时时间,每次操作耗时,采用相同的单位时,与 Mode.Throughput
互为倒数
Mode.SampleTime
:抽样测试,在执行过程中采样,最快的、50% 快的、90%、95%、99%、99.9%、99.99%、100%,可以使用抽样测试对算法的稳定性进行测试
Mode.SingleShotTime
:冷启动测试,测试方法在测试中只会运行一次,用于测试冷启动的性能
@BenchmarkMode
注解并不是只能接收单一的 Mode,它接收的是 Mode
数组,因此能够对测试方法设置多个 Mode。
除此之外还提供了 Mode.All
选项,使用当前 JMH 支持的所有 Benchmark Mode 进行测试。
3.3 @State
基本使用
@State
注解 只能作用在类 上,它描述了类对象的作用域,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @State(Scope.Benchmark) public static class BenchmarkState { volatile double x = Math.PI; } @State(Scope.Thread) public static class ThreadState { volatile double x = Math.PI; }
被 @State
标记的类会在 Benchmark 启动时按照给定的作用域进行初始化,在测试方法中可以使用这些类作为入参进行注入,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Benchmark public void measureUnshared (ThreadState state) { state.x++; } @Benchmark public void measureShared (BenchmarkState state) { state.x++; } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_03_States.class.getSimpleName()) .threads(4 ) .forks(1 ) .build(); new Runner (opt).run(); }
Default State
在基本使用中,@State
标记的都是静态内部类,静态内部类的作用和普通类没什么区别。
为了更方便地使用,JMH 提供了 Default State,当 @State
注解作用在 @Benchmark
注解标记的方法所在的类上时,测试方法直接使用的成员变量与测试方法接收当前类实例并使用其成员变量没有区别。
比如下面两种表示是等价的:
1 2 3 4 5 6 7 8 9 10 @State(Scope.Thread) public class Sample_04_DefaultState { double x = Math.PI; @Benchmark public void measure () { x++; } }
1 2 3 4 5 6 7 8 9 10 @State(Scope.Thread) public class Sample_04_DefaultState { double x = Math.PI; @Benchmark public void measure (Sample_04_DefaultState state) { state.x++; } }
3.4 @Setup 和 @TearDown
基本使用
@Setup
和 @TearDown
两个注解可以认为是 @State
注解生命周期的一部分:
@Setup
:该注解只能作用在方法上,并且标记的方法所在的类必须被 @State
注解标记。@Setup
注解标记的方法用于完成 启动 Benchmark 前的准备工作 。
@TearDown
:该注解与 @Setup
注解类似,只不过该注解标记的方法用于完成 Benchmark 结束后的检查工作 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @State(Scope.Thread) @Warmup(iterations = 1, time = 1) @Measurement(iterations = 1, time = 1) public class Sample_05_StateFixtures { double x; @Setup public void prepare () { x = Math.PI; } @TearDown public void check () { assert x > Math.PI : "Nothing changed?" ; } @Benchmark public void measureRight () { x++; } @Benchmark public void measureWrong () { double x = 0 ; x++; } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_05_StateFixtures.class.getSimpleName()) .forks(1 ) .jvmArgs("-ea" ) .build(); new Runner (opt).run(); } }
FixtureLevel
可以为 @Setup
和 @TearDown
设置 Level
类型的 value
值,默认值是 Level.Trial
,表示被标记的方法在整个基准测试中只执行一次。
除此之外,还有两个值可供选择:
Level.Iteration
:每轮循环完成之前(或之后)才会执行
Level.Invocation
:每次方法被调用之前(或之后)才会执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @State(Scope.Thread) @Warmup(iterations = 1, time = 1) @Measurement(iterations = 3, time = 1) public class Sample_06_FixtureLevel { double x; @TearDown(Level.Iteration) public void check () { assert x > Math.PI : "Nothing changed?" ; } @Benchmark public void measureRight () { x++; } @Benchmark public void measureWrong () { double x = 0 ; x++; } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_06_FixtureLevel.class.getSimpleName()) .forks(1 ) .jvmArgs("-ea" ) .shouldFailOnError(false ) .build(); new Runner (opt).run(); } }
FixtureLevelInvocation
合理使用 @Setup
和 @TearDown
注解的 value
值,能够模拟一些实际场景,比如模仿线程池的冷启动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 @Warmup(iterations = 1, time = 1) @Measurement(iterations = 1, time = 1) @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public class Sample_07_FixtureLevelInvocation { @State(Scope.Benchmark) public static class NormalState { ExecutorService service; @Setup(Level.Trial) public void up () { service = Executors.newCachedThreadPool(); } @TearDown(Level.Trial) public void down () { service.shutdown(); } } public static class LaggingState extends NormalState { public static final int SLEEP_TIME = Integer.getInteger("sleepTime" , 10 ); @Setup(Level.Invocation) public void lag () throws InterruptedException { TimeUnit.MILLISECONDS.sleep(SLEEP_TIME); } } @Benchmark public double measureHot (NormalState e, final Scratch s) throws ExecutionException, InterruptedException { return e.service.submit(new Task (s)).get(); } @Benchmark public double measureCold (LaggingState e, final Scratch s) throws ExecutionException, InterruptedException { return e.service.submit(new Task (s)).get(); } @State(Scope.Thread) public static class Scratch { private double p; public double doWork () { p = Math.log(p); return p; } } public static class Task implements Callable <Double> { private Scratch s; public Task (Scratch s) { this .s = s; } @Override public Double call () { return s.doWork(); } } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_07_FixtureLevelInvocation.class.getSimpleName()) .forks(1 ) .build(); new Runner (opt).run(); } }
LaggingState
中的线程池任务在执行之前,会先休眠 10 ms,而 NormalState
中的线程池任务在执行之前则不会,前者用于模拟线程池的冷启动。
3.5 Dead Code
在使用 JMH 时,一些错误的使用方式可能会导致测试代码被 JVM 优化掉,进而导致基准测试结果不准确。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 @Warmup(iterations = 1, time = 1) @Measurement(iterations = 1, time = 1) @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class JMHSample_08_DeadCode { 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 () { compute(x); } @Benchmark public double measureRight () { return compute(x); } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(JMHSample_08_DeadCode.class.getSimpleName()) .forks(1 ) .jvmArgs("-server" ) .build(); new Runner (opt).run(); } }
上述示例代码中:
baseline()
:是一个空方法
measureWrong()
:由于计算结果并没有返回,JVM 会自动优化,使其耗时测得与 baseline()
一样
measureRight()
:计算结果正常返回,JVM 不会自动优化,测出真正的执行效率
3.6 Blackhole
为了减少 JVM 的优化,JMH 提供了 Blackhole
对象,使用该对象对执行结果进行消费,让测试结果尽可能准确。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 @Warmup(iterations = 1, time = 1) @Measurement(iterations = 1, time = 1) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Thread) public class Sample_09_Blackholes { double x1 = Math.PI; double x2 = Math.PI * 2 ; private double compute (double d) { for (int c = 0 ; c < 10 ; c++) { d = d * d / Math.PI; } return d; } @Benchmark public double baseline () { return compute(x1); } @Benchmark public double measureWrong () { compute(x1); return compute(x2); } @Benchmark public double measureRight_1 () { return compute(x1) + compute(x2); } @Benchmark public void measureRight_2 (Blackhole bh) { bh.consume(compute(x1)); bh.consume(compute(x2)); } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_09_Blackholes.class.getSimpleName()) .forks(1 ) .build(); new Runner (opt).run(); } }
3.7 常量折叠
常量折叠,或者叫常数折叠。百度百科对 常数折叠 的解释是这样的:常数折叠是编译器最佳化技术,被使用在现代的编译器中。进阶的常数传播形式,或称之为稀疏有条件的常量传播,可以更精确地传播常数及无缝的移除无用的程式码。
简单来说:当计算结果是可预测的时候,编译器会在编译时期将结果计算出来,运行时直接使用计算结果,而不是每次都执行运算过程。
由于常量折叠的存在,在使用 JMH 时,应当避免常量折叠,让测试结果更加准确。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 @Warmup(iterations = 1, time = 1) @Measurement(iterations = 1, time = 1) @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class Sample_10_ConstantFold { 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 () { return compute(wrongX); } @Benchmark public double measureRight () { return compute(x); } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_10_ConstantFold.class.getSimpleName()) .forks(1 ) .build(); new Runner (opt).run(); } }
3.8 错误的循环
假设需要计算两个数相加的效率,那么可以有:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class Sample_11_Loops { int x = 1 ; int y = 2 ; @Benchmark public int measureRight () { return (x + y); } }
基于此,是否可以写一个循环,循环中进行 10 次相加运算,这 10 次循环相加的效率是否等于 1 次相加运算的 10 倍效率呢?
继续增加循环次数,100 次、1000 次、10000 次、100000 次,最终的效率是否是 1 次相加运算的效率的对应倍数呢?
JMH 提供了 @OperationsPerInvocation
注解,该注解接收 int
类型的 value
值,表示运行一次目标方法相当于运行了给定次数的 Benchmark。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 private int reps (int reps) { int s = 0 ; for (int i = 0 ; i < reps; i++) { s += (x + y); } return s; } @Benchmark @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 ); }
测试报告会和预期的一致吗?
measureRight()
与 measureWrong_1()
的效率类似,但随着循环次数的增加,运行效率也在不断增加,循环 100000 次的效率与 1 次计算的效率更是天差地别。
这告诉我们在测试时,不要利用循环多次的效率来倒推每次的执行的效率,这往往是不准确的。对同种计算循环多次时,JVM 会对其进行优化,以提高执行效率。
3.9 @Fork
JMH 提供了一个 @Fork
注解,那他有什么用呢?一起探究下。
提供 printRunningJvmName()
方法,返回正在运行的 JVM 的名称:
1 2 3 4 5 6 7 public static void printRunningJvmName (String name) { System.out.println(); System.out.println("---------------------------------------" ); System.out.println(name + " jvm name is : " + ManagementFactory.getRuntimeMXBean().getName()); System.out.println("---------------------------------------" ); System.out.println(); }
@Fork(0)
@Fork
注解可以作用在类上和方法上,当以 @Fork(0)
的形式作用在方法上时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Warmup(iterations = 1, time = 1) @Measurement(iterations = 1, time = 1) @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class Sample_12_Forking_0 { @Benchmark @Fork(0) public int measure_1_c1 () { return 1 ; } @Setup(Level.Trial) public void setup () { printRunningJvmName("setup" ); } public static void main (String[] args) throws RunnerException { printRunningJvmName("main" ); Options opt = new OptionsBuilder () .include(Sample_12_Forking_0.class.getSimpleName()) .build(); new Runner (opt).run(); } }
从最终的测试报告可知,执行 main()
方法与执行 Benchmark 是在同一个 JVM 中。
@Fork(1)
仅仅修改 @Fork(0)
为 @Fork(1)
,最终的测试报告又会有什么变化呢?
1 2 3 4 5 @Benchmark @Fork(1) public int measure_1_c1 () { return 1 ; }
此时执行 main()
方法与执行 Benchmark 不在同一个 JVM 中,也就是在执行 JMH 的整个测试时,会 fork 出一个进程,创建出崭新的 JVM,在这个 JVM 中执行 Benchmark。
@Fork(n)
如果修改为 @Fork(10)
呢?
1 2 3 4 5 @Benchmark @Fork(10) public int measure_1_c1 () { return 1 ; }
@Fork(10)
表示在执行 JMH 的整个测试时,fork 出 10 个进程来执行 Benchmark。
fork 进程的操作不是并行的,也就是说,先 fork 出一个进程,创建出一个 JVM,执行 Benchmark,之后销毁 JVM 再 fork 出一个进程,重复 10 次。
注意,这里重复 10 次只是重复创建 10 次 JVM,执行 setup()
方法虽然也只执行了 10 次,但后者还和 @Warmup
、@Measurement
和 @Setup
注解有关。
@Fork
如果不显式传入 @Fork
注解的 value
值,其默认值是 -1
,这相当于显式传入 5
。
1 2 3 4 5 @Benchmark @Fork public int measure_1_c1 () { return 1 ; }
但不建议这么使用,如果要传入 5
,应当显式指定 value
值为 5
。
首先,显式传入的方式更加清晰明确,其次在 @Fork
注解的 Java doc 中并未说明不传具体的值就相当于传入 5
,这是在自行测试后得出来的,但指不定在后续的版本中就修改了这个规则呢?
官方示例
首先定义 Counter
接口:
1 2 3 public interface Counter { int inc () ; }
再定义 Counter1
和 Counter2
作为 Counter
的实现类,但它们实现的逻辑都一样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public static class Counter1 implements Counter { private int x; @Override public int inc () { return x++; } } public static class Counter2 implements Counter { private int x; @Override public int inc () { return x++; } }
接着定义 measure()
方法,该方法接收一个Counter
实例,在内部通过循环调用 10 次 Counter
的 inc()
方法:
1 2 3 4 5 6 7 public int measure (Counter c) { int s = 0 ; for (int i = 0 ; i < 10 ; i++) { s += c.inc(); } return s; }
最后就是一系列 Benchmark:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 Counter c1 = new Counter1 ();Counter c2 = new Counter2 ();@Benchmark @Fork(0) public int measure_1_c1 () { return measure(c1); } @Benchmark @Fork(0) public int measure_2_c2 () { return measure(c2); } @Benchmark @Fork(0) public int measure_3_c1_again () { return measure(c1); } @Benchmark @Fork(1) public int measure_4_forked_c1 () { return measure(c1); } @Benchmark @Fork(1) public int measure_5_forked_c2 () { return measure(c2); }
初看会认为每个 Benchmark 的测试结果都一样,但在实际运行后会发现它们之间存在着差异。
当测试 measure_1_c1()
时,传入 c1
调用 measure()
方法,JVM 在执行该方法时,会假设 Counter
接口只有一个实现,使用方法内联的 JIT 优化手段,提高执行效率。在测试 measure_2_c2()
时,传入 c2
调用 measure()
方法,同一个 JVM 在执行该方法时,发现 Counter
接口的实现类实际上有多个,使用 Java 的动态方法机制(运行时多态),相比于方法内联,这种方式的执行效率会降低。后续在测试 measure_3_c1_again()
时也是相同情况。
测试 measure_4_forked_c1()
时,该方法被 @Fork(1)
标记,表示测试时会新建一个 JVM 进行测试,此时又会使用方法内联进行优化,最后测试的 measure_5_forked_c2()
也是这样。
什么时候使用 @Fork
现有如下测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @State(Scope.Thread) public static class SleepyState { public long sleepTime; @Setup public void setup () { sleepTime = (long ) (Math.random() * 1000 ); System.out.println(); System.out.println("-----------------------------------" ); System.out.println("sleepTime = " + sleepTime); System.out.println("-----------------------------------" ); } } @Benchmark @Fork(1) public void baseline (SleepyState s) throws InterruptedException { TimeUnit.MILLISECONDS.sleep(s.sleepTime); }
目的很简单,在执行 Benchmark 前,获取一个 [0, 1000)
之间的随机数,然后休眠随机数值的毫秒。
最终测得该方法的平均耗时时间应该和获取的随机数差不多。
但问题也来了,随机数的范围很大,每次执行获取的值不同,极端情况下,第一次得到的是 0,第二次得到的是 999,难道这两次能反映整体的平均耗时时间吗?
显然是不行的。
根据大数定律,随着测试次数的增加,最终的效率就越趋近于真实执行效率。
此时可以利用 @Fork
注解,比如在 @Fork(5)
和 @Fork(20)
的情况下,理论上 后者应该更趋近于真实执行效率,即 500 ms/op
,当然真正执行后 @Fork(5)
更接近也是有可能的。
1 2 3 4 5 6 7 8 9 10 11 @Benchmark @Fork(5) public void fork_1 (SleepyState s) throws InterruptedException { TimeUnit.MILLISECONDS.sleep(s.sleepTime); } @Benchmark @Fork(20) public void fork_2 (SleepyState s) throws InterruptedException { TimeUnit.MILLISECONDS.sleep(s.sleepTime); }
3.10 @Group 与 @GroupThreads
在计算两个都被 @Benchmark
注解标记的方法的执行效率时,在测试报告中,它们的执行效率会分开。
如果需要合并多个 Benchmark,可以使用 @Group
注解。使用 @Group
时,需要指定一个 value
值,JMH 会将具有相同 value
值的方法放到同一个 Benchmark 中执行,基于此可以创造一些线程之间的竞争关系。
与之搭配的还有 @GroupThreads
注解,该注解能够指定参与运行特定组的 Benchmark 方法的线程数。
回顾前文中的 @State
注解,它描述了类对象的作用域,它还有个 value
值是 Scope.Group
,这在前文中没有提到,@State(Scope.Group)
指定了类对象的作用域是组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @State(Scope.Group) @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MILLISECONDS) public class Sample_15_Asymmetric { private AtomicInteger counter; @Setup public void up () { counter = new AtomicInteger (); } @Benchmark @Group("g1") @GroupThreads(3) public int inc () { return counter.incrementAndGet(); } @Benchmark @Group("g1") @GroupThreads(1) public int get () { return counter.get(); } }
定义了名为 g1
的组,该组中有两个方法,分别是:
inc()
:同时有三个线程执行 AtomicInteger
的 incrementAndGet()
方法
get()
:只有一个线程执行 AtomicInteger
的 get()
方法
也就是说在 g1
中,同时会有四个线程在执行方法,incrementAndGet()
方法是线程安全的,其底层使用了 CAS,当有多个线程执行该方法时,会存在竞争。
除此之外,还定义了 g2
、g3
组:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Benchmark @Group("g2") public int inc1 () { return counter.incrementAndGet(); } @Benchmark @Group("g3") public int get1 () { return counter.get(); }
这两个组中只有一个线程在执行自增或 get,相比于存在线程竞争的 g1
,这两个组中的方法的效率 应该 更高。
当然这不是一定的,这与硬件条件还有关系,如果发现 g1
的执行效率比 g2
更高,尝试增加 g1
的线程竞争关系,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Benchmark @Group("g1") @GroupThreads(100) public int inc () { return counter.incrementAndGet(); } @Benchmark @Group("g1") @GroupThreads(30) public int get () { return counter.get(); }
此时有 100 个线程执行自增,30 个线程执行 get。
4. 初阶实战示例
4.1 排序算法的效率
十大排序算法的复杂度、稳定性如下图(来源:十大经典排序算法 | 菜鸟教程 )所示:
更多实现细节可以参考:
单从平均时间复杂度上来看,冒泡排序、选择排序、插入排序的性能是低于希尔排序、归并排序、快速排序和堆排序的。事实真是如此吗?
设定待排序数组内部元素取值为 0 到 100 的整数,编写 Benchmark 对上述排序算法效率进行比较。
数组长度为 10
Benchmark Mode Cnt Score Error Units
SortBenchmark.bubbleSort avgt 148.637 ns/op
SortBenchmark.bucketSort avgt 614.930 ns/op
SortBenchmark.countingSort avgt 229.073 ns/op
SortBenchmark.heapSort avgt 175.842 ns/op
SortBenchmark.insertSort avgt 118.565 ns/op
SortBenchmark.mergeSort avgt 665.085 ns/op
SortBenchmark.quickSort avgt 187.735 ns/op
SortBenchmark.radixSort avgt 5351.363 ns/op
SortBenchmark.selectionSort avgt 122.999 ns/op
SortBenchmark.shellSort avgt 149.182 ns/op
以快速排序 quickSort 和插入排序 insertSort 为例,前者的平均时间复杂度为 O ( n l o g n ) O(nlogn) O ( n l o g n ) ,后者为 O ( n 2 ) O(n^2) O ( n 2 ) ,但根据测试报表可知,在数组长度为 10 的情况下,插入排序的效率更高。
数组长度为 100
Benchmark Mode Cnt Score Error Units
SortBenchmark.bubbleSort avgt 10199.883 ns/op
SortBenchmark.bucketSort avgt 3765.742 ns/op
SortBenchmark.countingSort avgt 2896.813 ns/op
SortBenchmark.heapSort avgt 3061.878 ns/op
SortBenchmark.insertSort avgt 1335.316 ns/op
SortBenchmark.mergeSort avgt 12285.984 ns/op
SortBenchmark.quickSort avgt 2555.055 ns/op
SortBenchmark.radixSort avgt 23673.644 ns/op
SortBenchmark.selectionSort avgt 4337.397 ns/op
SortBenchmark.shellSort avgt 2487.184 ns/op
此时依旧是插入排序的效率更高。
数组长度为 10000
Benchmark Mode Cnt Score Error Units
SortBenchmark.bubbleSort avgt 61518470.000 ns/op
SortBenchmark.bucketSort avgt 1275125.000 ns/op
SortBenchmark.countingSort avgt 25470.588 ns/op
SortBenchmark.heapSort avgt 538462.500 ns/op
SortBenchmark.insertSort avgt 2728411.765 ns/op
SortBenchmark.mergeSort avgt 55002733.333 ns/op
SortBenchmark.quickSort avgt 362605.556 ns/op
SortBenchmark.radixSort avgt 43962510.000 ns/op
SortBenchmark.selectionSort avgt 14798706.667 ns/op
SortBenchmark.shellSort avgt 440547.059 ns/op
此时情况发生了好转,快速排序的效率明显领先于插入排序。
总结
在判断某一算法的实际效率时,不能一昧迷信时间复杂度,在数组长度较小的情况下,O ( n 2 ) O(n^2) O ( n 2 ) 的插入排序效率也能比 O ( n l o g n ) O(nlogn) O ( n l o g n ) 的快速排序效率高。
正因如此,JDK 底层对 Arrays.sort()
方法的实现也不是简单选用一种排序算法,不同的数组情况,将选择不同的排序算法,比如当数组长度较小时,选择插入排序作为排序逻辑。
4.2 Map > for ?
对于 for
循环来说,认为其时间复杂度为 O ( n ) O(n) O ( n ) ,而对于根据 key 从 Map
中获取对应的 value
,认为其时间复杂度为 O ( 1 ) O(1) O ( 1 ) 。单看时间复杂度,Map
的效率大于 for
循环,事实真是如此吗?
以枚举为例
以枚举的使用为例,实际开发过程中,枚举类中并不是只有枚举项,通常还会包含其他附加描述信息,比如 code
、value
等,然后在使用时根据这些附加信息获取对应的枚举项。
以根据 value
获取枚举项为例:
既可以遍历每个枚举项,找到与传入 value
对应的枚举项
也可以用 value
为 key,枚举项为 value 构造 Map
,通过该 Map
获取对应的枚举项
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public enum EnumMatch { A(1 ), B(2 ), C(3 ), D(4 ), E(5 ), F(6 ), G(7 ), H(8 ), I(9 ), J(10 ), NULL(-1 ), ; private final Integer value; EnumMatch(Integer value) { this .value = value; } public static EnumMatch matchWithFor (Integer value) { for (EnumMatch enumMatch : values()) { if (enumMatch.value.equals(value)) { return enumMatch; } } return NULL; } private static final Map<Integer, EnumMatch> ENUM_MAP = new HashMap <>(); static { for (EnumMatch enumMatch : values()) { ENUM_MAP.put(enumMatch.value, enumMatch); } } public static EnumMatch matchWithMap (Integer value) { EnumMatch result = ENUM_MAP.get(value); if (result == null ) { result = EnumMatch.NULL; } return result; } }
EnumMatch
中除 NULL
外,共有 10 个有效的枚举项。
随机生成一个 1 到 10 的随机数,测试使用 for
和 Map
获取对应枚举项的效率:
Benchmark Mode Cnt Score Error Units
EnumMatchBenchmark.matchByFor avgt 27.367 ns/op
EnumMatchBenchmark.matchByMap avgt 19.456 ns/op
此时通过 Map
获取对应枚举项的效率要更高些,但两者的差距并不大。
枚举通常用于类型的表示,其个数往往在 5 个以内。
假设只有 3 个有效的枚举项,随机生成一个 1 到 3 的随机数,测试 for
与 Map
的效率:
Benchmark Mode Cnt Score Error Units
EnumMatchBenchmark.matchByFor avgt 21.513 ns/op
EnumMatchBenchmark.matchByMap avgt 19.134 ns/op
尽管依旧是 Map
的效率领先,但二者的差距基本可以忽略不计。
从代码简洁的角度来看,显然是使用 for
更加简洁、清晰,减少了样板代码的出现。
以对象为例
业务开发中,实体类往往存在一个表示唯一标识的字段,后续可能需要从一组数据中筛选出给定标识指向的实体对象。对于筛选方式的实现往往有:
简单暴力使用 for
循环
性能极致使用 Map
简洁易读使用 Stream
在一般的认知中,这三种方式的筛选效率关系为:
Map > for > Stream
事实真是如此吗?
使用整型字段 a
作为 TestObj
对象中的唯一标识,现在需要从 10 个 TestObj
对象中筛选出唯一标识与给定整数相同的 TestObj
对象,使用上述三种筛选方式实现并测试它们的效率:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) @Warmup(iterations = 2, time = 5) @Measurement(iterations = 2, time = 5) @State(Scope.Thread) @Fork(1) public class TestObjBenchmark { List<TestObj> list; Map<Integer, TestObj> map; @Setup(Level.Trial) public void prepareValue () { list = new ArrayList <>(); for (int i = 1 ; i <= 10 ; i++) { TestObj obj = new TestObj (); obj.setA(i); list.add(obj); } map = new HashMap <>(); for (TestObj obj : list) { map.put(obj.getA(), obj); } } int a = -1 ; @Setup(Level.Invocation) public void prepareA () { a = new RandomDataGenerator ().nextInt(1 , 10 ); } @Benchmark public TestObj getByStream () { return list.stream() .filter(i -> i.getA().equals(a)) .findFirst() .orElse(null ); } @Benchmark public TestObj getByFor () { for (TestObj obj : list) { if (obj.getA().equals(a)) { return obj; } } return null ; } @Benchmark public TestObj getByMap () { return map.get(a); } public static void main (String[] args) throws RunnerException { Options options = new OptionsBuilder () .include(TestObjBenchmark.class.getSimpleName()) .build(); new Runner (options).run(); } }
输出的测试报告如下:
Benchmark Mode Cnt Score Error Units
TestObjBenchmark.getByFor avgt 2 37.708 ns/op
TestObjBenchmark.getByMap avgt 2 22.698 ns/op
TestObjBenchmark.getByStream avgt 2 59.334 ns/op
执行效率的大小关系与先前预想的一致,但它们之间的差距其实也不大。
尽管使用 Stream
的效率最低,但在效率要求不高的场景下,使用 Stream
会让代码更加简洁、清晰,并能极大减少样板代码的输出。
4.3 报表可视化
除了在控制台中输出测试报告外,JMH 允许以不同的文件格式输出测试报告。比如以 JSON 文件的形式,将测试报告输出到当前模块下 target
目录的 result.json
文件中,则需要在构造 Options
对象时进行以下配置:
1 2 3 4 5 6 7 8 9 10 public static void main (String[] args) throws RunnerException { Options options = new OptionsBuilder () .include(TestObjBenchmark.class.getSimpleName()) .resultFormat(ResultFormatType.JSON) .result("./official-sample/target/result.json" ) .build(); new Runner (options).run(); }
在拿到 result.json
文件后,将该文件拖入以下任意一个网站中,即可实现报表可视化:
5. 高阶使用技巧
5.1 调整 JVM 编译器策略
首先介绍下 JVM 的方法内联:在编译过程中遇到方法调用时,将调用的目标方法体纳入编译范围之中,并取代原方法调用的优化手段。
比如现有一个私有方法 b()
:
1 2 3 4 5 6 private void b () { int i = 0 ; i++; System.out.println(i); }
在 a()
方法中调用了 b()
方法:
1 2 3 4 5 6 7 private void a () { for (int i = 0 ; i < 10 ; i++) { b(); } }
在调用 b()
方法时,执行 invokespecial
指令。
方法调用也是有性能消耗的,比如会进行方法入栈、分配栈上内存等。
JVM 在编译上述代码时,发现对 b()
方法的调用可以优化为方法内联,即:
1 2 3 4 5 6 7 private void a () { for (int i = 0 ; i < 10 ; i++) { int i = 0 ; i++; System.out.println(i); } }
此时不再有对 b()
方法的调用,能够提高执行效率。
注意: 不是说任何方法的调用都能优化成方法内联,具体细节不再展开,详细内容可以参考周志明《深入理解Java虚拟机》一书中第四部分【程序编译与代码优化】中【编译器优化技术】的【方法内联】。
为了测试方法内联带来的性能影响,JMH 提供了 @CompilerControl
注解,该注解能够告知 JVM 如何对代码进行编译优化(或者说不优化)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public void target_blank () {} @CompilerControl(CompilerControl.Mode.DONT_INLINE) public void target_dontInline () {} @CompilerControl(CompilerControl.Mode.INLINE) public void target_inline () {} @CompilerControl(CompilerControl.Mode.EXCLUDE) public void target_exclude () {}
对这些方法进行测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Benchmark public void baseline () {} @Benchmark public void blank () { target_blank(); } @Benchmark public void dontinline () { target_dontInline(); } @Benchmark public void inline () { target_inline(); } @Benchmark public void exclude () { target_exclude(); }
测试报告如下:
Benchmark Mode Cnt Score Error Units
Sample_16_CompilerControl.baseline avgt 3 0.196 ± 0.002 ns/op
Sample_16_CompilerControl.blank avgt 3 0.196 ± 0.006 ns/op
Sample_16_CompilerControl.dontinline avgt 3 0.811 ± 2.552 ns/op
Sample_16_CompilerControl.exclude avgt 3 8.508 ± 1.443 ns/op
Sample_16_CompilerControl.inline avgt 3 0.222 ± 0.792 ns/op
在采用 EXCLUDE
时,执行耗时最长。
dontinline()
与 inline()
相比,后者的执行效率更高,进行方法内联优化后,执行效率能得到提升。
5.2 同步线程
JMH 在默认情况下,如何执行多线程的基准测试?
JMH 会等待所有线程都启动准备好之后,再同时执行 Benchmark,此时系统负载最高,CPU 使用率最高。
这与实际生产环境上不一样,生产环境上显然不可能等待所有线程都准备好之后,再等待请求访问,系统负载应该是逐步上升,CPU 使用率也是逐步提高。
在使用 OptionsBuilder
构造 Options
对象时,提供了 syncIterations()
方法,该方法能够设置线程同步。默认情况下,其值为 true
,表示 JMH 将尝试同步所有工作线程的迭代,以便它们同时开始每次迭代,并在所有线程完成后结束迭代。这可以帮助减少线程之间的协调开销对基准测试结果的影响。
这个选项并不总是有用的。在某些情况下,同步迭代可能会导致额外的同步开销,从而扭曲基准测试结果。此外,如果测试的代码本身就包含同步,那么同步迭代可能会隐藏这些同步的开销。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @State(Scope.Thread) @OutputTimeUnit(TimeUnit.MILLISECONDS) public class Sample_17_SyncIterations { private double src; @Benchmark public double test () { double s = src; for (int i = 0 ; i < 1000 ; i++) { s = Math.sin(s); } return s; } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder () .include(Sample_17_SyncIterations.class.getSimpleName()) .warmupTime(TimeValue.seconds(1 )) .warmupIterations(1 ) .measurementTime(TimeValue.seconds(1 )) .threads(Runtime.getRuntime().availableProcessors() * 16 ) .forks(1 ) .syncIterations(true ) .build(); new Runner (opt).run(); } }
设置为 true
时:
Benchmark Mode Cnt Score Error Units
Sample_17_SyncIterations.test thrpt 5 8628.210 ± 2454.625 ops/ms
设置为 false
时:
Benchmark Mode Cnt Score Error Units
Sample_17_SyncIterations.test thrpt 5 47739.259 ± 98840.596 ops/ms
5.3 Control 对象
在 Benchmark 中写死循环是一件很危险的事,很有可能导致运行无法停止。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @State(Scope.Group) public class Sample_18_Control_1 { public final AtomicBoolean flag = new AtomicBoolean (true ); @Benchmark @Group("pingpong") public void pingWithoutControl () { while (true ) { boolean setSuccess = flag.compareAndSet(true , false ); if (setSuccess) { return ; } } } @Benchmark @Group("pingpong") public void pongWithoutControl () { while (true ) { boolean setSuccess = flag.compareAndSet(false , true ); if (setSuccess) { return ; } } } }
存在成员变量 flag
,pingWithoutControl()
和 pongWithoutControl()
方法都在死循环中尝试对 flag
进行修改。
这两个方法是并行运行的, 因此可能出现以下情况,导致陷入死循环:
pingWithoutControl()
方法检查 flag
,发现其值为 true
,尝试将其修改为 false
;
pingWithoutControl()
方法在尝试修改的同时,pongWithoutControl()
将 flag
从 false
改成了 true
;
此时 pingWithoutControl()
的 CAS 操作就会失败,期望值不再是 true
,就导致 pingWithoutControl()
一直处在死循环中。
同样的情况也可能发生在 pongWithoutControl()
方法中,不再赘述。
如果有办法判断 JMH 的测试是否已经结束,就能规避此处的死循环。当 JMH 测试结束时,也退出循环。
JMH 提供了 Control
类,当其作为 Benchmark 方法的参数时,JMH 能够将 Control
对象注入到方法中。Control
提供了 JMH 测试开始与结束的判断。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @State(Scope.Group) public class Sample_18_Control_2 { public final AtomicBoolean flag = new AtomicBoolean (true ); @Benchmark @Group("pingpong") public void pingWithoutControl (Control cnt) { while (true ) { if (cnt.stopMeasurement) { return ; } boolean setSuccess = flag.compareAndSet(true , false ); if (setSuccess) { return ; } } } @Benchmark @Group("pingpong") public void pongWithoutControl (Control cnt) { while (true ) { if (cnt.stopMeasurement) { return ; } boolean setSuccess = flag.compareAndSet(false , true ); if (setSuccess) { return ; } } } }
当 JMH 测试结束时,也会退出循环,无论运行几次,都不会出现死循环。
5.4 SpringBoot 环境下使用 JMH
定义 CallService
模拟实际生产环境中的 Service:
1 2 3 4 5 6 7 8 9 10 @Service public class CallService { public void call () { for (int i = 0 ; i < 100 ; i++) { double d = Math.sqrt(100 ); } } }
编写测试类使用 JMH 测试 CallService
中 call()
方法的性能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @SpringBootTest @State(Scope.Benchmark) @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class CallServiceTest { @Autowired private void setCallService (CallService callService) { BeanHolder.setBean(callService); } private CallService callService; @Setup public void setup () { callService = BeanHolder.getBean(); } @Benchmark public void call () { callService.call(); } @Test public void executeBenchmark () throws RunnerException { Options options = new OptionsBuilder () .include("\\." + this .getClass().getSimpleName() + "\\." ) .forks(0 ) .warmupIterations(1 ) .warmupTime(TimeValue.seconds(1 )) .measurementIterations(1 ) .measurementTime(TimeValue.seconds(1 )) .build(); new Runner (options).run(); } }
代码不长,有几个注意点:
JMH 相关的注解(比如 @State
、@BenchmarkMode
等)还是放在类上;
注入 CallService
不再采用字段注入,而是使用 Set 注入,并结合 BeanHolder
以便在 @Setup
方法中为成员变量设置初始值;
1 2 3 4 5 6 7 8 9 10 11 12 13 public class BeanHolder { private static Object bean; public static void setBean (Object bean) { BeanHolder.bean = bean; } @SuppressWarnings("unchecked") public static <T> T getBean () { return (T) bean; } }
在被 @Test
注解标记的测试方法中构造 Options
对象,执行 Runner
的 run()
方法进行测试;
构造 Options
对象时,forks()
方法的参数 一定要设置成 0 ,使得 Benchmark 和启动方法在同一个 JVM 中运行;
Benchmark 的编写与先前无异