第一个原则,即测试真实的应用程序,是指应该以实际产品的使用方式进行测试。大致而言,进行性能测试的代码可以分为3种:微基准测试(microbenchmark)、宏基准测试(macrobenchmark)和介基准测试(mesobenchmark)。每一种都各有优缺点,只有适合真实应用程序的代码才能提供最好的测试结果。

微基准测试是指通过测量一小部分代码的性能来确定多种实现中哪个最好。一些例子包括比较创建线程和使用线程池的开销,比较某个算术算法和其替代实现的执行时间,等等。

微基准测试似乎是测试性能的好办法,但是诸如即时编译和垃圾回收这些对开发人员很有吸引力的Java特性,给正确编写微基准测试增加了难度。

01. 必须读取测试的结果

微基准测试程序和常规程序有很多不同的地方。首先,Java代码在执行的前几次都是解释执行的,执行的时间越长,运行速度越快。出于这个原因,所有的基准测试(不仅是微基准测试)通常都包括一个预热期,在这期间,JVM可以将代码编译到最佳状态。

最佳状态包含了很多优化。举例来说,下面是一个看似简单的循环,用于计算第50个斐波那契数。

public void doTest() {
    // 主循环
    double l;
    for (int i = 0; i < nWarmups; i++) {
        l = fibImpl1(50);
    }
    long then = System.currentTimeMillis();
    for (int i = 0; i < nLoops; i++) {
        l = fibImpl1(50);
    }
    long now = System.currentTimeMillis();
    System.out.println("Elapsed time: " + (now - then));
}

这段代码旨在得出fibImpl1()方法的执行时间。它先预热了编译器,然后执行了编译后的方法。但这个执行时间可能是0(或者更有可能的是,没有循环体的for循环的执行时间是0)。因为l的值未被读取,所以编译器完全可以跳过它不进行计算。这取决于fibImpl1()方法的内部结构,如果其内部仅仅是算术运算,那全都可以跳过。还有一种可能:方法只有一部分被执行,甚至产生了错误的l值。因为l值从未被读取,所以没人会注意到错误。(第4章将详细讲解如何消除循环。)

解决该问题的方法是:确保每个结果都被读取,而不只是写入。在实践中,将l的定义从局部变量改为实例变量(用volatile关键字进行声明)即可测量这个方法的性能。(第9章将解释l实例变量必须用volatile声明的原因。)

多线程微基准测试

在上述例子中,即使微基准测试是单线程的,也需要使用volatile变量。

在编写多线程微基准测试时要相当警惕。当多个线程同时执行一小段代码时,发生同步瓶颈(和其他线程问题)的可能性相当大。多线程微基准测试的结果往往会导致需要花费大量时间在优化同步瓶颈上,而不是在解决更紧迫的性能需求上,但同步瓶颈很少出现在实际代码中。

思考这样一种情况:在微基准测试中,有两个线程调用一个对象的同步方法。因为基准测试的代码量少,所以大部分时间在执行同步方法。即使同步方法的执行时间只占整个微基准测试的50%,即使少到只有两个线程,同时执行同步方法的概率也会很大,所以基准测试会执行得很慢。而且随着线程的增加,资源竞争导致的性能问题会越来越糟糕。最终,测试变成了测量JVM处理多线程资源竞争的方式,这并不是微基准测试的初衷。


02. 必须测试一系列的输入值

即便读取了微基准测试结果,隐患依然存在。上述代码只有一个目的:计算第50个斐波那契数。足够智能的编译器可以分析出这一点,只执行一次循环体就能达成目的,或至少能减少迭代次数,毕竟这些操作是多余的。

此外,fibImpl1(1000)和fibImpl1(1)的性能很可能相差很大。如果测试的目标是比较不同实现的性能,那就必须测试一系列的输入值。

这些值可以是随机的,举例如下:

for (int i = 0; i < nLoops; i++) {
    l = fibImpl1(random.nextInteger());
}

