你好,我是尉刚强。从这节课开始,我们就进入了课程的第三个模块:性能看护篇。接下来,我们会用5节课的时间,来学习和掌握性能测试的核心理论、测试工具的选择和使用,并理解如何才能更好地集成在流水线中监控软件产品性能的能力。

今天,我们先来了解下基准测试(Benchmark)的分类,并重点学习下在进行微基准测试时都会碰到哪些问题,以及高效实现微基准测试的方法步骤和手段。

现在,我想先问你一个问题:软件为什么要进行基准测试呢?

实际上,从软件生命周期的视角来看,由于新需求的不断引入,导致软件实现在持续不断地演进与变化,而在这个过程中,软件的熵会不断增大,同时软件的性能也很容易被不断地劣化。所以说,性能优化是一个持续改进的过程,如果没有好的措施来看护软件的性能基线,就很容易导致软件系统的性能长期处于不稳定的状态。

那么,基准测试的目的,就是为软件系统获取一个已知的基线水平。这样,当软件修改变化导致性能发生劣化的时候,我们就可以在第一时间发现问题。

但是,如何对软件系统做好基准测试,是一件非常有挑战的事情!我举个简单的例子,有些互联网SaaS服务在进行性能测试时,需要很大规模的用户接入,可是这在测试场景下是很难构造的。

另外,基准测试按照被测系统规模,可以分为微基准测试与宏基准测试。其中,微基准测试主要针对的是软件编码实现层面上的性能基线测试,而宏基准测试则是针对产品系统级所开展的性能基线测试。

所以今天这节课,我会先给你介绍下微基准测试中面临的一些核心挑战与难点,带你分析如何才能做好微基准测试。至于宏基准测试的相关知识点,我会在下节课给你讲解。

不过在开始之前,我还要说明一点,就是由于微基准测试与编程语言实现的相关性比较大,所以接下来,我主要是从程序员使用非常多的Java语言为出发点,来给你介绍微基准测试面临的问题。

OK,下面我们就从Java软件程序的微基准测试开始,来了解下即时编译对代码实现性能测试的影响吧。

JIT对代码实现性能测试影响

事实上,对于Java软件程序来说,进行微基准测试其实存在很大的挑战,而这其中最大的挑战就来自于JIT(Just In Time),也就是JDK中的HotSpot虚拟机的即时编译技术。

JIT技术会在程序运行过程中,寻找到热点代码,并将这部分代码提前编译成机器码保存起来,这样在下次运行时就可以避免解释执行,而是可以直接运行机器码,以此提升系统性能。

那么JIT又是如何影响微基准测试呢?下面我就通过几个场景案例,来给你介绍说明下。

首先,在代码运行的过程中,JIT中会对一些比较小的函数方法实施内联优化,也就是将一个函数方法(对象方法)生成的指令直接插入到被调用函数的指令内,这样就可以通过减少函数调用开销来提升执行性能。

然后,针对程序中For循环频繁执行的代码块,JIT也会根据循环执行次数来决定是否启动编译优化,当满足一定的次数门限后,就会实施栈上替换(OSR),也就是把循环体内生成的字节码替换为编译好的机器码来加速执行,从而导致For循环在不同遍历中的执行代码和运行时间不一致。

同时,JIT的代码优化是实时动态的行为,会受制于Code Cache的大小限制。所以,如果优化后的运行效果不理想,JIT还会触发逆优化,它的功能是把原来放到Code Cache中的机器码删除掉,这部分代码又回退为Java字节码执行。

所以综上所述,这些技术手段其实都会造成代码的执行时间发生变化,进一步就会影响微基准测试(但这只是JIT即时优化技术中很小的一部分,这里我们只需明白JIT技术会影响到代码的微基准测试结果即可)。

而除了各种技术手段的影响之外,还有一个原因,就是Java虚拟机在运行期存在两种模式:Client模式和Server模式。Client模式主要追求编译期的优化速度,而Server模式更关注运行期的性能,所以针对这两种模式,JIT进行热点代码优化的默认策略并不一样,这也会直接影响到微基准测试的结果。

那么根据以上的分析,我们怎样才能避免JIT对微基准性能测试带来如此大的干扰呢?

答案就是使用充足的代码预热。也就是说,你首先需要将Java的被测代码循环执行很多次,以确保代码已经被JIT优化过,然后再对该段代码进行微基准测试,来获取测量值(如何更方便地进行预热,我会在后面的JMH测试框架部分讲解)。

