虽然JMH可以帮我们更好地了解我们所编写的代码,但是如果我们所编写的JMH基准测试方法本身就有问题,那么就会很难起到指导的作用,甚至还会可能会产生误导,如何避免编写错误的微基准测试方法呢?
现代的Java虚拟机已经发展得越来越智能了,它在类的早期编译阶段、加载阶段以及后期的运行时都可以为我们的代码进行相关的优化,比如Dead Code的擦除、常量的折叠,还有循环的打开,甚至是进程Profiler的优化,等等,因此要掌握如何编写良好的微基准测试方法,首先我们要知道什么样的基准测试代码是有问题的。
1 避免DCE(Dead Code Elimination)
所谓Dead Code Elimination是指JVM为我们擦去了一些上下文无关,甚至经过计算之后确定压根不会用到的代码:
public void test(){
int x=10;
int y=10;
int z=x+y;
}
分别定义了x和y,并且经过相加运算得到了z,但是在该方法的下文中再也没有其他地方使用到z(既没有对z进行返回,也没有对其进行二次使用,z甚至不是一个全局的变量),JVM很有可能会将test()方法当作一个空的方法来看待,也就是说会擦除对x、y的定义,以及计算z的相关代码
@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(5)
@State(Scope.Thread)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
public class JMHExample13 {
/**
* 作为一个空的方法,主要用于做基准数据。
*/
@Benchmark
public void test1(){
// do nothing
}
/**
* 虽然进行了log运算,但是结果既没有再进行二次使用,也没有进行返回。
*/
@Benchmark
public void test2(){
Math.log(PI);
}
/**
* 同样进行了log运算,虽然第一次的运算结果是作为第二次入参来使用的,
* 但是第二次执行结束后也再没有对其有更进一步的使用。
*/
@Benchmark
public void test3(){
double res = Math.log(PI);
Math.log(res);
}
/**
* 该方法对运算结果进行了返回操作。
* @return
*/
@Benchmark
public double test4(){
double res = Math.log(PI);
return Math.log(res);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(JMHExample13.class.getSimpleName())
.build();
new Runner(options).run();
}
}
基准测试结果:
Benchmark Mode Cnt Score Error Units
JMHExample13.test1 avgt 10 0.001 ± 0.001 us/op
JMHExample13.test2 avgt 10 0.001 ± 0.001 us/op
JMHExample13.test3 avgt 10 0.001 ± 0.001 us/op
JMHExample13.test4 avgt 10 0.006 ± 0.001 us/op
可以发现test1,test2,test3 基本没有相差,因为test2,test3 的代码进行过擦除操作,这样的代码被称为Dead Code(死代码,其他地方都没有用到的代码片段),而test4则与上述两个方法不同,由于它对结果进行了返回,因此Math.log(PI)不会被认为它是Dead Code,因此它将占用一定的CPU时间。
若想要编写性能良好的微基准测试方法,则不要让方法存在Dead Code,最好每一个基准测试方法都有返回值。
2 使用Blackhole
假设在基准测试方法中,需要将两个计算结果作为返回值,那么我们该如何去做呢?我们第一时间想到的可能是将结果存放到某个数组或者容器当中作为返回值,但是这种对数组或者容器的操作会对性能统计造成干扰,因为对数组或者容器的写操作也是需要花费一定的CPU时间的。
JMH提供了一个称为Blackhole的类,可以在不作任何返回的情况下避免Dead Code的发生,Blackhole直译为“黑洞”
@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(5)
@State(Scope.Thread)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
public class JMHExample14 {
/**
* 会产生dead code
* 两次计算的结果虽然使用了,但是最后的结果没有使用
*/
@Benchmark
public void test1(){
double res1 = Math.log(PI);
double res2 = Math.log(PI);
double res = res1 + res2;
}
/**
* 两次的结果相加的结果返回了,所以不会产生dead code
*/
@Benchmark
public double test2(){
double res1 = Math.log(PI);
double res2 = Math.log(PI);
return res1 + res2;
}
/**
* 没有返回值,可以使用Blackhole
* @return
*/
@Benchmark
public void test3(Blackhole blackhole){
blackhole.consume(Math.log(PI));
blackhole.consume(Math.log(PI));
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(JMHExample14.class.getSimpleName())
.build();
new Runner(options).run();
}
}
Benchmark Mode Cnt Score Error Units
JMHExample14.test1 avgt 10 0.001 ± 0.001 us/op
JMHExample14.test2 avgt 10 0.006 ± 0.001 us/op
JMHExample14.test3 avgt 10 0.011 ± 0.001 us/op
Blackhole可以帮助你在无返回值的基准测试方法中避免DC(Dead Code)情况的发生。
3 避免常量折叠(Constant Folding)
常量折叠是Java编译器早期的一种优化——编译优化。在javac对源文件进行编译的过程中,通过词法分析可以发现某些常量是可以被折叠的,也就是可以直接将计算结果存放到声明中,而不需要在执行阶段再次进行运算。
private final int x = 10;
private final int y = x*20;
在编译阶段,y的值将被直接赋予200,这就是所谓的常量折叠。
@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(5)
@State(Scope.Thread)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
public class JMHExample15 {
private final int x1 = 200;
private final int x2 = 200;
private int y1 = 200;
private int y2 = 200;
/**
* 直接返回开方结果
* @return
*/
@Benchmark
public int test1(){
return 200;
}
/**
* 计算x1 * x2;
* @return
*/
@Benchmark
public double test2(){
return Math.sqrt(x1 * x2);
}
/**
* 计算x1 * x2;
* @return
*/
@Benchmark
public double test3(){
return Math.sqrt(y1 * y2);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(JMHExample15.class.getSimpleName())
.build();
new Runner(options).run();
}
}
Benchmark Mode Cnt Score Error Units
JMHExample15.test1 avgt 10 0.006 ± 0.001 us/op
JMHExample15.test2 avgt 10 0.006 ± 0.001 us/op
JMHExample15.test3 avgt 10 0.012 ± 0.001 us/op
可见test1和test2结果一样,test2就是编译阶段发生了变量折叠,在运行阶段根本不需要再进行计算
test3由于y是变量,不会发生变量折叠,所以运行阶段需要运算的。
这里就是演示一下,测试用例过于简单,说服力其实是不够的
4 避免循环展开(Loop Unwinding)
尽可能地避免或者减少在基准测试方法中出现循环,因为循环代码在运行阶段(JVM后期优化)极有可能被“痛下杀手”进行相关的优化,这种优化被称为循环展开
int sum=0;
for(int i = 0;i<100;i++){
sum+=i;
}
上面的例子中,sum=sum+i这样的代码会被执行100次,也就是说,JVM会向CPU发送100次这样的计算指令,这看起来并没有什么,但是JVM的设计者们会认为这样的方式可以被优化成如下形式(可能):
int sum=0;
for(int i = 0;i<20; i+=5){
sum+=i;
sum+=i+1;
sum+=i+2;
sum+=i+3;
sum+=i+4;
}
优化后将循环体中的计算指令批量发送给CPU,这种批量的方式可以提高计算的效率,假设1+2这样的运算执行一次需要1纳秒的CPU时间,那么在一个10次循环的计算中,我们觉得它可能是10纳秒的CPU时间,但是真实的计算情况可能不足10纳秒甚至更低
1.5 Fork用于避免Profile-guided optimizations
虽然Java支持多线程,但是不支持多进程,这就导致了所有的代码都在一个进程中运行,相同的代码在不同时刻的执行可能会引入前一阶段对进程profiler的优化,甚至会混入其他代码profiler优化时的参数,这很有可能会导致我们所编写的微基准测试出现不准确的问题
@BenchmarkMode(Mode.AverageTime)
@Fork(0) // Fork设置为0
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(5)
@State(Scope.Thread)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
public class JMHExample16 {
// Inc1 和Inc2的实现完全一样
interface Inc {
int inc();
}
public static class Inc1 implements Inc {
private int i = 0;
@Override
public int inc() {
return ++i;
}
}
public static class Inc2 implements Inc {
private int i = 0;
@Override
public int inc() {
return ++i;
}
}
private Inc inc1 = new Inc1();
private Inc inc2 = new Inc2();
private int measure(Inc inc) {
int result = 0;
for (int i = 0; i < 10; i++) {
result += inc.inc();
}
return result;
}
@Benchmark
public int measure_inc_1() {
return this.measure(inc1);
}
@Benchmark
public int measure_inc_2() {
return this.measure(inc2);
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(JMHExample16.class.getSimpleName())
.build();
new Runner(options).run();
}
}
测试结果:
Benchmark Mode Cnt Score Error Units
JMHExample16.measure_inc_1 avgt 10 0.007 ± 0.001 us/op
JMHExample16.measure_inc_2 avgt 10 0.034 ± 0.004 us/op
measure_inc_1和measure_inc_2的实现方式几乎是一致的,它们的性能却存在着较大的差距,
这其实就是JVM Profiler-guided optimizations导致的,由于我们所有的基准测试方法都与本次测试程序的JVM进程共享,因此难免在其中混入测试进程的Profiler,但是在将Fork设置为1的时候,也就是说每一次运行基准测试时都会开辟一个全新的JVM进程对其进行测试,那么多个基准测试之间将不会再存在干扰。
设置为1的测试结果:
Benchmark Mode Cnt Score Error Units
JMHExample16.measure_inc_1 avgt 10 0.011 ± 0.001 us/op
JMHExample16.measure_inc_2 avgt 10 0.010 ± 0.001 us/op
当然,你可以将Fork设置为大于1的数值,那么它将多次运行在不同的进程中,不过一般情况下,我们只需要将Fork设置为1即可。