2.1 原则1:测试真实应用

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

2.1.1 微基准测试

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

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

  1. public void doTest() {
  2. // 主循环
  3. double l;
  4. long then = System.currentTimeMillis();
  5. for (int i = 0; i < nLoops; i++) {
  6. l = fibImpl1(50);
  7. }
  8. long now = System.currentTimeMillis();
  9. System.out.println("Elapsed time: " + (now - then));
  10. }
  11. ...
  12. private double fibImpl1(int n) {
  13. if (n < 0) throw new IllegalArgumentException("Must be > 0");
  14. if (n == 0) return 0d;
  15. if (n == 1) return 1d;
  16. double d = fibImpl1(n - 2) + fibImpl(n - 1);
  17. if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
  18. return d;
  19. }

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

1. 必须使用被测的结果

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

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

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

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

多线程微基准测试

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

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

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

2. 不要包括无关的操作

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

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

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

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

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

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

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

  1. int[] input = new int[nLoops];
  2. for (int i = 0; i < nLoops; i++) {
  3. input[i] = random.nextInt();
  4. }
  5. long then = System.currentTimeMillis();
  6. for (int i = 0; i < nLoops; i++) {
  7. try {
  8. l = fibImpl1(input[i]);
  9. } catch (IllegalArgumentException iae) {
  10. }
  11. }
  12. long now = System.currentTimeMillis();

3. 必须输入合理的参数

此处还有第 3 个隐患,就是测试的输入值范围:任意选择的随机输入值对于这段被测代码的用法来说并不具有代表性。在这个测试例子中,有一半的方法调用会立即抛出异常(即所有的负数)。输入参数大于 1476 时,也都会抛出异常,因为此时计算出来的是 double 类型所能表示的最大的斐波那契数。

如果计算斐波那契数的速度大幅度提升,但例外情况直到计算结束时才被监测到时,在实现中会发生什么?考虑下面这种替代实现:

  1. public double fibImplSlow(int n) {
  2. if (n < 0) throw new IllegalArgumentException("Must be > 0");
  3. if (n > 1476) throw new ArithmeticException("Must be < 1476");
  4. return verySlowImpl(n);
  5. }

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

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

热身期

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

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

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

  1. package net.sdo;
  2. import java.util.Random;
  3. public class FibonacciTest {
  4. private volatile double l;
  5. private int nLoops;
  6. private int[] input;
  7. public static void main(String[] args) {
  8. FibonacciTest ft = new FibonacciTest(Integer.parseInt(args[0]));
  9. ft.doTest(true);
  10. ft.doTest(false);
  11. }
  12. private FibonacciTest(int n) {
  13. nLoops = n;
  14. input = new int[nLoops];
  15. Random r = new Random();
  16. for (int i = 0; i < nLoops; i++) {
  17. input[i] = r.nextInt(100);
  18. }
  19. }
  20. private void doTest(boolean isWarmup) {
  21. long then = System.currentTimeMillis();
  22. for (int i = 0; i < nLoops; i++) {
  23. l = fibImpl1(input[i]);
  24. }
  25. if (!isWarmup) {
  26. long now = System.currentTimeMillis();
  27. System.out.println("Elapsed time:" + (now - then));
  28. }
  29. }
  30. private double fibImpl1(int n) {
  31. if (n < 0) throw new IllegalArgumentException("Must be > 0");
  32. if (n == 0) return 0d;
  33. if (n == 1) return 1d;
  34. double d = fibImpl1(n - 2) + fibImpl(n - 1);
  35. if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
  36. return d;
  37. }
  38. }

甚至这个微基准测试的测量结果中也仍然有一些与计算斐波那契数没有太大关系:调用 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 或者用户交互发生。

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

2.1 原则1:测试真实应用 - 图2 快速小结

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

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

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

2.1.4 代码示例

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

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

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

  1. public interface StockPrice {
  2. String getSymbol();
  3. Date getDate();
  4. BigDecimal getClosingPrice();
  5. BigDecimal getHigh();
  6. BigDecimal getLow();
  7. BigDecimal getOpeningPrice();
  8. boolean isYearHigh();
  9. boolean isYearLow();
  10. Collection<? extends StockOptionPrice> getOptions();
  11. }

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

  1. public interface StockPriceHistory {
  2. StockPrice getPrice(Date d);
  3. Collection<StockPrice> getPrices(Date startDate, Date endDate);
  4. Map<Date, StockPrice> getAllEntries();
  5. Map<BigDecimal,ArrayList<Date>> getHistogram();
  6. BigDecimal getAveragePrice();
  7. Date getFirstDate();
  8. BigDecimal getHighPrice();
  9. Date getLastDate();
  10. BigDecimal getLowPrice();
  11. BigDecimal getStdDev();
  12. String getSymbol();
  13. }

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

  1. public class StockPriceHistoryImpl implements StockPriceHistory {
  2. ...
  3. public StockPriceHistoryImpl(String s, Date startDate,
  4. Date endDate, EntityManager em) {
  5. Date curDate = new Date(startDate.getTime());
  6. symbol = s;
  7. while (!curDate.after(endDate)) {
  8. StockPriceImpl sp = em.find(StockPriceImpl.class,
  9. new StockPricePK(s, (Date) curDate.clone()));
  10. if (sp != null) {
  11. Date d = (Date) curDate.clone();
  12. if (firstDate == null) {
  13. firstDate = d;
  14. }
  15. prices.put(d, sp);
  16. lastDate = d;
  17. }
  18. curDate.setTime(curDate.getTime() + msPerDay);
  19. }
  20. }
  21. ...
  22. }

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

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

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

  1. public static BigDecimal sqrtB(BigDecimal bd) {
  2. BigDecimal initial = bd;
  3. BigDecimal diff;
  4. do {
  5. BigDecimal sDivX = bd.divide(initial, 8, RoundingMode.FLOOR);
  6. BigDecimal sum = sDivX.add(initial);
  7. BigDecimal div = sum.divide(TWO, 8, RoundingMode.FLOOR);
  8. diff = div.subtract(initial).abs();
  9. diff.setScale(8, RoundingMode.FLOOR);
  10. initial = div;
  11. } while (diff.compareTo(error) > 0);
  12. return initial;
  13. }

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

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

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

  1. protected void processRequest(HttpServletRequest request,
  2. HttpServletResponse response)
  3. throws ServletException, IOException {
  4. try {
  5. String symbol = request.getParameter("symbol");
  6. if (symbol == null) {
  7. symbol = StockPriceUtils.getRandomSymbol();
  8. }
  9. ... similar processing for date and other params...
  10. StockPriceHistory sph;
  11. DateFormat df = localDf.get();
  12. sph = stockSessionBean.getHistory(symbol, df.parse(startDate),
  13. df.parse(endDate), doMock, impl);
  14. String saveSession = request.getParameter("save");
  15. if (saveSession != null) {
  16. .... Store the data in the user`s session ....
  17. .... Optionally store the data in a global cache for
  18. .... use by other requests
  19. }
  20. if (request.getParameter("long") == null) {
  21. // 发回一个带有约4K大小的数据的页面
  22. request.getRequestDispatcher("history.jsp").
  23. forward(request, response);
  24. }
  25. else {
  26. // 发回一个带有约100K大小的数据的页面
  27. request.getRequestDispatcher("longhistory.jsp").
  28. forward(request, response);
  29. }
  30. }

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

被测系统的硬件

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

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