补充:在C/C++语言中,由于在编译期间,所有代码都被编译转换成了汇编指令,所以在对代码段进行性能测试时,并不需要这个单独的预热阶段。

所以简而言之,微基准测试就是对代码执行时间的一项测量活动,而既然是对时间的测量,肯定就会受到测量精度的影响。

那么,针对Java而言,测量时间的精度是否需要满足微基准测试的需求呢?下面我们就一起来探讨下这个问题。

测量时间的精度问题

在现实世界中,我们会使用手表来计算时间间隔,如果手表上的时间最小单位是秒,那么你可以大致认为测量出的时间间隔误差小于秒。而在计算机系统中,当测量时间使用更小的单位之后,那测量时间间隔的误差是否仍然小于最小的时间单位呢?

这个答案其实是否定的。因为对于计算机系统来说,通常测量获取的时间不是准确的。这要怎么理解呢?接下来我给你举个具体的例子。

在Java语言中,测试时间的方法通常会使用System.currentTimeMillis(),这是一个获取系统当前时刻距离1970年1月1日的毫秒偏移量值,因为返回值是一个long类型的数字,所以可以帮助我们更方便地计算时间间隔。

不过,虽然这个接口获取的时间偏移是基于ms(毫秒)单位的,但受制于底层实现的差异,每次获取时间的准确度并不确定,甚至有些场景下获取的时间偏差可能会超过10ms。

因此为了解决这个问题,Java语言中后来引入了一个System.nanoTime()方法,这是一个获取系统当前时刻与之前某一个时刻的偏移值,可以支持我们记录更精准的时间间隔。它可以获取更小的时间单位ns(纳秒),但同样的,这并不代表误差会小于ns。

补充:目前测量时间间隔的最精确方法是,通过指令获取代码运行期间,CPU中的时钟寄存器差值,再根据CPU的时钟周期频率来计算出时间间隔。这种方式在做C/C++实时系统的运行时间分析时,使用得比较多,但它也受制于CPU的指令级发射机制和编译乱序优化的影响,测试出来的时间间隔也会存在一定的误差。

实际上,针对较小的代码段运行时间测不准的问题,微基准测试的一种可行方式,就是迭代、累积运行多次后获取的测试时间间隔,然后再平均到每一次的运行时间上,这样就可以减少获取的时间间隔误差对测量结果的影响。

但这里仍然存在一个问题,就是对代码段迭代很多次,又容易触发JIT中的栈上替换(OSR)优化,可真实的业务代码在执行过程中并没有出现JIT,也没有触发OSR。所以这样就会导致基准测试值不能反映真实的业务性能水平问题,你也需要注意规避。

总而言之,针对Java语言,在进行微基准测试时,我们不能太依赖底层接口获取的测量时间精度,因为Java的底层无法保证测量精度是非常准确的。

不过,除了测量时间精度会对测量结果产生影响以外,由于软件代码本身的运行时间也是不确定的,所以针对这种情况,我们在做微基准测试的时候,还需要在基于波动的测量结果的前提下,来尽量准确地获取平均测量结果,以此支撑性能分析。

那么接下来,我们就具体来看看测量结果数据的波动现象。

测量结果数据波动现象

这里我们要先明确一点,就是我们不可能完全剥离掉测试时软硬件运行环境的影响,也不可能完全避免测试结果的计算误差,我们必须客观接受获取的测量结果存在波动的这种现象

那么,由于测试性能获取的结果会是一直波动的,所以根据单次结果去判断性能是否退化,其实也会比较困难。

所以在这个基础上,我们可以基于统计学方法,先测量计算出性能测试结果的波动范围区间,也就是置信区间,然后根据测试结果是否落在置信区间,来判断性能基线是否发生变化。

可是这样问题就来了:如何计算出测试结果的波动范围区间呢?我们先来看一张示意图:

如上图所示,你可以获取大量的测试值并计算出平均值,假设你觉得95%左右的测量结果为可信数据,那么你就可以选择平均值周围95%的测量结果的最大值与最小值范围,作为置信区间。

实际上,判断微基准测试的性能是否发生变化,还有一个更有效的手段,就是使用图表协助分析测试结果的变化趋势。

如上图所示,绿色菱形为每一轮基准测量结果,其中你会比较容易看到一个性能拐点。这是因为图表携带了比置信区间更多的有效信息,更容易进行准确判断。另外,对于性能基线微基准测试而言,它的目标也并不在于追求单次测试结果的准确性,而是要测试出性能变化走势的准确性。

OK,在基于以上微基准测试所面临的问题分析之后,现在我们就知道该如何规避这些因素,以避免影响到微基准测试结果。而接下来我们要讨论的,就是如何更好地实施执行微基准测试的具体方法。