这可能不是我们想要的结果,因为循环的执行时间包含了计算随机数的时间。现在测试检测的时间是计算nLoops次斐波那契数列的时间加上生成nLoops个随机数的时间。

所以,最好提前算好输入值。

int[] input = new int[nLoops];
for (int i = 0; i < nLoops; i++) {
    input[i] = random.nextInt();
}
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
    try {
        l = fibImpl1(input[i]);
    } catch (IllegalArgumentException iae) {
    }
}
long now = System.currentTimeMillis();


03. 必须测量正确的输入值

你也许已经注意到,现在测试不得不在调用fibImpl1()方法时捕获异常:输入值的范围可能包括负数(负数中没有斐波那契数),或者输入值大于1476(其结果超出了double的表示范围)。

当代码用于生产环境时,这些是可能出现的输入值吗?在这个例子里也许不是,但在你自己的基准测试中,情况可能就不一样了。考虑这样的情况:假设你测试的代码有两种实现,第一种实现能够快速计算出斐波那契数,但不会检查输入值的范围;第二种实现在输入值超出范围时立刻抛出异常,然后执行一个缓慢的递归操作来计算斐波那契数,如下所示。

public double fibImplSlow(int n) {
    if (n < 0) throw new IllegalArgumentException("Must be > 0");
    if (n > 1476) throw new ArithmeticException("Must be < 1476");
    return recursiveFib(n);
}

在输入值的范围很大的情况下,和原来的实现相比,上述这种新的实现更快,这仅仅是因为方法的开头进行了范围检查。

在现实中,如果用户总是给方法传入小于100的值,那么比较上述两种实现就会得出错误的结论。一般情况下,fibImpl1()方法会更快。就像第1章解释的那样,我们应该针对常见的情况进行优化。(这显然是一个虚构的例子,在原来的实现上添加边界检查就能优化,生产环境中一般不太可能有这种情况。)


04. 代码在生产环境中可能有不同表现

到目前为止,我们关注的问题可以通过认真编写微基准测试来解决。代码被纳入到一个更大的系统中后,其他因素也会影响代码运行的最终结果。编译器利用代码的性能分析反馈(profile feedback)决定编译方法时的最佳优化方式。性能分析反馈基于方法被频繁调用、调用栈的深度、参数的实际类型(包括子类)等因素,而这些都是由代码的实际运行环境决定的。

因此,对于同样的代码,编译器在微基准测试中的优化方式常常与大型系统中的优化方式不同。

在垃圾回收方面,微基准测试也可能表现出截然不同的行为。考虑微基准测试的两种实现:第一种实现可以快速得出结果,但是会产生大量短期对象(short-lived object);第二种实现慢一点,产生的短期对象也少一点。

当运行一个小程序来测试时,第一种实现可能更快。尽管它会触发更多的垃圾回收操作,但新生代垃圾回收器在回收时会快速丢弃短期对象,更短的整体时间有利于这种实现。当在服务器上使用多线程并发执行这段代码时,GC性能分析看起来会不同:多个线程会很快填满新生代,许多在微基准测试中被快速丢弃的短期对象在多线程服务器环境下使用时,会被提升到老年代。这会导致频繁的(而且代价巨大的)Full GC。在这种情况下,Full GC花费的大量时间,让第一种实现不如第二种快,“更慢”的实现产生更少的垃圾。

最后,要确定微基准测试的实际含义。像我们现在讨论的基准测试,很多循环的整体时间差异是以秒为单位的,而单次迭代的时间差异是以纳秒为单位的。纳秒慢慢累加,“积少成多”就会频繁造成性能问题。但是要明确,特别是在回归测试中,纳秒级别的程序跟踪是否有意义。若一个集合被访问上百万次,那么每次访问节省的几纳秒就很重要(见第12章的例子)。对于不那么频繁的操作(比如每次REST调用时运行一次),修复微基准测试发现的纳秒级别的回归问题就会浪费时间,将这些时间用于优化其他操作必然更有益。

