第 2 章 性能测试方法

第 2 章 性能测试方法

本章讨论了性能测试的 4 项原则。这些原则是后续章节的基础,也涵盖了性能工程科学的各个方面。

后续章节中的许多示例均取材于一个普通应用,本章也对此做了概要介绍。

2.1 原则1:测试真实应用

第 1 条原则就是,应该在产品实际使用的环境中进行性能测试。性能测试大体上可以分为 3 种,每种都有其优点和不足,只有适用于实际应用的才能取得最好的效果。

2.1.1 微基准测试

第 1 种是微基准测试。微基准测试用来测量微小代码单元的性能,包括调用同步方法的用时与非同步方法的用时比较,创建线程的代价与使用线程池的代价,执行某种算法的耗时与其替代实现的耗时,等等。

微基准测试看起来很好,但要写对却很困难。考虑以下代码,被测的方法是计算出第 50 个斐波那契数,这段代码试图用微基准测试来测试不同实现的性能:

public void doTest() {
    // 主循环
    double l;
    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));
}
...
private double fibImpl1(int n) {
    if (n < 0) throw new IllegalArgumentException("Must be > 0");
    if (n == 0) return 0d;
    if (n == 1) return 1d;
    double d = fibImpl1(n - 2) + fibImpl(n  - 1);
    if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
    return d;
}

代码看起来简单,却存在很多问题。

1. 必须使用被测的结果

这段代码的最大问题是,实际上它永远都不会改变程序的任何状态。因为斐波那契的计算结果从来没有被使用,所以编译器可以很放心地去除计算结果。智能的编译器(包括当前的 Java 7 和 Java 8)最终执行的是以下代码:

long then = System.currentTimeMillis();
long now = System.currentTimeMillis();
System.out.println("Elapsed time: " + (now - then));

结果是,无论计算斐波那契的方法如何实现,循环执行了多少次,实际的流逝时间其实只有几毫秒。循环如何被消除的细节请参见第 4 章。

有个方法可以解决这个问题,即确保读取被测结果,而不只是简单地写。实际上,将局部变量 l 的定义改为实例变量(并用关键字 volatile 声明)就能测试这个方法的性能了。(实例变量 l 必需声明为 volatile 的原因请参见第 9 章。)

多线程微基准测试

即便本示例是单线程微基准测试,也必需使用 volatile 变量。

编写多线程微基准测试时务必深思熟虑。当若干个线程同时执行小段代码时,极有可能会产生同步瓶颈(以及其他线程问题)。所以,如果我们过多依赖多线程基准测试的结果,就常常会将大量时间花费在优化那些真实场景中很少出现的同步瓶颈上,而不是性能需求更迫切的地方。

考虑这样的微基准测试,即有两个线程同时调用同步方法。由于基准测试的代码量相对于被测方法来说比较少,所以多数时间都是在执行同步方法。假设执行同步方法的时间只占整个微基准测试的 50%,即便少到只有两个线程,同时执行同步代码的概率仍然很高。因此基准测试运行得很慢,并且随着线程数的增加,竞争所导致的性能问题将愈演愈烈。最终结果就是,测试衡量的是 JVM 如何处理竞争,而不是微基准测试的本来目的。

2. 不要包括无关的操作

即便使用了被测结果,依然还有隐患。上述代码只有一个操作:计算第 50 个斐波那契数。可想而知,其中有些迭代操作是多余的。如果编译器足够智能的话,就能发现这个问题,从而只执行一遍循环——至少可以少几次迭代,因为那些迭代是多余的。

另外,fibImpl(1000) 的性能可能与 fibImpl(1) 相差很大。如果目的是为了比较不同实现的性能,测试的输入就应该考虑用一系列数据。

也就是说,解决这个问题,需要给 fibImpl1() 传入不同的参数。可以使用随机值,但仍然必须小心。

下面是种简单方法,即在循环中使用随机数生成器:

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

可以看到,循环中包括了计算随机数,所以测试的总时间是计算斐波那契数列的时间,加上生成一组随机数的时间。这可不是我们的目的。

微基准测试中的输入值必须事先计算好,比如:

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();

3. 必须输入合理的参数

此处还有第 3 个隐患,就是测试的输入值范围:任意选择的随机输入值对于这段被测代码的用法来说并不具有代表性。在这个测试例子中,有一半的方法调用会立即抛出异常(即所有的负数)。输入参数大于 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 verySlowImpl(n);
}

虽然很难想象会有比原先用递归更慢的实现,但我们不妨假定有这么个实现并用在了这段代码里。通过大量输入值比较这两种实现,我们会发现,新的实现竟然比原先的实现快得多——仅仅是因为在方法开始时进行了范围检查。

如果在真实场景中,用户只会传入小于 100 的值,那这个比较就是不正确的。通常情况下 fibImpl() 会更快,正如第 1 章所说,我们应该为常见的场景进行优化。(显然这是个精心构造的例子。不管怎样,仅仅在原先的实现上添加了边界测试就使得性能变好,通常这是不可能的。)

热身期

Java 的一个特点就是代码执行的越多性能越好,第 4 章将会覆盖这个主题。基于这点,微基准测试应该包括热身期,使得编译器能生成优化的代码。

本章后续将深入讨论热身期的优缺点。微基准测试需要热身期,否则测量的是编译而不是被测代码的性能了。

综合所有因素,正确的微基准测试代码看起来应该是这样:

package net.sdo;

import java.util.Random;

public class FibonacciTest {
    private volatile double l;
    private int nLoops;
    private int[] input;

    public static void main(String[] args) {
        FibonacciTest ft = new FibonacciTest(Integer.parseInt(args[0]));
        ft.doTest(true);
        ft.doTest(false);
    }

    private FibonacciTest(int n) {
        nLoops = n;
        input = new int[nLoops];
        Random r = new Random();
        for (int i = 0; i < nLoops; i++) {
            input[i] = r.nextInt(100);
        }
    }