实施微基准测试的步骤方法

一般来说,在实施微基准测试的时候,你需要根据具体的被测试代码片段,手动编码很多代码逻辑来获取测量值。但这里存在一个问题,就是你会很容易忽略前面提到的一些实现因素,从而导致测量结果不能准确反映性能。

那么,有没有什么更快速、有效的测试步骤流程呢?这里我根据以往的实践经验,给你总结了一个微基准测试的基本步骤流程,可以帮助你更好地实现微基准测试。

这个步骤方法主要分为四步:

不过,如果是自己手动来规避微基准测试的各种问题的话,实施起来会比较复杂。好在每种编程语言都有现成的微基准测试框架可供选择,比如对于Java语言来说,JMH就是首选的微基准性能测试框架;而对C/C++语言而言,Google Benchmark则是首选的微基准测试框架。

所以接下来,我就主要来给你介绍下Java的JMH框架。

JMH测试框架是如何帮助完成微基准测试的?

JMH(Java Macrobenchmark Harness)是一个测试Java或JVM上其他语言的微基准测试工具,它把支撑微基准测试的标准过程机制与手段都内置到了框架中,从而可以支持我们通过注解的方式,来高效率开发微基准测试用例

我们来看一个例子。如以下代码段所示,我们可以使用@Benchmark来标记需要基准测试的方法,然后写一个main方法来启动基准测试:

@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 2, time = 1)
@BenchmarkMode({Mode.Throughput})
public class Sample {

    @Benchmark   //这里标注的方法就是一个被测函数方法
    public void helloworld() {
        System.out.println("hello world")
    }
    // 
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(Sample.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();  //启动基准测试
    }
}

另外,在JMH中,我们还可以使用@Warmup注解来配置预热时间。下面的代码示例中,就表示配置预热3轮,每轮1秒钟,这样就可以跳过预热阶段,来规避JIT编译优化对测试结果的影响。

@Warmup(iterations = 3, time = 1)

然后,我们还可以使用@Measurement注解来配置基准测试运行时间。下面代码中表示的是配置测试2轮,每轮1秒钟,在每轮执行期间还会不断地迭代执行。因此,我们会得到两轮执行之后的一个测试结果:

Benchmark            Mode  Cnt       Score       Error         Units
Sample.helloworld    thrpt  2   2703833258.555 ± 354675008.250  us/op

除此之外,JMH还支持以下几种测试模式:

这样,我们就可以通过如下的方式来选择配置前面提到的测试模式:

@BenchmarkMode({Mode.Throughput})

最后,JMH还支持多种格式的结果输出,比如TEST、CSV、SCSV、JSON、LaTeX等。如下所示,这是一个打印出JSON格式的命令:

java -jar benchmark.jar -rf json

而且JMH的测试结果在导出后,还可以使用JMH Visual进行显示,但这个工具只显示单个测试导出结果。所以在通常情况下,为了更好地监控被测方法的性能变化趋势,我们还需要持续地导出并保存JMH结果,这样才能通过其他可视化手段去分析其变化趋势。

当然了,今天这节课,我主要目的是带你理解做好微基准测试的方法与步骤,所以并不会给你详细介绍JMH的构建配置过程,这里我给你推荐一个基于Gradle构建的JMH的样例库,你可以直接下载下来,参考开发测试用例或配置构建工程。

小结

热力学之父开尔文男爵(Lord Kelvin)曾经说过一句对性能优化领域有哲学指导意义的话:If you cannot measure it, you cannot improve it. 这句话的大致意思是,你只能优化你能测量到的性能问题。不仅如此,你也只能看护你能测量到的软件性能。

而微基准测试,正是你支撑与看护高性能编码实现的重要手段。

今天这节课,我带你理解了微基准测试会碰到问题与挑战、高效开展微基准测试的方法步骤,以及借助微基准性能测试框架来更好地协助测试的方法。其中,你需要重点关注的是做好微基准测试的理论和方法,这样当具体的测量结果不准确时,你就可以做到有的放矢,找到应对方案。

另外,通过学习今天的课程,你还可以在深入理解基线性能面临的问题与挑战的基础上,来指导在核心高性能模块软件开发的过程中,准确高效地开发微基准测试,并能够及时发现测试中存在的问题。

思考题

在真实的软件产品中,你有没有发现过哪些被测方法代码,很难保持测试态与运行态的执行方式一致的呢?

欢迎在留言区分享你的看法。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

评论