JAVA拾遗 — JMH与8个代码陷阱

作者:kiritomoe  

来源:Kirito的技术分享


前言


JMH (http://openjdk.java.net/projects/code-tools/jmh/) 是 Java Microbenchmark Harness(微基准测试)框架的缩写(2013年首次发布)。与其他众多测试框架相比,其特色优势在于它是由 Oracle 实现 JIT 的相同人员开发的。在此,我想特别提一下 Aleksey Shipilev (http://shipilev.net/)(JMH 的作者兼布道者)和他优秀的博客文章。


笔者花费了一个周末,将 Aleksey 大神的博客,特别是那些和 JMH 相关的文章通读了一遍,外加一部公开课视频 《"The Lesser of Two Evils" Story》 ,将自己的收获归纳在这篇文章中,文中不少图片都来自 Aleksey 公开课视频。


阅读本文前


本文没有花费专门的篇幅在文中介绍 JMH 的语法,如果你使用过 JMH,那当然最好,但如果没听过它,也不需要担心(跟我一周前的状态一样)。我会从 Java Developer 角度来谈谈一些常见的代码测试陷阱,分析他们和操作系统底层以及 Java 底层的关联性,并借助 JMH 来帮助大家摆脱这些陷阱。


通读本文,需要一些操作系统相关以及部分 JIT 的基础知识,如果遇到陌生的知识点,可以留意章节中的维基百科链接,以及笔者推荐的博客。


笔者能力有限,未能完全理解 JMH 解决的全部问题,如有错误以及疏漏欢迎留言与我交流。


初识 JMH


  • 测试精度


JAVA拾遗 — JMH与8个代码陷阱_第1张图片

测试精度


上图给出了不同类型测试的耗时数量级,可以发现 JMH 可以达到微秒级别的的精度。


这样几个数量级的测试所面临的挑战也是不同的。

  • 毫秒级别的测试并不是很困难

  • 微秒级别的测试是具备挑战性的,但并非无法完成,JMH 就做到了

  • 纳秒级别的测试,目前还没有办法精准测试

  • 皮秒级别…Holy Shit


图解:

  • Linpack : Linpack benchmark 一类基础测试,度量系统的浮点计算能力

  • SPEC:Standard Performance Evaluation Corporation 工业界的测试标准组织

  • pipelining:系统总线通信的耗时


Benchmark 分类


测试在不同的维度可以分为很多类:集成测试,单元测试,API 测试,压力测试… 而 Benchmark 通常译为基准测试(性能测试)。你可以在很多开源框架的包层级中发现 Benchmark,用于阐释该框架的基准水平,从而量化其性能。


基准测试又可以细分为 :Micro benchmark,Kernels,Synthetic benchmark,Application benchmarks.etc.本文的主角便属于 Benchmark 的 Micro benchmark。


基础测试分类详细介绍 here (http://prof.ict.ac.cn/DComputing/uploads/2013/DC_1_3_benchmark.pdf)

JAVA拾遗 — JMH与8个代码陷阱_第2张图片

motan中的benchmark


  • 为什么需要有 Benchmark


If you cannot measure it, you cannot improve it.

--Lord Kelvin


俗话说,没有实践就没有发言权,Benchmark 为应用提供了数据支持,是评价和比较方法好坏的基准,Benchmark 的准确性,多样性便显得尤为重要。


Benchmark 作为应用框架,产品的基准画像,存在统一的标准,避免了不同测评对象自说自话的尴尬,应用框架各自使用有利于自身场景的测评方式必然不可取,例如 Standard Performance Evaluation Corporation (SPEC) 即上文“测试精度”提到的词便是工业界的标准组织之一,JMH 的作者 Aleksey 也是其中的成员。


  • JMH 长这样


@Benchmark
public void measure() {
    // this method was intentionally left blank.
}


使用起来和单元测试一样的简单


它的测评结果

Benchmark                                Mode  Cnt           Score           Error  Units
JMHSample_HelloWorld.measure  thrpt    5  3126699413.430 ± 179167212.838  ops/s


  • 为什么需要 JMH 测试


你可能会想,我用下面的方式来测试有什么不好?


long start = System.currentTimeMillis();
measure();
System.out.println(System.currentTimeMillis()-start);


难道 JMH 不是这么测试的吗?


@Benchmark
public void measure() {
}


事实上,这是本文的核心问题,建议在阅读时时刻带着这样的疑问,为什么不使用第一种方式来测试。在下面的章节中,我将列举诸多的测试陷阱,他们都会为这个问题提供论据,这些陷阱会启发那些对“测试”不感冒的开发者。


  • 预热


在初识 JMH 小节的最后,花少量的篇幅来给 JMH 涉及的知识点开个头,介绍一个 Java 测试中比较老生常谈的话题 — 预热(warm up),它存在于下面所有的测试中。


«Warmup» = waiting for the transient responses to settle down


特别是在编写 Java 测试程序时,预热从来都是不可或缺的一环,它使得结果更加真实可信。


JAVA拾遗 — JMH与8个代码陷阱_第3张图片

warmup plateaus


上图展示了一个样例测评程序随着迭代次数增多执行耗时变化的曲线,可以发现在 120 次迭代之后,性能才趋于最终稳定,这意味着:预热阶段需要有至少 120 次迭代,才能得到准确的基础测试报告。(JVM 初始化时的一些准备工作以及 JIT 优化是主要原因,但不是唯一原因)。需要被说明的事,JMH 的运行相对耗时,因为,预热被前置在每一个测评任务之前。


使用 JMH 解决 12 个测试陷阱


  • 陷阱1:死码消除


JAVA拾遗 — JMH与8个代码陷阱_第4张图片


measureWrong 方法想要测试 Math.log 的性能,得到的结果和空方法 baseline 一致,而 measureRight 相比 measureWrong 多了一个 return,正确的得到了测试结果。


这是由于 JIT 擅长删除“无效”的代码,这给我们的测试带来了一些意外,当你意识到 DCE 现象后,应当有意识的去消费掉这些孤立的代码,例如 return。JMH 不会自动实施对冗余代码的消除。


死码消除这个概念很多人其实并不陌生,注释的代码,不可达的代码块,可达但不被使用的代码等等,我这里补充一些 Aleksey 提到的概念,用以阐释为何一般测试方法难以避免引用对象发生死码消除现象:

  1. Fast object combinator.

  2. Need to escape object to limit thread-local optimizations.

  3. Publishing the object ⇒ reference heap write ⇒ store barrier.


很绝望,个人水平有限,我没能 get 到这些点,只能原封不动地贴给大家看了。


JMH 提供了专门的 API — Blockhole 来避免死码消除问题。


@Benchmark
public void measureRight(Blackhole bh) {
    bh.consume(Math.log(PI));
}


  • 陷阱2:常量折叠与常量传播


常量折叠 (Constant folding) 是一个在编译时期简化常数的一个过程,常数在表示式中仅仅代表一个简单的数值,就像是整数 2,若是一个变数从未被修改也可作为常数,或者直接将一个变数被明确地被标注为常数,例如下面的描述:

  i = 320 * 200 * 32;


多数的现代编译器不会真的产生两个乘法的指令再将结果储存下来,取而代之的,他们会辨识出语句的结构,并在编译时期将数值计算出来(在这个例子,结果为 2,048,000)。


有些编译器,常数折叠会在初期就处理完,例如 Java 中的 final 关键字修饰的变量就会被特殊处理。而将常数折叠放在较后期的阶段的编译器,也相当常见。


private double x = Math.PI;

// 编译器会对 final 变量特殊处理 
private final double wrongX = Math.PI;

@Benchmark
public double baseline() // 2.220 ± 0.352 ns/op
    return Math.PI;
}

@Benchmark
public double measureWrong_1() // 2.220 ± 0.352 ns/op
    // 错误,结果可以被预测,会发生常量折叠
    return Math.log(Math.PI);
}