    private void doTest(boolean isWarmup) {
        long then = System.currentTimeMillis();
        for (int i = 0; i < nLoops; i++) {
            l = fibImpl1(input[i]);
        }
        if (!isWarmup) {
            long now = System.currentTimeMillis();
            System.out.println("Elapsed time:" + (now - then));
        }
    }

    private double fibImpl1(int n) {
        if (n < 0) throw new IllegalArgumentException("Must be > 0");
        if (n == 0) return 0d;
        if (n == 1) return 1d;
        double d = fibImpl1(n - 2) + fibImpl(n  - 1);
        if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
        return d;
    }
}

甚至这个微基准测试的测量结果中也仍然有一些与计算斐波那契数没有太大关系:调用 fibImpl1() 的循环和方法开销,将每个结果都写到 volatile 变量中也会有额外开销。

此外还需要留意编译效应。编译器编译方法时,会依据代码的性能分析反馈来决定所使用的最佳优化策略。性能分析反馈基于以下因素:频繁调用的方法、调用时的栈深度、方法参数的实际类型(包括子类)等,它还依赖于代码实际运行的环境。编译器对于相同代码的优化在微基准测试中和实际应用中经常有所不同。如果用相同的测试衡量斐波那契方法的其他实现,就能看到各种编译效应,特别是当这个实现与当前的实现处在不同的类中时。

最终,还要探讨微基准测试实际意味着什么。比如这里讨论的基准测试,它有大量的循环,整体时间以秒计,但每轮循环迭代通常是纳秒级。没错,纳秒累计起来,“积少成多”就会成为频繁出现的性能问题。特别是在做回归测试的时候,追踪级别设为纳秒很有意义。如果集合操作每次都节约几纳秒,日积月累下来意义就很重大了(示例参见第 12 章)。对于那些不频繁的操作来说,例如那种同时只需处理一个请求的 servlet,修复微基准测试所发现的纳秒级性能衰减就是浪费时间,这些时间用在优化其他操作上可能会更有价值。

微基准测试难于编写,真正管用的又很有限。所以,应该了解这些相关的隐患后再做出决定,是微基准测试合情合理值得做,还是关注宏观的测试更好。

2.1.2 宏基准测试

衡量应用性能最好的事物就是应用自身,以及它所用到的外部资源。如果正常情况下应用需要调用 LDAP 来检验用户凭证,那应用就应该在这种模式下测试。虽然删空 LDAP 调用在模块测试中有一定意义,但应用本身必须在完整真实配置的环境中测试。

随着应用规模的增长,上述准则愈加重要也更难达到。复杂系统并不是各个部分的简单加和,装配之后,各部分的行为会有很大不同。所以,比如你伪装数据库调用,那就意味着你并不担心数据库的性能——对了,你是 Java 人,为什么要处理其他人的性能问题呢?数据库连接会因为缓存而消耗大量堆内存,网络也会因为发送大量数据而饱和,代码调用简单方法(与调用 JDBC 驱动程序的代码相比)时的不同优化,短代码路径因为 CPU 管线和缓存而比长代码路径更为有效,等等。

需要测试整体应用的另外一个原因是资源的分配。在完美世界中,我们有足够的时间去优化应用的每一行代码。但现实是,截止日期迫在眉睫,只对复杂系统进行部分优化也无法立即奏效。

考虑图 2-1 中的数据流。用户发起数据请求,然后系统进行业务处理,并基于结果从数据库装载数据,再进行处理,最后将更改后的数据存入数据库,并将结果发还给用户。方框中的数字(例如 200 RPS)是每秒的请求数,是模块单独测试时所能承载的处理量。

图像说明文字

图 2-1:典型的程序流程

从商业角度看,业务处理是最重要的,是程序存在的理由,也是有人愿意付钱给我们的原因。不过在这个例子中,即便业务处理速度提高 100% 也完全没什么好处。任何应用(包含独立运行的 JVM)都可以像这样划分成一系列步骤,方框中的模块、子系统等产生数据的速度取决于它们的效率。(在这个模型中,每个方框耗费的时间包括子系统代码的执行时间,也包括网络传输的时间、磁盘传输的时间,等等。如果是模块化的模型,时间应该只包括该模块内代码的执行时间。)数据进入子系统的速率取决于前一个模块或系统的输出速率 1

1原文中的“box”指图中的方框,其含义是模块或子系统,为便于理解,此处采取意译。——译者注

假设业务处理的算法有所改进,处理量达到了 200 RPS,系统能承受的负载也相应增加。LDAP 系统可以处理这些增加的负载:目前为止一切都好,数据将以 200 RPS 的速率注入业务处理模块,而它也将以 200 RPS 的速率输出。

但数据库只能以 100 RPS 的速率装载数据。虽然向数据库发送请求的速率为 200 RPS,输出到其他模块的速率却只有 100 RPS。即便业务逻辑处理的效率加倍,系统整体的吞吐量仍然只能达到 100 RPS。所以,除非花时间改善环境其他方面的效率,否则业务逻辑做再多改进也是无效的。

多 JVM 时的全系统测试

全应用测试有个很重要的场景,就是同一台机器上同时运行多个应用。许多 JVM 的默认调优都是默认假定整个机器的资源都归 JVM 使用。如果单独测试,优化效果很好。如果在其他应用(包括但不限于 Java 程序 2)运行的时候进行测试,性能会有很大的不同。

这方面的示例请参见后续章节,这里只快速过一遍:单个 JVM(默认配置)执行 GC 周期时,该机器上所有处理器的 CPU 使用率都会变成 100%。如果测量程序执行时的平均 CPU 使用率,大概会有 40%——实际意思是,某些时候 30% 的 CPU 被占用,其他时候为 100%。当隔离 JVM 时,它可以运行得很好,但如果 JVM 与其他应用并发运行,它就不可能在 GC 时获得 100% 的 CPU。此时测出来的性能会与它单独运行时不同。

这是微基准测试和模块测试不可能让你全面了解应用性能的另一个原因。