尽管有很多缺陷,但微基准测试还是很受欢迎,以至于OpenJDK有一个专门用来开发微基准测试的核心框架:Java微基准测试工具(jmh,Java Microbenchmark Harness)。jmh被JDK开发人员用来构建针对JDK本身的回归测试,同时为开发人员提供了通用的基准测试框架。2.5.1节将讨论jmh的更多细节。

什么是预热期?

Java的一个性能特点是,代码执行得越多,性能就越好(这个话题会在第4章讲到)。因此,微基准测试必须包含一个预热期,让编译器有机会生成最佳代码。

本章稍后会深入讨论预热期的优点。微基准测试必须有预热期,否则它测量的就是编译性能,而不是代码性能。

要测量一个应用程序的性能,最好的测量对象就是应用程序本身,外加它使用的任何外部资源。这就是宏基准测试的原则。如果应用程序通过调用目录服务(比如通过LDAP1)来检查用户凭证,那就应该在这种模式下测试应用程序。去掉LDAP调用对于模块级别的测试有效果,但是应用程序必须在配置完整的情况下进行测试。

1lightweight directory access protocol,轻量目录访问协议。

一个原因是,随着应用程序规模的增长,上述准则变得越来越重要,也越来越难实现。复杂系统并不是各个组成部分加在一起那么简单,当这些部分组合在一起时,它们的行为会截然不同。例如,模拟数据库调用可能意味着你再也不必担心数据库性能了——嘿,你是写Java的,为什么非要处理本该由DBA操心的性能问题呢?数据库连接会消耗大量的堆空间用于缓存;网络会因传送了太多数据而饱和;代码调用简单的方法(简单是相对于JDBC驱动中复杂的代码而言的)时优化方式不同;CPU传输和缓存短代码路径比长代码路径更高效……

需要测试完整应用程序的另一个原因是资源分配。在理想的情况下,有足够的时间优化应用程序中的每一行代码。在现实世界里,交付日期迫在眉睫,仅仅优化复杂运行环境的一部分可能不会有立竿见影的效果。

思考图2-1所示的数据流。用户登录后发起数据请求,然后系统进行数据业务处理,数据库基于此加载数据,之后经过专有计算,修改后的数据存入数据库,最后将结果返回给用户。每个方框中的数字表示该模块在单独测试时每秒能处理的请求数(RPS)。

图2-1:典型的数据流

从业务角度来看,专有计算是最重要的。这是程序存在的理由,也是我们获取报酬的原因。然而在这个例子中,使专有计算模块的速度翻倍完全没有任何好处。任何应用程序(包括独立运行的JVM)都可以像这样,建模为一系列的步骤,方框(模块、子系统等)的效率决定了方框的数据输出速度。前一个方框的数据输出速度决定了下一个子系统的数据输入速度。

假设数据业务处理部分进行了算法改进,可以处理200 RPS,系统的负载也会相应地增加。LDAP系统可以处理增加的负载:到目前为止还好,数据会以200 RPS的速率输入数据业务处理模块,也会以200 RPS的速率输出。

但是,数据加载模块的处理速率仍然只有100 RPS。即便有200 RPS输入数据库,也只有100 RPS会输出到其他模块。虽然数据业务处理的效率翻倍了,但是系统的总吞吐量仍只有100 RPS。在花时间改进运行环境的其他方面之前,进一步改进数据业务处理的算法只会是徒劳。

在这个例子中,优化数据业务处理所花费的时间并没有被完全浪费。一旦优化了其他的系统瓶颈,性能收益就会显现。准确地说,这是优先级的问题。在没测试整个应用程序的时候,不可能知道优化哪部分的性能会有效果。

多个JVM上的系统整体测试

