JMH指北
今回介绍入门一款实用性不错的测试工具——JMH,其实了解这个东西也是一段时间之前的事情了,拖拖拉拉到现在,感觉已经忘记了大部分,所以就当重温吧。了解JMH的同时会自然而然接触到一些JVM相关的知识,值得学习一番。
简介
JMH全称Java Microbenchmark Harness,翻译过来就是Java微基准测试工具套件。很明显它是一款Java的测试工具,而其中的微基准则表明了它的适用层级。对代码性能的追逐是码农常常需要做的事情,那么代码的性能到底怎么样,不能靠嘴巴说而需要量化的指标,很多开源工具会给出JMH的对比测试结果来显示自己性能是如何的优越。如今计算机的算力对于执行一段代码块来说,很有可能就是几纳秒的事情,因此为了得出“肉眼可见”的结论,往往需要循环重试。没有接触JMH之前我相信大多数人都做过把一个方法用for循环执行n次并且记录起始结束时间来验证这个方法耗时如何的事情,这对于纯编译执行的语言或许没什么问题,但是对于Java或者基于JVM的语言来说并不能得到最准确的结果,JVM做了很多我们看不到的事情,所以同一个测试运行多次可能会看到差别较大的结果。而JMH就是为了解决这个问题而来, 它由JVM开发人员编写,编写JMH不是一件容易的事情,因为这需要非常熟悉JVM的运行机制。
用法说明
JDK9以上的版本自带了JMH,其他版本则需要引入相关的依赖。JMH的主页很简单,基本上就只是有一个指向Github项目地址的连接,而Github项目中的主页也只是给出了一些简单的用法说明,其余的只是告诉你去看样例来理解。其实这样我觉得挺不错,所以本篇的内容主要就是照着样例一个一个解释。
官方项目说明文档中写明了推荐使用命令行来执行测试,首先采用maven来创建项目的基本骨架,然后编写测试代码并打包,最后使用命令行调用执行jar包。在编写时推荐将JMH构建成为一个独立的项目,在关系上依赖具体的应用项目,这样能够确保基准测试程序正确地初始化并产生可靠的结果。当然也可以选择在IDE中直接运行,现在流行的IDE如IDEA中也提供了相关插件,在进行了解学习时是个不错的使用方式。
样例说明
01 HelloWorld
1 | public class JMHSample_01_HelloWorld { |
JMH的工作方式如下: 用户使用@benchmark 注释方法,然后 JMH执行生成的代码,以此尽可能可靠地执行该测试方法。请阅读@Benchmark的javadoc注释来了解完整的语义和限制。方法名称并不重要,只要方法用@benchmark 它就会被认为是一个基准测试方法,在同一个类中可以有多个基准方法。注意如果基准测试方法永远不结束,那么JMH运行也永远不会结束。如果您从方法体中抛出异常,JMH 运行会立刻结束这个基准测试,然后执行列表中的下一个基准测试。尽管这个基准测试什么也没有执行,但它很好地展示了基础结构对于测量的负载,没有任何基础设施不会招致任何开销,重要的是要知道你正在处理的基础管理费用是多少。在将来的示例中,你可能会发现这种思想是通过比较“基线”测量结果而展开的。
02 BenchmarkModes
1 | public class JMHSample_02_BenchmarkModes { |
这个例子介绍了注解@BenchmarkMode以及配合使用的@OutputTimeUnit,这个注解接收的枚举值代表了测试类型,注意注解接收的是数组类型,这代表你可以同时执行多种测试。
- Throughput:单位时间内的执行次数。过在有限迭代时间内不断调用基准方法并计算执行该方法的次数来度量原始吞吐量。
- AverageTime:每次执行的平均耗时。它与Throughput相似,只是有时度量时间更方便。
- SampleTime:采样每次执行的时间。在这种模式下,仍然是在有时间限制的迭代中运行该方法,但是不测量总时间,而是测量某几次调用测试方法所花费的时间。主要是为了推断时间分布和百分比。JMH会尝试自动调整采样频率,如果方法执行过于缓慢会导致所有执行都会被采集。
- SingleShotTime:测量单次执行时间。迭代次数在这种模式下是无意义的,这种模式对于测试冷启动执行效果很有用。
- All:所有模式集合。
样例的javadoc中还说明了如果你对某些执行行为感到疑惑,可以尝试查看生成的代码,你可能会发现代码并没有在做你期望做的事情。
03 States
1 | public class JMHSample_03_States { |
很多时候在执行基准测试的时候你需要维护某些状态,同时JMH经常用于构建并发型基准测试,因此提供了状态对象的标记注解:@State,使用其标注的对象将会被按需构建并且在整个测试过程中按照给定的范围重用。注意State对象总是会被某一个需要获取它的线程实例化,这意味着你可以像在工作线程中那样初始化字段。基准测试方法可以直接引用这些State对象(作为方法参数),JMH会自动做注入操作。
04 Default State
1 |
|
很多情况下你只需要一个状态对象,此时你可以选择将基准测试类自身标记@State,这样就能够很方便地引用自身的成员。
05 State Fixtures
1 |
|
因为State对象在benchmark生命周期中维持,因此相关状态管理方法会有所帮助,JMH提供了一些常见的状态管理方法,如果使用Junit或者TestNG会对这些非常熟悉。这些管理方法只会对State对象有效,否则JMH将会编译失败。同时方法只会在某个使用State对象的线程中调用,这意味着管理方法内是线程私有环境。
06 Fixture Level
1 |
|
状态管理方法可以在不同层级执行,主要提供了三种:
- Level.Trial:在整个benchmark执行前后调用
- Level.Iteration:在每次迭代执行前后调用
- Level.Invocation:在每次方法调用前后执行。注意如果要使用这个级别请仔细查看相关javadoc,了解其使用限制
执行状态管理方法耗费的时间不会统计入结果,所以在方法内可以做一些比较重的操作。
07 Fixture Level Invocation
1 |
|
给了一个Level.Invocation的使用示例。可以看到定义了三个State对象,并且前两个有继承关系,注意到状态对象方法的Level有所不同。两个benchmark方法虽然内容相同,但是因为使用了不同的State对象,measureCold方法会在每次调用前睡10ms,以此模拟对比线程池不同使用形式下的表现。
Level.Invocation对于每次执行都要执行一些前置或者后续操作时会比较方便,但是使用它你需要仔细阅读它的javadoc说明。在它的javadoc中说明它主要适用于执行时间超过1ms的方法,并给出了四点警示:
- 因为Setup、Teardown等方法不能计入性能统计结果,因此使用这个Level时必须对每次调用单独计时,如果方法调用时间很短,那么为了计时所发起的获取系统时间戳的调用将会影响测试结果甚至造成瓶颈
- 还是因为单独计时造成的问题,由于单独计时然后累加,这可能造成精度丢失,求和得到较短的时间
- 为了维持与其他Level相同的共享行为,JMH有时需要在访问state对象时进行synchronized同步,这有可能使测量结果偏移正确值
- 根据当前的实现,辅助方法与基准测试方法是交叠执行的,这在多线程基准测试时可能会有影响,比如某个线程在执行基准测试方法时可以观察到别的线程已经调用了TearDown从而导致发生异常。
08 Dead Code
1 |
|
这个例子说明了Dead Code陷阱,许多基准测试失败的原因是因为没有考虑Dead-Code Elimination(DCE 死代码消除)。编译器非常聪明,能够推断出某些计算是多余的,并将其完全消除,如果被淘汰的部分是我们的基准测试代码,那么就会出现问题。所幸JMH提供了必要的基础设施来应对这种状况,你可以为方法定义返回值,将计算结果返回,这样JMH就会添加对DCE的对应处理。
09 Blackholes
1 |
|
这个例子引出了最终处理DCE的对象Blackhole,如果基准测试方法只有一个计算结果那么你可以直接将其返回,JMH对隐式调用Blockhole来处理返回值。但是如果测试方法有多个返回值,则可以尝试直接引入Blackhole对象手动处理。
10 Constant Fold
1 |
|
这个例子与JVM的优化——常量折叠相关。如果JVM发现计算的结果无论如何都是一样的即是一个常量,它可以巧妙地对其进行优化。在给出的例子中,这意味着我们可以将计算移到内部JMH循环之外。通常我们可以通过读取非final的State对象字段来避免这种情况。注意IDE有时会给出将字段定义为final的建议,这对于普通代码来说是正确的,但是在基准测试情况下需要仔细考虑。
11 Loops
1 |
|
这个例子表明了使用者不应该在基准测试方法中主动添加循环并减少方法调用次数。循环是为了最小化调用测试方法的开销,通过在内部循环而不是在方法调用层面循环调用——这个观点是不正确的,当我们允许优化器合并循环迭代时,你会看到一些意想不到的情况。
执行上面的代码可以发现,当JVM对内部循环进行优化以后,耗时表现有10倍的提升(机子不同可能有所差别)。
12 Forking
1 |
|
JVM 擅长profile-guided optimizations。 这对基准测试来说是不利的,因为不同的测试可以将它们的profile混合在一起,然后为每个测试提供“统一糟糕”的代码。 Fork(在单独的进程中运行)每个测试可以规避这个问题。JMH默认会Fork进程来处理测试方法。可以在测试时查看进程来验证。
上面的样例代码中,Counter1和Counter2在逻辑上是等价的,但是在JVM看来仍然是不同的对象。因此在同一个进程中交替混合执行两种计数方法,会导致性能反而出现下降的情况,measure_3_c1_again的表现会明显差于measure_1_c1。
13 Run To Run
1 |
|
JVM是一个复杂的系统,这也会导致很多的不确定性。有时我们必须要考虑单次执行的差异性,而JMH提供的Fork特性在规避PGO的同时也会自动将所有进程的结果归入统计结果,方便我们使用。代码样例中,sleepTime由随机数计算得出,以此模拟每次执行的差异性。
14 N/A
样例被删除了?
15 Asymmetric
1 |
|
这个例子介绍了Group的概念,在此之前,所有的测试都是对称一致的,所有的线程执行相同的代码。有了Group就可以执行非对称测试,它可以将多个方法绑定在一起并且规定线程应该如何分布。以上述代码为例,两个方法inc和get都属于同一个group g,但是分配了不同的线程数量,执行测试时可以发现有3个线程执行inc方法、1个线程执行get方法。如果使用4个线程来执行测试,只会生成一个执行组,使用4*N个线程将会调用N个执行组。
注意State对象的范围还包括Scope.Group,这能够使得State对象在每个group内部分享。
16 Complier Control
1 |
|
这个例子表明了可以使用注解来告诉编译器执行一些特定的操作,比如是否进行方法内联(inline)。具体查看上面的代码就可以,比较明确。
17 Sync Iterations
1 |
|
这个样例想要表达的内容主要在注释中。实践表明如果使用多线程来执行基准测试,工作线程的开始和结束方式将严重影响性能表现。通常的做法是将所有线程停止在某个类似栅栏的地方让后统一放行,但是这样并不是很有效,因为这并不能保证工作线程在同一时间开始工作。更好的解决方案是引入虚假迭代,增加执行迭代的线程,然后原子性地将系统转移到执行测量方法上,在停止过程中也可以做同样的事情。这听起来很复杂,但是JMH已经处理好了,即执行选项:syncIterations。
18 Control
1 |
|
本样例介绍了一个实验性质的工具类Control,其用途主要是为了在条件执行的情况下能够停止基准测试方法执行,如果基准方法不停止整个测试将不会结束。上面的例子中,在同一个group内两个方法分别执行cas操作,若果没有Control的介入,在测试停止时,其中一个方法将会陷入死循环。
19 N/A
样例被删除了?
20 Annotations
1 | public class JMHSample_20_Annotations { |
JMH不仅支持在运行时使用Options对象来配置执行参数,同样也支持使用注解来进行配置,包括@Measurement、@Warmup等等,大部分配置参数都能够找到对应的注解。
21 Consume CPU
1 |
|
介绍了一种“空转”的方法,有时候可能就是需要消耗掉一部分性能,可以使用Blockhole的静态方法来快速实现这个目的。
22 False Sharing
1 |
|
伪共享是并发编程中常见的问题,缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行会因为缓存失效导致性能下降。这个问题在微基准测试中同样不能忽略。这个样例给出了解决这个问题的几种方法:
- 字段填充:额外定义多个字段来填补缓存行
- 类继承:也是填充的一种,将多余字段定义在父类里
- 数组填充:定义一个较长的数组,有效数据的间隔大于缓存行大小
- 注解:JDK 8提供了@Contended注解来告诉编译器被注解的字段需要填充
23 Aux Counters
1 |
|
辅助计数器,不是很常见,就直接翻译一下吧:在一些特殊的情况下,你需要根据当前代码执行的结果来区分获取的吞吐量/时间指标。 为了应对这种情况,JMH提供了特殊的注释,将@State对象视为承载用户计数器的对象。 有关限制请参阅@AuxCounters的javadoc。
24 Inheritance
1 | public class JMHSample_24_Inheritance { |
JMH允许使用继承,你可以在抽象父类中使用注解来配置基准测试并且提供一些需要实现的抽象方法。@Benchmark这个注解是可以被继承的,所有子类都会具有父类的基准测试方法。值得注意的是,由于这是编译期才能知道的关系,因此需要注意JMH编译阶段。此外,注解的生效规则是在继承树中最近的注解将会生效。
25 API GA
这个样例有些复杂,不是很懂,先不谈了好吧。。
26 Batch Size
1 |
|
如果测试方法的执行效率并不是稳定的,即每次执行测试都存在较大的差别,在这种情况下以固定时间范围执行测试是不可行的,因此必须选用Mode.SingleShotTime。但是与此同时只执行一次对于该操作来说无法得到可信赖的测试结果,此时就可以选择使用batchSize参数。
对于上面的例子来说,所做的事情是在测试在链表中间插入对象,这个操作受到链表长度的影响,因此效率不是稳定的。为了达到每次测试的执行环境等价,需要执行固定次数,所以对于measureRight这个正确基准测试方法的行为可描述为:迭代5轮,每轮执行一次,每次调用5000次测试方法。
27 Params
1 |
|
这个例子比较好理解也很实用,很多时候需要比较不同配置参数下的测试结果,JMH也提供了多参数执行的能力,你可以通过@Param注解和param配置项来给出参数候选项。注意在有多个测试参数且都包含多个候选项的情况下,JMH会执行所有参数的排列组合。
28 Blackhole Helpers
1 |
|
这个样例表明你可以在一些辅助方法中使用、保存Blackhole对象。在样例中,Setup方法的方法参数带有Blackhole,并以此对接口进行了不同的实现。这种注入能力对于一些其他JMH基础工具同样适用,比如Control。
29 States DAG
1 |
|
本例描述的是State对象存在依赖关系的情况,JMH允许各个State对象存在DAG(有向无环图)形式的依赖关系。在例子中Thread Scope的Local对象依赖Benchmark Scope的Shared对象,每个Local对象都会从Shared对象的队列成员中取出专属的Counter。这是个实验性质的特性,不是很常用,简单了解即可。
30 Interrupts
1 |
|
JMH能够给Benchmark方法设值超时时间,在超时后主动interrupt方法调用。上面的例子与样例18类似但是没有Control对象来控制,因此在测试进入停止阶段时会有某个方法block住。JMH会在默认或设置的超时时间到达时进行打断并提示用户进行了打断操作,方便用户判断打断是否影响测试结果。
31 Infra Params
1 |
|
JMH提供了一些能够在运行时查询当前配置的工具类,方便在代码逻辑中根据配置进行操作。主要包括三个参数对象:BenchmarkParams、IterationParams、ThreadParams,这个应该无需多解释,字面意思。
32 Bulk Warmup
1 |
|
这是对样例12的补充,在样例12中我们知道为了不影响JVM的PGO优化,JMH会默认Fork进程使每个基准测试方法在独立的JVM中预热、执行。但是也有可能用户就是想测试在混杂执行的情况下的执行情况,此时可以通过设置warmupMode为WarmupMode.BULK来控制JMH运行所有方法的预热后再执行相关基准测试方法。注意JMH仍然会为每个方法Fork进程,只是每个进程开始执行时的预热行为发生了改变。
33 Security Manager
1 |
|
这个样例是关于安全方面的说明,Java安全主要依靠Security Manager。样例给出了指定安全策略以及无安全管理的测试对比方式。
34 Safe Looping
1 |
|
这个样例是对样例11的补充,通过样例11我们知道在编写测试方法时不应该手动执行循环而应该让JMH在方法调用层面进行操作,但是有时循环无法避免,比如测试查询数据库后遍历获取的数据列表,此时循环是测试方法不可分离的一部分。针对这种情况,上述示例代码给出了错误和正确的处理方法。首先直白的循环一定是错误的,JVM会执行内联、推断、简化等各种操作使得代码块“失效”,最方便的处理方式是在循环内使用Blackhole对象,但是如果Blackhole的方法调用占据了基准测试的大部分时间那也无法得到正确的测试结果,此时可以考虑定义一个阻止内联的空方法来代替Blackhole,但是这个操作非常vm-specific,只有必要的时候才应该使用,请详细阅读上面代码中的相关注释说明。
35 Profilers
JMH提供了一些非常方便的分析器,可以帮助用户了解基准测试的细节信息。 虽然这些分析器不能替代成熟的外部分析器,但在许多情况下它们可以方便快速地深入研究基准行为。 当你在对基准代码本身进行不断地调整时,快速获得结果非常重要。这个例子中给出了许多分析器的执行结果说明,示例内容比较长,请直接在Github中查看。
36 Branch Prediction
1 |
|
这个样例表述的内容与JVM的一个优化功能相关:分支预测,发生这种类型的问题主要是由规整的数据集造成。在编写代码时很有可能因为简单的生成规则或者代码美感偏向之类的原因导致数据非常规整,但这恰恰会适得其反。众所周知,规则的数据集可以被软件或硬件良好地优化,而分支预测正是其中一种手段。
在代码例子中给出了两种byte数组数据集合:乱序的和有序的,然后对其分别执行逻辑相同的测试方法:循环数组根据元素是否大于0来执行对应的代码块。显然,排序的数组在正负分界点前后只会执行固定的代码块,这使得JVM可以利用这一点进行优化。
37 Cache Access
1 |
|
这个样例讨论的并不是JMH本身,而是说明缓存读取形式所带来的影响,很多时候性能上的差别都可以通过访问内存方式的差别来解释。样例中使用遍历矩阵来说明了这个观点,两个基准测试方法分别通过行优先和列优先的形式来遍历矩阵,得到的结果是列优先的遍历方式明显会更慢一些。
38 Per Invoke Setup
1 |
|
最后这个样例举了一个冒泡排序的例子,由于排序操作是对数组直接进行修改且冒泡排序受到数组本身顺序的影响,因此在相同环境下重复执行排序并不是稳定的操作。很明显measureWrong方法直接进行循环排序是错误的;接下来按照直白的想法,用户通常会选择采用Level.Invocation级别的Setup操作来在每次方法调用之前对数组进行拷贝,这样操作逻辑上是没有问题的,但是Level.Invocation是一个需要小心使用的调用级别,你必须仔细阅读相关javadoc说明,这在前面的样例中也有提到,JMH并不推荐使用这种形式;最后给到的measureRight方法直接把数组拷贝放在了基准测试方法块内部,尽管看起来不太好,但是在逻辑代码执行时间占绝对主导的情况下,这是经过实践得出的最佳实践。最后样例也给出了他们实际执行的结果对比。