2原文“other JVM”直译容易误解为“其他 JVM 实现”,此处改用“Java 程序”。——译者注

本例子中,优化业务处理并不完全是浪费时间:在系统其他性能瓶颈上曾经付出的努力,终究会有好处。进一步说,这中间有个优先顺序:不进行整体应用的测试,就不可能知道哪部分的优化会产生回报。

2.1.3 介基准测试

我的调优工作包括 Java SE 和 EE,每种都会有一组类似微基准测试的测试。对于 Java SE 工程师来说,这个术语意思是样本甚至比 2.1.1 节的还要小:测量很小的东西。Java EE 工程师则将这个术语用于其他地方:测量某方面性能的基准测试,但仍然要执行大量代码。

Java EE 微基准测试的例子,测量从应用服务器返回的简单 JSP 响应。仔细比较处理请求的代码和传统微基准测试的代码:有许多 socket 管理代码,读取请求、查找(可能需要编译)JSP、写入响应等代码。从传统角度来看,这不能算微基准测试。

这种测试也不是宏基准测试:没有安全(比如用户不用登录),没有会话管理,也没有大量使用其他的 Java EE 特性。因为它只是实际应用的子集,介于两者之间——它是介基准测试。介基准测试并不局限于 Java EE:它是一个术语,我用来表示做一些实际工作,但不是完整应用的基准测试。

介基准测试与微基准测试相比隐患更少,又比宏基准测试容易。介基准测试不包含会被编译器优化的大量死代码(除非应用中真的存在死代码,否则这种情况下优化是件好事)。介基准测试更容易线程化:它们比全应用时运行的代码更容易遇到同步瓶颈,不过这些是实际应用在更大规模硬件系统和更大负载时,最终都会遇到的瓶颈。

介基准测试仍然不完美。开发人员用这样的基准测试比较两个应用服务器性能时,容易误入歧途。考虑表 2-1 中两个应用服务器假想的响应时间。

表2-1:两个应用服务器的假想响应时间

测试

应用服务器1(毫秒)

应用服务器2(毫秒)

简单 JSP

19

50

有会话的 JSP

75

50

仅使用简单 JSP 的开发人员比较两个服务器性能时,可能不会意识到,服务器 2 会自动为每个请求创建会话。他可能会得出服务器 1 性能更快的结论,结果他就做出了错误选择,因为实际上服务器 1 创建会话要花费更长时间。(后续调用的性能是否有差别是另一个需要考虑的因素,但从这些数据无法预计一旦会话创建后,哪个服务器的性能会更好。)

即便如此,介基准测试也为测试全应用提供了一个合理选择。它们的性能比微基准测试更接近实际应用。这里有个连续的过程。本章稍后的章节将概要介绍一个常见应用,后续章节中的许多示例程序都出自该应用。这个应用有 EE 模式,但这种模式不使用会话复制(高可用),或者基于 EE 平台的安全。虽然它能访问企业资源(比如数据库),但多数示例中它只使用随机数据。在 SE 模式下,它模仿一些实际(但很快)计算:比如没有 GUI 或者用户交互发生。

介基准测试也有益于自动化测试,特别是模块级别的测试。

 快速小结

1. 好的微基准测试既难写,价值又有限。如果你必须使用它,那可以用它来快速了解性能,但不要依赖它们。

2. 测试完整应用是了解它实际运行的唯一途径。

3. 在模块或者操作级别隔离性能——介基准测试——相对于全应用测试来说,是一种合理的折中途径,而不是替代方法。

2.1.4 代码示例

贯穿全书的许多例子都来自于一个示例应用,计算某只股票在一段时间内的“历史”最高价和最低价,以及标准差。因为所有数据皆为虚构,价格和股票代码也是随机生成,所以这里的历史标上了引号。

本书的所有示例代码都可在我的 GitHub 上 3 找到,本节只是覆盖了代码的基本要点。基本接口 StockPrice 表示某股票某天的价格区间:

3https://github.com/ScottOaks/JavaPerformanceTuning。——译者注

public interface StockPrice {
        String getSymbol();
        Date getDate();
        BigDecimal getClosingPrice();
        BigDecimal getHigh();
        BigDecimal getLow();
        BigDecimal getOpeningPrice();
        boolean isYearHigh();
        boolean isYearLow();
        Collection<? extends StockOptionPrice> getOptions();
}

通常,那些示例应用都是对一组股价进行处理,这些股价表示一段时间内的股票历史(比如 1 年或 25 年,取决于具体的示例):

public interface StockPriceHistory {
        StockPrice getPrice(Date d);
        Collection<StockPrice> getPrices(Date startDate, Date endDate);
        Map<Date, StockPrice> getAllEntries();
        Map<BigDecimal,ArrayList<Date>> getHistogram();
        BigDecimal getAveragePrice();
        Date getFirstDate();
        BigDecimal getHighPrice();
        Date getLastDate();
        BigDecimal getLowPrice();
        BigDecimal getStdDev();
        String getSymbol();
}

这个接口的基本实现是从数据库载入股价:

public class StockPriceHistoryImpl implements StockPriceHistory {
    ...
    public StockPriceHistoryImpl(String s, Date startDate,
        Date endDate, EntityManager em) {
        Date curDate = new Date(startDate.getTime());
        symbol = s;
        while (!curDate.after(endDate)) {
            StockPriceImpl sp = em.find(StockPriceImpl.class,
                         new StockPricePK(s, (Date) curDate.clone()));
            if (sp != null) {
                Date d = (Date) curDate.clone();
                if (firstDate == null) {
                    firstDate = d;
                }
                prices.put(d, sp);
                lastDate = d;
            }
            curDate.setTime(curDate.getTime() + msPerDay);
        }
    }
    ...
}

这个示例的架构是从数据库载入数据,第 11 章将使用这个功能。不过为了便于运行示例,多数时候将用伪装过的实体管理器(mock entity manager)随机生成一系列数据。大体上,多数示例是模块级别的介基准测试,适合随手演示性能问题——不过全应用运行时,我们会了解实际的应用性能(参见第 11 章)。