在测试完整的应用程序时,一个特别重要的情况是,多个应用程序同时运行在同一硬件上。JVM的很多方面进行了调优,并假设所有硬件资源都是可用的。如果对这些JVM进行单独测试,那么它们会表现得很好。如果在其他应用程序(包括但不限于其他JVM)正在运行时测试JVM,那么其表现则会大不相同。

后文将给出这样的例子。这里简单提一句:当执行一个GC周期时,单个JVM(默认配置下)会使所有处理器的CPU使用率达到100%。如果以程序执行时的平均水平来测量CPU,那么CPU使用率可能会达到40%——实际上,CPU使用率有时是30%,有时是100%。这在JVM独立运行时还好,但是如果JVM和其他的应用程序一同运行,那么在GC时不可能100%利用机器的CPU。这时的性能会和单独运行时有明显差距。

这也是微基准测试和模块级别的基准测试不一定能让你了解应用程序性能全貌的另一个原因。

介基准测试介于微基准测试和宏基准测试之间。在与开发人员一起研究Java SE和大型Java程序的性能时,我发现两者都有一套被称为“微基准测试”的测试。对于Java SE工程师,“微基准测试”意味着比2.1.1节中的例子更小的测试,用来测量很小的部分。Java程序开发人员则往往会把这个词用在其他基准测试上,即测量某个性能方面的基准测试,但是仍然要执行大量代码。

应用程序微基准测试的一个例子是,测量服务器返回简单REST请求的响应有多快。与传统的微基准测试相比,测试这种请求的代码是相当多的:管理套接字的代码、读取请求的代码、编写响应的代码等。从传统的角度来看,这不算是微基准测试。

上述测试也不算是宏基准测试。它没有安全性(比如用户不用登录系统)、没有会话管理、没有大量使用其他应用程序特性。因为这种测试只是实际应用程序的子集,所以它属于中间地带——介基准测试。我用这个术语来指代做了一些实际工作但功能不全的基准测试。

介基准测试比微基准测试的缺陷更少,也比宏基准测试更容易操作。介基准测试不会有大量可以被编译器优化的无效代码(除非无效代码存在于应用程序中,这时候优化是好事)。介基准测试更容易使用多线程。它们比完整的应用程序更有可能遇到同步瓶颈,不过,当实际的应用程序运行在大型硬件系统上且有大量负载时,这些瓶颈是不可避免的。

尽管如此,介基准测试仍不完美。如果使用这种基准测试来比较两台应用程序服务器的性能,那么开发人员很容易误入歧途。考虑两台REST服务器假定的响应时间,如表2-1所示。

表2-1:两台REST服务器假定的响应时间

若仅用简单的REST调用来比较两台服务器的性能,那么开发人员可能意识不到,服务器2自动地给每个请求都进行了鉴权。因此,开发人员可能得出服务器1的性能更好的结论。然而,如果应用程序一直需要鉴权(这是典型的情况),那么开发人员得出的结论就是错误的,因为服务器1需要花更多的时间进行鉴权。

即便如此,介基准测试也提供了一个测试完整应用程序的选择。介基准测试的性能特点比微基准测试的更接近实际应用程序。本章稍后会用到一个应用程序,后续章节的很多例子会用到它。这个应用程序的服务器模式(REST服务器和Jakarta Enterprise Edition服务器)不使用类似认证一样的服务器工具。虽然该应用程序可以访问企业资源(例如数据库),但在多数例子中,它只是产生随机数据来替代数据库调用。在批处理模式下,比如在没有图形用户界面或者不与用户交互的情况下,它会模拟一些实际(但快速)的运算。

用介基准测试进行自动化测试也是很好的方法,特别是在模块级别的测试中。

快速小结

·没有一个合适的框架,很难写出好的微基准测试。

·要了解代码的实际运行情况,唯一的方法是测试整个应用程序。

·通过介基准测试,可以在模块级别或者操作级别进行合理的独立性能测试,但是这无法替代完整应用程序的测试。