@Benchmark
public double measureWrong_2() // 2.220 ± 0.352 ns/op
    // 错误,结果可以被预测,会发生常量折叠
    return Math.log(wrongX);
}

@Benchmark
public double measureRight() // 22.590 ± 2.636  ns/op
    return Math.log(x);
}


经过 JMH 可以验证这一点:只有最后的 measureRight 正确测试出了 Math.log 的性能,measureWrong_1,measureWrong_2 都受到了常量折叠的影响。


常数传播(Constant propagation) 是一个替代表示式中已知常数的过程,也是在编译时期进行,包含前述所定义,内建函数也适用于常数,以下列描述为例:

  int x = 14;
  int y = 7 - x / 2;
  return y * (28 / x + 2);


传播可以理解变量的替换,如果进行持续传播,上式会变成:

  int x = 14;
  int y = 0;
  return 0;


  • 陷阱3:永远不要在测试中写循环


这个陷阱对我们做日常测试时的影响也是巨大的,所以我直接将他作为了标题:永远不要在测试中写循环!


本节设计不少知识点,循环展开(loop unrolling),JIT & OSR 对循环的优化。对于前者循环展开的定义,建议读者直接查看 wiki 的定义,而对于后者 JIT & OSR 对循环的优化,推荐两篇 R 大的知乎回答:

循环长度的相同、循环体代码相同的两次for循环的执行时间相差了100倍?

OSR(On-Stack Replacement)是怎样的机制?


对于第一个回答,可以直接看答案,问题本身有待商榷;第二个回答,阐释了 OSR 都对循环做了哪些手脚。


测试一个耗时较短的方法,入门级程序员(不了解动态编译的同学)会这样写,通过循环放大,再求均值。


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("ns/op : " + (endTime - startTime));
    }
}


实际上,这段代码的结果是不可预测的,太多影响因子会干扰结果。原理暂时不表,通过 JMH 来看看几个测试方法,下面的 Benchmark 尝试对 reps 方法迭代不同的次数,想从中获得 reps 真实的性能。(注意,在 JMH 中使用循环也是不可取的,除非你是 Benchmark 方面的专家,否则在任何时候,你都不应该写循环)


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
@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(1000)
public int measureWrong_1000() {
    return reps(1000);
}

@Benchmark
@OperationsPerInvocation(10000)
public int measureWrong_10000() {
    return reps(10000);
}

@Benchmark
@OperationsPerInvocation(100000)
public int measureWrong_100000() {
    return reps(100000);
}


结果如下:

Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_11_Loops.measureRight         avgt    5  2.343 ± 0.199  ns/op
JMHSample_11_Loops.measureWrong_1       avgt    5  2.358 ± 0.166  ns/op
JMHSample_11_Loops.measureWrong_10      avgt    5  0.326 ± 0.354  ns/op
JMHSample_11_Loops.measureWrong_100     avgt    5  0.032 ± 0.011  ns/op
JMHSample_11_Loops.measureWrong_1000    avgt    5  0.025 ± 0.002  ns/op
JMHSample_11_Loops.measureWrong_10000   avgt    5  0.022 ± 0.005  ns/op
JMHSample_11_Loops.measureWrong_100000  avgt    5  0.019 ± 0.001  ns/op


如果不看事先给出的错误和正确的提示,上述的结果,你会选择相信哪一个?实际上跑分耗时从 2.358 随着迭代次数变大,降为了 0.019。手动测试循环的代码 BadMicrobenchmark 也存在同样的问题,实际上它没有做预热,效果只会比 JMH 测试循环更加不可信。


Aleksey 在视频中给出结论:假设单词迭代的耗时是

你可能感兴趣的:(JAVA拾遗 — JMH与8个代码陷阱)