附带说明一下,许多示例依赖随机数生成器的性能。与微基准测试示例不同,这里是有意为之,可以展示一些 Java 的性能问题。(就此而言,示例是为了测量一些任意状况下的性能,随机数生成器的性能正好适合此目的。这点与微基准测试大有不同,微基准测试中包括随机数生成时间就会影响整体计算。)

这些示例重度依赖 BigDecimal 的性能,它被用来存储所有的数据。这是保存货币数据时的标准选择,如果货币用原生的 double 对象,半分钱的舍入和更小的数量就会很成问题。从编写示例的角度来看,这样做也有价值,因为可以在计算股价标准差时产生一些“业务逻辑”或者延长计算。计算标准差需要知晓 BigDecimal 数的平方根。标准 Java API 不支持这个函数,示例将采用以下方法:

public static BigDecimal sqrtB(BigDecimal bd) {
    BigDecimal initial = bd;
    BigDecimal diff;
    do {
        BigDecimal sDivX = bd.divide(initial, 8, RoundingMode.FLOOR);
        BigDecimal sum = sDivX.add(initial);
        BigDecimal div = sum.divide(TWO, 8, RoundingMode.FLOOR);
        diff = div.subtract(initial).abs();
        diff.setScale(8, RoundingMode.FLOOR);
        initial = div;
    } while (diff.compareTo(error) > 0);
    return initial;
}

这是巴比伦平方根计算法的实现。它不是最有效的实现,特别是初始值可以估算得更好,可以少几轮迭代。这是经过深思熟虑的,因为计算需要花费一些时间(模拟业务逻辑),不过它展示了第 1 章中的基本观点:使 Java 代码更快的常用方法是更好的算法,这不依赖 Java 调优或者 Java 编码实践。

StockPriceHistory 接口实现中的标准差、平均值和直方图,都是由具体数据推演出来的。在不同的实现中,会立即计算(从实体管理器加载数据的时候)或者推迟计算(调用该方法的时候)。StockPrice 所引用的 StockOptionPrice 与此类似,它是股票在特定天的期权价之一,它的值也可以立即或者推迟从实体管理器中获取。对于这两种场景,通过接口定义,不同的实现可以在不同情况下进行比较。

这些接口也与 Java EE 应用自然吻合:用户先访问 JSP 页面,然后选择感兴趣的股票代码和时间范围。在标准示例中,请求将发送到标准 servlet,它会解析输入参数,通过内嵌的 Java Persistence API(JPA)调用无状态的 Enterprise JavaBean(EJB),以获取数据,然后将响应转发到 JavaServer Pages(JSP)页面,它再将数据格式化成 HTML 的形式:

protected void processRequest(HttpServletRequest request,
        HttpServletResponse response)
        throws ServletException, IOException {
    try {
        String symbol = request.getParameter("symbol");
        if (symbol == null) {
        symbol = StockPriceUtils.getRandomSymbol();
        }
            ... similar processing for date and other params...
        StockPriceHistory sph;
        DateFormat df = localDf.get();
        sph = stockSessionBean.getHistory(symbol, df.parse(startDate),
                        df.parse(endDate), doMock, impl);
    String saveSession = request.getParameter("save");
    if (saveSession != null) {
        .... Store the data in the user`s session ....
        .... Optionally store the data in a global cache for
        .... use by other requests
    }
    if (request.getParameter("long") == null) {
        // 发回一个带有约4K大小的数据的页面
        request.getRequestDispatcher("history.jsp").
                forward(request, response);
    }
    else {
        // 发回一个带有约100K大小的数据的页面
        request.getRequestDispatcher("longhistory.jsp").
                forward(request, response);
        }
    }

这个类可以注入不同的 StockPriceHistory 实现(除了其他方法,初始化方法立即执行或者推迟)。它可以选择缓存后端数据库(或者是伪装的实体管理器)的数据。处理企业应用时,这些都是通常的做法(特别是,中间层应用服务器可以缓存数据,有时被认为是它很大的性能优势)。贯穿全书的例子也将解释这些权衡。

被测系统的硬件

虽然本书主要集中在软件上,但基准测试同样也会测量它们所运行的硬件。

本书中多数的例子都运行在我的台式机系统上,CPU 为 4 核(4 个逻辑 CPU)的 AMD Athlon X4 640 CPU,物理内存为 8 GB,操作系统为 Ubuntu Linux 12.04 LTS。

2.2 原则2:理解批处理流逝时间、吞吐量和响应时间

性能测试的第 2 条原则是多角度审视应用性能。应该测量哪个指标取决于对应用最重要的因素。

2.2.1 批处理流逝时间

测量应用性能的最简单方法是,看它完成任务花了多少时间,例如接收 10 000 只股票 25 年的历史价格并计算标准差,生成某公司 50 000 名雇员的薪酬福利报表,以及执行 1 000 000 次循环的时间等。

在非 Java 的世界,可以很直接地测试流逝时间:应用记下时间点从而测量执行时间。但在 Java 世界中,由于即时编译(JIT),这种方法就会有些问题了。第 4 章描述了这个过程,其中的重点是,虚拟机会花几分钟(或更长时间)全面优化代码并以最高性能执行。由于这个(以及其他)原因,研究 Java 的性能优化就要密切注意代码优化的热身期:大多数时候,应该在运行代码执行足够长时间,已经编译并优化之后再测量性能。

其他影响应用热身的因素

通常认为的应用热身,指的就是等待编译器优化运行代码,不过基于代码的运行时长还有其他一些影响性能的因素。

例如,JPA 通常都会缓存从数据库读取的数据(参见第 11 章),由于这些数据可以从缓存中获取而不需要长途跋涉到数据库,所以通常再次使用时,操作就会变得更快。与此类似,应用程序读文件时,操作系统就会将文件载入内存。随后再次读取相同文件就会变得更快,这是因为数据已经驻留在计算机主内存中,并不需要从磁盘实际读取。

一般来说,应用热身过程中有许多地方会缓存数据,虽然并不都那么明显。

另一方面,许多情况下应用从开始到结束的整体性能更为重要。报告生成器处理 10 000 个数据元素需要花费大量时间,但对最终用户而言,处理前 5000 个元素是否比后 5000 个慢 50% 并不重要。即便是像应用服务器这样的系统——其性能必定会随运行时间而改善——初始的性能依然很重要。某种配置下的应用服务器需要 45 分钟才能达到性能峰值。对于在这段时间访问应用的用户来说,热身期的性能就很重要了。

基于这些理由,本书的许多例子都是面向批处理的(即便这看起来有些不寻常)。

2.2.2 吞吐量测试

吞吐量测试是基于一段时间内所能完成的工作量。虽然最常见的吞吐量测试是服务器处理客户端产生的数据,但这并非绝对的:单个独立运行的应用也可以像测量流逝时间一样测量吞吐量。

在客户端 - 服务器的吞吐量测试中,并不考虑客户端的思考时间。客户端向服务器发送请求,当它收到响应时,立刻发送新的请求。持续这样的过程,等到测试结束时,客户端会报告它所完成的操作总量。客户端常常有多个线程在处理,所以吞吐量就是所有客户端所完成的操作总量。通常这个数字就是每秒完成的操作量,而不是测量期间的总操作量。这个指标常常被称作每秒事务数(TPS)、每秒请求数(RPS)或每秒操作次数(OPS)。

所有的客户端 - 服务器测试都存在风险,即客户端不能足够快地向服务器发送数据。这可能是由于客户端机器的 CPU 不足以支持所需数量的客户端线程,也可能是因为客户端需要花大量时间处理响应才能发送新的请求。在这些场景中,测试衡量的其实是客户端性能而不是服务器性能,这并不是我们的目的。

其中的风险依赖于每个线程所承载的工作量(客户端机器的线程数和配置)。由于客户端线程需要执行大量工作,零思考时间(面向吞吐量)测试更可能会遇到这种情形。因此,通常吞吐量测试比响应时间测试的线程数少,线程负载也小。

通常吞吐量测试也会报告请求的平均响应时间。这是重要的信息,但它的变化并不表示性能有问题,除非报告的吞吐量相同。能够承受 500 OPS、响应时间 0.5 秒的服务器,它的性能要好过响应时间 0.3 秒但只有 400 OPS 的服务器。

吞吐量测试总是在合适的热身期之后进行,特别是因为所测量的东西并不固定。

2.2.3 响应时间测试

最后一个常用的测试指标是响应时间:从客户端发送请求至收到响应之间的流逝时间。

响应时间测试和吞吐量测试(假设后者是基于客户端 - 服务器模式)之间的差别是,响应时间测试中的客户端线程会在操作之间休眠一段时间。这被称为思考时间。响应时间测试是尽量模拟用户行为:用户在浏览器输入 URL,用一些时间阅读返回的网页,然后点击页面上的链接,花一些时间阅读返回的网页,等等。

当测试中引入思考时间时,吞吐量就固定了:指定数量的客户端,在给定思考时间下总是得到相同的 TPS(少许差别,参见框注)。基于这点,测量请求的响应时间就变得重要了:服务器的效率取决于它响应固定负载有多快。

思考时间和吞吐量

有两种方法可以测试客户端包括思考时间时的吞吐量。最简单的方法就是客户端在请求之间休眠一段时间。

while (!done) {
    time = executeOperation();
    Thread.currentThread().sleep(30*1000);
}

这种情况下,吞吐量一定程度上依赖响应时间。如果响应时间是 1 秒,就意味着客户端每 31 秒发送一个请求,产生的吞吐量就是 0.032 OPS。如果响应时间是 2 秒,客户端就是每 32 秒发送一个请求,吞吐量就是 0.031 OPS。

另外一种方法是周期时间(Cycle Time)。周期时间设置请求之间的总时间为 30 秒,所以客户端休眠的时间依赖于响应时间:

while (!done) {
    time = executeOperation();
    Thread.currentThread().sleep(30*1000 - time);
}

无论响应时间是多少,这种方法都会产生固定的吞吐量,每个客户端 0.033 OPS(假设本例中的响应时间都少于 30 秒)。

测试工具中的思考时间时常有变,平均值为特定值,但会加入随机变化以更好地模拟用户行为。另外,线程调度从来不会严格实时,所以客户端请求之间的时间也会略有不同。

因此,即便工具提供周期时间而不是思考时间,测试所报告的吞吐量也相差无几。但是,如果吞吐量远超预期,说明测试中一定有什么出错了。

衡量响应时间有两种方法。响应时间可以报告为平均值:请求时间的总和除以请求数。响应时间也可以报告为百分位请求,例如第 90 百分位响应时间。如果 90% 的请求响应小于 1.5 秒,且 10% 的请求响应不小于 1.5 秒,则 1.5 秒就是第 90 百分位响应时间。

两种方法的一个区别在于,平均值会受离群值影响。这是因为计算平均值时包括了离群值。离群值越大,对平均响应时间的影响就会越大。

图 2-2 展示了 20 个请求,它们响应时间的范围比较典型。响应时间是从 1 到 5 秒。平均响应时间(平行且靠近 x 轴的粗线)为 2.35 秒,且 90% 的请求发生在 4 秒或 4 秒以内(平行且远离 x 轴的粗线)。

{%}

图 2-2:一组典型的响应时间

对于行为正常的测试来说,这是常见的场景。离群值会影响分析的准确性,就像图 2-3 显示的数据那样。

{%}

图 2-3:一组含有离群值的响应时间

这组数据中包括一个很大的离群值:有个请求花费了 100 秒。结果第 90 百分位响应时间和平均响应时间的粗线就调换了位置。平均响应时间蹿到了 5.95 秒,而第 90 百分位响应时间为 1.0 秒。对于这样的案例,应该考虑减少离群值带来的影响(从而降低平均响应时间)。

一般来说,像这样的离群值很少见,不过由于 GC(垃圾收集)引入的停顿,Java 应用更容易发生这种情况。(并不是因为 GC 引入了 100 秒的延迟,而是尤其是对于有较小的平均响应时间的测试来说,GC 停顿会引入较大的离群值。)性能测试中通常关注的是第 90 百分位响应时间(有时是第 95 百分位或第 99 百分位响应时间,这里说第 90 百分位并没有什么神奇之处)。如果你只能盯住一个数字,那最好选择基于百分位数的响应时间,因为它的减少会让大多数用户受益。不过,最好一并考虑平均响应时间和至少一种百分位响应时间,你就不会错过有很大离群值的场景了。

负载生成器

有许多开源和商业的负载生成器。本书以 Faban(http://faban.org/)为例,这是一个开源的、基于 Java 的负载生成器。Faban 带有一个简单程序(fhb),可用来测试简单 URL 的性能:

% fhb -W 1000 -r 300/300/60 -c 25 http://host:port/StockServlet?stock=SDO
ops/sec: 8.247
% errors: 0.0
avg. time: 0.022
max time: 0.045
90th %: 0.030
95th %: 0.035
99th %: 0.035

这个测试例子中有 25 个客户端(-c 25)向 StockServlet 发送请求(股票代码 SDO),每个请求的周期时间为 1 秒(-W 1000)。-r 300/300/60 表示,基准测试的热身期为 5 分钟(300 秒),接下来是 5 分钟测试周期和 1 分钟减速期。测试之后,fhb 报告该测试的 OPS 和各种响应时间(由于包括思考时间,响应时间就成为重要的度量,而 OPS 则在不断变化)。

只要替换有限的几个参数,fhb 就可以处理 POST 数据,用少量脚本就可以处理多个 URL。对于更为复杂的测试来说,Faban 提供了很有用的 Java 框架来定义基准测试负载生成器。4

4fhb 的命令行,请参见 http://faban.org/1.2/docs/man/fhb.html。——译者注

 

 快速小结

1. Java 性能测试中很少使用面向批处理的测试(或者任何没有热身期的测试),但这种测试可以产生很有价值的结果。

2. 其他可以测量吞吐量或响应时间的测试,则依赖负载是否以固定的速率加载(也就是说,基于模拟的客户端思考时间)。

2.3 原则3:用统计方法应对性能的变化

第 3 条原则讲的是性能测试的结果会随时间而变。即便程序每次处理的数据集都相同,产生的结果也仍然会有差别。因为有很多因素会影响程序的运行,如机器上的后台进程,网络时不时的拥堵等。好的基准测试不会每次都处理相同的数据集,而是会在测试中制造一些随机行为以模拟真实的世界。这就会带来一个问题:运行结果之间的差别,到底是因为性能有变化,还是因为测试的随机性。

可用以下方法来解决这个问题,即多次运行测试,然后对结果求平均。当被测代码发生变化时,就再多次运行测试,对结果求平均,然后比较两个平均值。这听起来似乎很容易。

不幸的是,事情并没有想象中那么简单。要想弄清楚测试间的差别是真实的性能变化还是随机变化并不容易——这就是性能调优的关键所在,不仅需要科学引领道路,还需要懂点艺术才能玩得转。

比较基准测试的结果时,我们不可能知道平均值的差异是真的性能有差还是随机涨落。最好的办法是先假设“平均值是一样的”,然后确定该命题为真时的几率。如果命题很大几率为假,我们就有信心认为平均值是真的有差别(虽然我们永远无法 100% 肯定)。

像这种因代码更改而进行的测试称为回归测试。在回归测试中,原先的代码称为基线(baseline),而新的代码称为试样(specimen)。考虑一个批处理程序的案例,基线和试样都执行 3 次,表 2-2 给出了所用的时间。

表2-2:假设两组测试的执行时间

迭代

基线(秒)

试样(秒)

第 1 次

1.0

0.5

第 2 次

0.8

1.25

第 3 次

1.2

0.5

平均值(秒)

1

0.75

试样的平均值表明代码性能改善了 25%。这个测试所反映出来的 25% 的改善,我们真的能相信多少?看上去很美好:试样中 3 个有 2 个的值小于基线平均值,看起来改进的步子很大——但是如果用本节介绍的方法分析这些结果就会得出结论,试样和基线性能相同的概率有 43%。观察到的这些数字说明,两组测试的基本性能在 43% 的时间内是相同的,因此性能不相同的时间只占 57%。顺便说一句,57% 的时间内性能不相同和性能改善 25% 也完全不是一回事,稍后讨论这个问题。

上述概率看起来和我们的预期有差别,其原因是测试的结果变化很大。一般来说,结果数据差别越大,就越难判断平均值之间的差异是真实的差别还是随机变动。

此处的数字 43%,是学生 t 检验(Student's t-test,以下称 t 检验)得出的结果,这是一种针对一组数据及其变化的统计分析。顺便说一句,“学生”是首次发表该检验的科学家 5 的笔名,而不是提醒你(至少我)那些年在学校睡过的统计学课。t 检验计算出的 p 值,是指原假设(null hypothesis)成立时 6 的概率。(有一些程序和类库可以计算 t 检验,本节的结果是用 Apache Commons Mathematics 类库中的 TTest 计算的。)

5即威廉·戈斯特。——译者注

6原文为“false”,有误。——译者注

回归测试中的原假设是指假设两组测试的性能一样。这个例子中的 p 值大约为 43%,意思是我们相信这两组测试平均值相同的概率为 43%。相反,我们相信平均值不同的概率为 57%。

57% 意味着什么,两组测试的平均值不相同?严格来讲,这并不意味着我们相信性能改善 25% 的概率有 57%——它只是意味着,我们相信结果不同的概率为 57%。性能可能改善了 25%,也可能 125%,甚至试样的实际性能也许比基线还糟糕。最大的可能则是测量出来的差别就是接近于真实的差异(特别是随着 p 值下降越是如此),只是我们永远无法肯定这点。

统计学及其语义

正确表述 t 检验结果的语句应该像这样:试样与基线有差别的可能性为 57%,差别预计最大有 25%。

不过通常会这么描述:结果改善 25% 的置信度(confidence level)为 57%。确切地说,这种说法与前面并不一致,也会让统计学家们抓狂,不过这种说法简短而易于为人接受,也不算太离谱。统计学分析经常会涉及不确定性,如果语义可以精确地陈述,自然能让人更好地理解这种不确定性。不过对于那些基础问题已经很清楚的领域,语义描述上有些悄然简化也是在所难免的。

t 检验通常与 α 值一起使用,α 值是一个点(有点随意),如果结果达到这个点那就是统计显著性(statistical significance)。通常 α 值设置为 0.1——意思是说,如果试样和基线只在 10%(0.1)的时间里相同(或反过来讲,90% 的时间里试样和基线有差异),那结果就被认为是统计显著。其他常用的 α 值还有 0.05(置信度为 95%)或 0.01(置信度为 99%)。如果测试的 p 值小于 1-α 值,则被认为是统计显著。

因此,查找代码性能变化的正确方法是先决定一个显著性水平——比如 0.1——然后用 t 检验判定在这个显著性水平上试样是否与基线有差别。请仔细搞明白,如果显著性测试失败,意味着什么。在这个例子中,p 值为 0.43,在置信度为 90% 的情况下我们不能说有显著性差异,而结果表示平均值不相同。事实上,测试没有显著性差异并不意味着结果无关紧要,它仅仅表示这个测试没法形成定论。

统计学中的显著性与重要性

显著性差异并不意味着统计结果对我们更重要。平均为 1 秒的变化很小的基线,和平均为 1.01 秒的变化很小的试样,其 p 值可能为 0.01:结果的差别有 99% 的置信度。

但结果的差别只有 1%。现在假定另外一个测试,试样和基线有 10% 的变动,但是 p 值为 0.2,即非统计显著。哪个测试的结果最为重要?这需要更多时间来审查。

审查后发现,虽然相差 10% 的测试的置信度低,但在用时上更加优化(如果可能的话,可以用更多数据来验证测试结果是否真的统计显著)。仅仅因为 1% 差异的可能性更大,并不意味着它更重要。

从统计学上说,测试不能得出定论通常是因为样本数据不足。迄今为止,示例所考虑的基线和试样各是 3 次迭代。再加 3 次迭代,结果会变成这样:基线的迭代结果分别是 1、1.2 和 0.8 秒,试样的迭代结果分别是 0.5、1.25 和 0.5 秒?随着数据的增加,p 值就从 0.43 跌落到了 0.19,这意味着结果有差异的概率从 57% 上升到了 81%。运行更多测试迭代,再加 3 个数据点后,概率则增加到了 91%——超过了常规的统计显著性水平。

运行更多测试迭代从而达到某个统计显著性水平的方法并不总是可行。严格来说,这么做也没有必要。实际上,用以判定统计显著性的 α 值可以任意选择,虽然通常选的都是普遍认可的值。置信度为 90% 时,p 值 0.11 不是统计显著,但置信度为 89% 时,它就是统计显著了。

这里得到结论:回归测试并不是非黑即白的科学。对于一组数据,不经过统计分析你就没法弄清楚数字的含义,也就没法进行比较和判断。此外,由于概率的不确定性,即便用统计分析也不能给出完全可靠的结果。性能调优工程师的工作就是:考虑一堆数据(或者他们的均值)、弄清各种概率、决定往哪使力。

 快速小结

1. 正确判定测试结果间的差异需要统计分析,通过统计分析才能确定这些差异是不是归因于随机因素。

2. 可以用严谨的 t 检验来比较测试结果,实现上述目的。

3. t 检验可以告知我们变动存在的概率,却无法告诉我们哪种变动该忽略,而哪种该追查。如何在两者之间找到平衡,是性能调优工程的艺术魅力所在。

2.4 原则4:尽早频繁测试

这是第 4 条也是最后的原则。性能极客们(包括我)喜欢将性能测试作为开发周期不可或缺的一部分。理想情况下,在代码提交到中心源代码仓库前,性能测试就应该作为过程的一部分运行,如果代码引入了性能衰减,提交就会被阻止。

本章中,建议之间有些内在的冲突,而建议和现实之间也有冲突。好的性能测试包含了许多代码——至少中等规模的介基准测试是这样。它需要在新老代码上重复运行多次,以便确认性能真的有差别而不是随机变动。在大型项目中,这可能需要花费好几天或者一周时间,这使得在提交代码到仓库之间运行性能测试变得不那么现实。

通常的软件开发周期也没使事情变得更容易。项目日程通常会固定特性的发布日期:所有的代码变动必须在发布周期的早些时候就提交到源代码仓库,而剩下的时间则贡献给了将新版本中的缺陷(包括性能问题)抖落干净。这导致了提早测试的两个问题。

(1) 为了赶上项目进度,开发人员会在时间压力之下提交代码,而一旦有时间修复性能问题时,又变得踯躇不前。早期提交代码所导致的 1% 的性能衰减,开发人员愿意承受压力,修复问题。而等到功能特性截止夜才提交的代码,如果性能衰减 20%,开发人员就只能以后再处理了。

(2) 代码发生变化,性能也会随之而变。这个道理与测试全应用(以及可能有的模块测试)相同:堆内存的使用情况会改变,代码编译也会改变,等等。

开发过程中无论有多少困难,频繁的性能测试仍然很重要,即便有时候不能立刻解决问题。比如代码性能衰减了 5%,随着开发的推进,开发人员或许可以采用以下措施:如果他的代码依赖有待集成的功能特性,那就等该功能可用时,再稍微调整代码,性能衰减的问题或许就解决了。这是合理的情况,即便这意味着性能测试不得不几个星期都伴随着 5% 的性能衰减(这是不幸的事,却又无法避免,还可能掩盖了其他问题)。

另一方面,如果性能衰减只有等架构更改才能修复的话,那就最好在其余代码开始依赖新代码实现之前,尽早捕获和解决它。这是一种平衡,需要仔细分析,甚至还常常需要点政治技巧。

遵循以下准则,可以使得尽早频繁测试变得最有用。

自动化一切

  所有的性能测试都应该脚本化(或者程序化,虽然脚本更简单)。全部环境都必须通过脚本安装和配置新代码(创建数据库连接、建立用户账号等),然后用脚本运行测试集。所谓自动化,还不止这些:脚本必须能够多次运行测试,对结果进行 t 检验分析,并能生成置信度报告,说明统计结果是相同,还是不同,如果不同,相差多少。

  在测试运行前,必须通过自动化技术确保机器处于已知状态:必须检查是否有不希望运行的进程,操作系统配置是否正确,等等。只有每轮运行时保持相同的环境,性能测试才是可重复的。自动化过程中必须考虑这点。

测试一切

  必须自动收集能想象到的每一点数据,以便进行后续分析。这些数据包括整个运行过程中采集的系统信息:CPU 使用率、磁盘使用率、网络使用率和内存使用率等。数据还包括应用的日志——应用产生的日志,以及垃圾收集器的日志。理想情况下,还应该包括 JFR 记录的信息(参见第 3 章),或者对系统影响较小的性能分析(profiling)信息,周期性线程堆栈,以及其他堆分析数据,例如直方图或者全堆的转储信息(尤其是全堆转储,需要占用大量空间,没有必要长期保留)。

  如果适用的话,监控信息还必须包括系统其他部分的数据:例如,如果程序使用数据库,就应该包括数据库机器的系统统计数据,以及所有的数据库诊断输出(包括 Oracle 的 Automatic Workload Repository [AWR] 这样的性能报告)。

  这些数据可以指导所有未被覆盖的回归分析。如果 CPU 使用率上升,就需要参考性能分析信息,弄清楚是什么花费了这么多时间。如果 GC 时间变长,就该查阅堆性能分析信息,搞明白是什么消耗了这么多内存。如果 CPU 和 GC 时间都减少,某些地方的竞争可能降低了性能:栈数据可以指示特定的同步瓶颈(参见第 9 章),JFR 记录可用来发现应用的延迟,数据库日志也可以发现数据库竞争加剧的线索。

  当发现性能衰减源时,需要进一步巡查,找到更多可用数据,更多可以追踪的线索。正如第 1 章所讨论的,发生性能衰减的未必是 JVM。测量一切,从而确保分析的正确性。

在真实系统上运行

  在单核笔记本上运行测试,与在 256 线程 SPARC CPU 机器上有很大的不同。从线程效应上来说,原因很清楚:机器规模越大,同时能运行的线程就越多,从而能减少应用线程对 CPU 的竞争。与此同时,大规模系统也会遇到小型笔记本上会被忽略的同步性能瓶颈。

  还有其他重大的性能差异,即便乍一眼看上去不那么明显。许多重要的性能调优标志,它们的默认值是基于 JVM 运行的底层硬件系统计算出来的。平台和平台之间所编译出来的代码也不同。缓存——软件缓存以及更重要的硬件缓存——在不同系统和不同负载下也是不一样的,等等。

  因此,除非在预期的负载和预期的硬件下测试,否则永远无法在测试中完全了解特定生产环境下的性能。可以在配置较低的硬件上运行规模较小的测试,以此来模拟和外推。在现实测试中,复制生产环境相当困难或昂贵。但外推只是简单的预测,即便在最好的情况下,预测也可能是错的。大规模系统远不只是将各部分加起来那么简单,没有什么测试能够代替在真实系统上的负载了。

 快速小结

1. 虽然频繁的性能测试很重要,但并非毫无代价,在日常的开发周期中需要仔细斟酌。

2. 自动化测试系统可以收集所有机器和程序的全部统计数据,这可以为查找性能衰减问题提供必不可少的线索。

2.5 小结

性能测试包括各种权衡。面对诸多相互制约的选择,我们能否做出适当的决策,对于系统性能能否提升至关重要。

性能测试应该先测哪部分,与我们的经验和直觉息息相关。微基准测试在这方面的作用最小,它的用途仅限于为某些操作设立宽泛的指导。这为其他测试留下广泛的施展空间,从小模块的测试到大规模多层的应用环境。所有这些测试都有某方面的价值,如何选择就得依靠经验和直觉了。不过最终部署到生产环境中之后,除了全应用测试,就没什么可选了。只有到那时才能理解所有与性能相关的问题以及全部影响。

与此类似,哪些代码导致或没导致性能衰减,并不总是皂白分明的。程序时不时会表现出随机行为,而一旦引入了随机性,我们就再也无法 100% 确定这些数据意味着什么了。使用统计分析有助于使结果变得更客观,但即便如此,仍然免不了主观臆断。理解这些数据背后的概率及其意义,有助于降低主观性。

目录

  • 版权声明
  • O'Reilly Media, Inc. 介绍
  • 推荐序
  • 前言
  • 第 1 章 导论
  • 第 2 章 性能测试方法
  • 第 3 章 Java性能调优工具箱
  • 第 4 章 JIT编译器
  • 第 5 章 垃圾收集入门
  • 第 6 章 垃圾收集算法
  • 第 7 章 堆内存最佳实践
  • 第 8 章 原生内存最佳实践
  • 第 9 章 线程与同步的性能
  • 第 10 章 Java EE 性能调优
  • 第 11 章 数据库性能的最佳实践
  • 第 12 章 Java SE API技巧
  • 附录 A 性能调优标志摘要
  • 作者简介
  • 关于封面