`
zhangziyangup
  • 浏览: 1075791 次
文章分类
社区版块
存档分类
最新评论

健壮的 Java 基准测试,第 1 部分: 问题,了解 Java 代码基准测试的问题

 
阅读更多

当今的 CPU 速度已经达到数 GHz,出现了多核处理器和数 GB 的内存,即使在这样的时代,程序性能问题仍然受到持续的关注。随着硬件功能的每次提高,都会出现具有挑战性的新型应用程序(或者增加了程序员的 “惰性”)。基准测试代码(以及从基准测试结果得出正确的结论)总是存在问题和困难,而且几乎没有比 Java 更难进行基准测试的语言,尤其是在先进的现代虚拟机上。

这个分两部分的文章系列只讨论程序执行时间,不考虑执行程序时的其他重要性质,比如内存使用量。即使在如此狭义的性能定义之下,精确地进行代码基准测试仍然有很多困难。这些问题的数量和复杂性使大多数基准测试都不太精确,常常导致误解。本文的第一部分只讨论这些问题,并给出了在编写自己的基准测试框架时需要考虑的各个方面。

一个性能难题

我首先通过一个性能难题演示一些基准测试方面的问题。请考虑清单 1 中的代码(参见 参考资料 获得本文的完整示例代码链接):


清单 1. 性能难题

以下哪个版本运行得最快呢?

  1. 保持此代码不变(calculate 中没有 arg 测试)
  2. 只取消 L1 行的注释标志,但是禁用断言(使用 -disableassertions JVM 选项;这也是默认行为)
  3. 只取消 L1 行的注释标志,但是启用断言(使用 -enableassertions JVM 选项)
  4. 只取消 L2 行的注释标志

您至少应该猜出 A 版本(没有测试)一定是最快的,还可能猜到 B 应该与 A 差不多一样快,因为在关闭断言的情况下,L1 行是死代码,良好的动态优化编译器应该会消除它。这种猜测对吗?不幸的是,可能错了。清单 1 中的代码取自 Cliff Click 在 2002 JavaOne 上的发言稿(参见 参考资料)。他的幻灯片报告了下面的执行时间:

  1. 5 秒
  2. 0.2 秒
  3. (他没有报告这种情况下的数据)
  4. 5 秒

当然,最让人吃惊的是 B。它怎么会比 A 快 25 倍呢?

6 年后,我在下面的现代配置上运行了 清单 1 中的代码(除非另外说明,本文中的所有基准测试结果都采用这种配置):

  • 硬件:2.2 GHz Intel Core 2 Duo E4500,2 GB RAM
  • 操作系统:Windows® XP SP2,包含到 2008 年 3 月 13 日为止的所有更新
  • JVM:1.6.0_05,所有测试都使用 -server 选项

我得到了以下结果:

  1. 38.601 ms
  2. 56.382 ms
  3. 38.502 ms
  4. 39.318 ms

B 现在明显比 A、C 和 D 慢。但是,结果仍然很奇怪:B 应该与 A 差不多,可是它比 C 还慢,这很让人吃惊。注意,我对每个版本做了 4 次度量,都获得了大体类似的结果(偏差在 1 ms 之内)。

Click 的幻灯片讨论了为什么会得到奇怪的结果(他把这种现象归因于复杂的 JVM 行为;还牵涉到一个 bug)。Click 是 HotSpot JVM 的架构师,所以他的解释应该是合理的。但是,普通的程序员有办法进行正确的基准测试吗?

答案是肯定的。在本文的 第 2 部分 中,我将提供一个 Java 基准测试框架,您可以放心地下载和使用它,因为它处理了许多基准测试问题。这个框架很容易满足大多数基准测试需求:只要把目标代码打包成特定类型的任务对象(CallableRunnable),然后调用 Benchmark 类。其他所有工作(性能度量、统计数据计算和结果报告)都会自动完成。

为了演示这个框架的使用方法,我把 main 替换为清单 2 中的代码,从而重新对 清单 1 中的代码进行基准测试:


清单 2. 使用 Benchmark 解决性能难题

在我的配置上运行此代码产生了以下结果:

  1. mean = 20.241 ms ...
  2. mean = 20.246 ms ...
  3. mean = 26.928 ms ...
  4. mean = 26.863 ms ...

结果终于符合预期了:A 和 B 的执行时间基本相同。C 和 D(它执行相同的参数检查)的执行时间也差不多,但是长一点儿。

在这种情况下,使用 Benchmark 获得了预期的结果,这可能是因为它在内部执行 task 许多次,丢弃出现稳定的执行状态之前的 “预热(warmup)” 数据,然后执行一系列精确的度量。与之相反,清单 1 中的代码马上开始度量执行时间,这意味着它的结果与实际代码的执行时间关系不大,但与 JVM 行为 密切相关。尽管在上面的结果中省略了(由 ... 表示),但是 Benchmark 还执行一些有意义的统计计算,这些计算表明了结果的可靠性。

但是,请不要直接使用这个框架。您应该在一定程度上熟悉本文,尤其是熟悉与 动态优化 有关的一些复杂问题,以及 第 2 部分 中讨论的一些解释问题。不要盲目地相信任何数字。要了解这些数字是如何获得的。

执行时间度量

从原理上看,度量代码的执行时间很简单:

  1. 记录开始时间。
  2. 执行代码。
  3. 记录停止时间。
  4. 计算时间差。

大多数 Java 程序员可能会编写出与清单 3 相似的代码:


清单 3. 典型的 Java 基准测试代码

                
long t1 = System.currentTimeMillis();
task.run();    // task is a Runnable which encapsulates the unit of work
long t2 = System.currentTimeMillis();
System.out.println("My task took " + (t2 - t1) + " milliseconds to execute.");

清单 3 的方法对于长时间运行的任务常常很合适。例如,如果 task 所需的执行时间达到一分钟,那么下面讨论的分辨率问题可能不明显。但是,随着 task 执行时间的降低,这段代码会越来越不精确。基准测试框架应该能够自动处理任何 task,所以清单 3 并不合适。

一个问题是分辨率:System.currentTimeMillis 表示返回的结果具有名义上的毫秒级分辨率(参见 参考资料)。如果假设结果包含随机的 ±1 ms 误差,并希望执行时间的度量误差不超过 1%,那么对于执行时间等于或小于 200 ms 的任务,System.currentTimeMillis 就不能满足分辨率需求(因为两次度量涉及两个误差,误差的和可能达到 2 ms)。

在真实环境中,System.currentTimeMillis 的分辨率可能会糟糕 ~10-100 倍。它的 Javadoc 指出:

注意,尽管返回值的时间单位是毫秒,但是值的粒度取决于底层操作系统,甚至可能比操作系统的时间单位更大。例如,许多操作系统以几十毫秒作为时间度量的单位。

已经报告的分辨率数据见表 1:


表 1. 分辨率

分辨率 平台 来源(参见 参考资料) 55 ms 10 ms 15.625 ms ~15 ms 10 ms 1 ms
Windows 95/98 Java Glossary
Windows NT, 2000, XP 单处理器 Java Glossary
Windows XP 多处理器 Java Glossary
Windows(可能是指 XP) Simon Brown
Linux 2.4 内核 Markus Kobler
Linux 2.6 内核 Markus Kobler

所以,对于执行时间小于 10 秒的任务,清单 3 中的代码很容易出现过大的误差。

System.currentTimeMillis 的最后一个问题是,假设它反映 “墙上时钟” 的时间,这个问题甚至会影响长时间运行的任务。这意味着,由于标准时间到夏时制的转换或 Network Time Protocol(NTP)同步等事件,它的值偶尔会有突变(向前或向后)。这些调整虽然很少出现,但是可能导致错误的基准测试结果。

JDK 1.5 引入了一个分辨率更高的 API:System.nanoTime(参见 参考资料)。它名义上返回纳秒数,但是有不确定的偏移量。它的关键特性包括:

  • 它只适用于度量时间差。
  • 它的精确性和精度(参见 参考资料)应该不会比 System.currentTimeMillis 差,但是在一些平台上与 System.currentTimeMillis 相同。
  • 在现代硬件和操作系统上,它可以提供微秒级的精确性和精度。

结论:基准测试应该坚持使用 System.nanoTime,因为它通常具有更好的分辨率。但是,它可能并不比 System.currentTimeMillis 好,基准测试代码必须处理这种可能性。

JDK 1.5 还引入了 ThreadMXBean 接口(参见 参考资料)。它有几个功能,但是它的 getCurrentThreadCpuTime 方法与基准测试的关系尤其密切(参见 参考资料)。这个方法不度量流逝(“墙上时钟”)时间,而是度量当前线程使用的实际 CPU 时间(这个时间小于或等于流逝时间)。

不幸的是,getCurrentThreadCpuTime 也有一些问题:

  • 您的平台可能不支持它。
  • 在支持它的不同平台上,它的语义有差异(例如,对于使用 I/O 的线程,它的 CPU 时间可能包括执行 I/O 的时间,但是 I/O 时间也可能算在操作系统线程上)。
  • ThreadMXBean Javadoc 提出了以下警告:“在某些 Java 虚拟机实现中,启用线程 CPU 时间度量可能导致很大的开销”。(这是一个与操作系统相关的问题。在某些操作系统上,度量线程 CPU 时间所需的 microaccounting 总是打开的,所以 getCurrentThreadCpuTime 没有额外的性能影响。其他操作系统在默认情况下关闭这个特性;如果启用它,它会降低进程中的所有线程或所有进程的性能)。
  • 它的分辨率不明确。因为它返回的结果具有名义上的纳秒级分辨率,自然会认为它的精确性和精度限制与 System.nanoTime 相同。但是,我没有找到证明这一点的任何文档,而且有一个报告指出它的精度更低(参见 参考资料)。我对 getCurrentThreadCpuTimenanoTime 做了对比试验,发现前者产生的平均执行时间比较小。在我的桌面电脑配置上,执行时间大约降低了 0.5%-1%。不幸的是,度量漂移量很大;例如,标准差很容易增加到三倍。在一台 N2 Solaris 10 机器上,执行时间降低了 5%-10%,度量漂移量没有增加(有时候出现大幅度降低)。
  • 最糟糕的是:当前线程使用的 CPU 时间可能是不相关的。例如,一个任务有一个进行调用的线程(将度量这个线程的 CPU 时间),它仅仅建立一个线程池,然后把一些子任务发送给这个池,然后就一直空闲着,直到池完成任务。进行调用的线程所用的 CPU 时间会非常少,而完成这个任务所需的时间可以无限长。因此,报告这个时间会导致严重的误解。

由于有这些问题,在通用的基准测试框架上默认使用 getCurrentThreadCpuTime 太危险了。第 2 部分 中提供的 Benchmark 类要求通过一个特殊配置来启用它。

所有时间度量 API 需要注意的问题:它们都有执行开销,如果过于频繁地执行这些 API,就会严重歪曲度量值。这个问题的影响高度依赖于平台。例如,在 Windows 的现代版本中,System.nanoTime 涉及一个执行时间为微秒级的操作系统调用,所以调用它的频率不应该高于每 100 微秒一次,否则对度量的影响就会超过 1%。(相反,System.currentTimeMillis 只需要读取一个全局变量,所以执行得非常快,是纳秒级的。如果仅仅考虑对度量的影响,可以更频繁地调用它;但是,这个全局变量的更新没这么频繁,根据 表 1 来看,大约是每 10 到 15 毫秒一次,所以频繁地调用它是没有必要的)。另一方面,在大多数 Solaris(和某些 Linux®)机器上,System.nanoTime 常常比 System.currentTimeMillis 执行得快。

代码预热

一个性能难题 一节中,我指出 Benchmark 产生更可靠的结果的原因是,它只度量稳定状态下 task 的执行时间,而不理会最初的性能。大多数 Java 实现具有复杂的性能生命周期。一般来说,最初的性能往往相当低,然后性能显著提高(常常出现几次性能跃升),直到到达稳定状态。假设希望度量稳定状态下的性能,就需要了解影响这个过程的所有因素。

类装载

JVM 通常只在类的第一次使用类时装载它们。所以,task 的第一次执行时间包含装载它使用的所有类的时间(如果这些类还没有装载的话)。因为类装载往往涉及磁盘 I/O、解析和检验,这会显著增加 task 的第一次执行时间。常常可以通过多次执行 task 来消除这种影响。(我说常常 —— 而不是总是,这是因为 task 可能具有复杂的分支行为,这可能导致它在任何给定的执行过程中并不使用所有可能用到的类。幸运的是,如果执行任务足够多次,就可能经历所有分支,因此很快就会装载所有相关类)。

如果使用定制的类装载器,就有另一个问题:JVM 可能认为一些类已经成了垃圾,因此决定卸载它。这不太可能严重影响性能,但是仍然会使基准测试结果产生偏差。

可以在基准测试之前和之后调用 ClassLoadingMXBeangetTotalLoadedClassCountgetUnloadedClassCount 方法,以此判断在基准测试过程中是否发生了类装载/卸载(参见 参考资料)。如果两次的结果不同,就是还未达到稳定状态。

混合模式

在执行即时(Just-in-time,JIT)编译之前,现代的 JVM 通常会运行代码一段时间(常常是纯解释式运行),从而收集剖析信息(参见 参考资料)。这对基准测试的影响在于,任务可能需要执行许多次,才能达到稳定状态。例如,Sun 的客户机/服务器 HotSpot JVM 当前的默认行为是,必须对一个代码块进行 1,500(客户机)或 10,000(服务器)次调用,之后才对包含这个代码块的方法进行 JIT 编译。

注意,我在这里使用了一个一般性短语 “代码块(code block)”,这不仅仅可以指完整的方法,还可以指一个方法中的块。例如,许多先进的编译器可以识别出构成 “热” 代码的循环代码块,即使它只包含对包含方法的一个调用。我将在本文的 堆栈上替换 一节中详细解释这一点。

因此,对稳定状态下的性能进行基准测试需要以下步骤:

  1. 执行 task 一次,以便装载所有类。
  2. 执行 task 足够多次,以确保出现稳定状态的执行数据。
  3. 再多执行 task 几次,以获得执行时间的估计值。
  4. 使用步骤 3 计算 n,这是执行 task 的次数,这些次执行的累计时间必须足够大。
  5. 度量对 taskn 次调用的总执行时间 t
  6. 估算执行时间,t/n

度量 taskn 次执行的目的在于,让累计的执行时间足够大,从而减少前面讨论的所有时间度量误差的影响。

步骤 2 比较棘手:怎么能够知道 JVM 什么时候完成了对这个任务的优化?

一种看似聪明的方法是不断度量执行时间,直到结果值收敛。这种方式似乎很好,但是如果 JVM 正在收集剖析信息,然后在您开始步骤 5 之后突然应用剖析信息执行 JIT 编译,这种方法就无效了;这在 未来 更可能引起问题。

另外,如何量化 “收敛” 的概念呢?

连续编译?

目前,Sun 的 HotSpot JVM 只执行一个剖析阶段,然后可能执行编译。如果忽略 去优化,当前并不执行连续编译,这是因为把剖析代码放在热点方法中开销太大了(参见 参考资料)。

对于剖析开销问题,存在一些解决方案。例如,JVM 可以保留方法的两个版本:一个不包含剖析代码的快速版本和执行剖析的慢速版本(参见 参考资料)。JVM 在大多数时候使用快速版本,只是偶尔使用慢速版本来维护剖析信息,这样就不会对性能产生显著影响。JVM 还可以在另一个处理器核空闲时并发执行慢速版本。这样的技术在未来可能使连续编译成为常规做法。

另一种方法是在一个预先确定的长度合理的时间段内连续执行任务(Benchmark 类就使用这种方法)。10 秒的预热阶段应该足够了(参见 Click 发言稿的第 33 页)。这种方法可能并不比度量执行时间直至收敛的方法更可靠,但是更容易实现。它还更容易参数化:用户应该很容易理解这种方法的概念,而且知道预热时间越长,结果就越可靠(但以长时间的基准测试为代价)。

如果可以判断什么时候 JIT 编译,就可以更有把握地确定稳定状态性能。尤其是,如果您认为已经到了稳定状态并开始基准测试,但是随后发现在基准测试期间发生了编译,那么可以中止并重试。

根据我的知识,还没有探测 JIT 编译是否发生的完美方法。最好的技术是在基准测试之前和之后调用 CompilationMXBean.getTotalCompilationTime。不幸的是,CompilationMXBean 的实现非常拙劣,所以这种方法有许多问题。另一种技术是,在使用 -XX:+PrintCompilation JVM 选项的情况下,解析(或人工观察)stdout(参见 参考资料)。

动态优化

除了预热问题之外,JVM 的动态编译涉及另外几个影响基准测试的问题。这些问题很微妙。而且更糟糕的是,只能靠基准测试程序员来解决这些问题,基准测试框架对此没有帮助。(本文的 缓存准备 两节也讨论一些由基准测试程序员负责解决的问题,但是这些问题基本上靠常识就能够解决)。

去优化

另一个问题是去优化(参见 参考资料):编译器可以停止使用已编译的方法,并对它进行一段时间的解释,然后重新编译它。当执行优化的动态编译器做出的假设已经过时时,就会发生这种情况。一个例子是使单态调用转换失效的类装载。另一个例子是不常用的分支:在最初编译一个代码块时,只编译最常用的代码路径,而不常用的分支(比如异常路径)仍然采用解释方式。但是,如果不常用的分支变成了经常执行的,它们就成了热点,这会触发重新编译。

因此,即使按照前一节中的建议实现了稳定状态,也要注意性能仍然可能突然下降。这是需要探测在基准测试期间是否发生 JIT 编译的另一个原因。

堆栈上替换

另一个问题是堆栈上替换(OSR),这种高级 JVM 特性有助于优化某些代码结构(参见 参考资料)。请考虑清单 4 中的代码:


清单 4. OSR 问题的示例代码

如果 JVM 只考虑方法调用,那么根本不会使用 main 的编译版本,因为它只被调用一次。为了解决这个问题,JVM 可以考虑方法内代码块的执行。尤其是,对于清单 4 中的代码,JVM 可以跟踪执行每个循环的次数。(循环的最后一个括号构成一个 “向后分支”)。在默认情况下,任何循环在达到一定的迭代次数(比如 10,000 次)之后,就应该触发整个方法的编译。因为 main 不会被再次调用,所以简单的 JVM 不会使用它的编译版本。但是,使用 OSR 的 JVM 非常机智,可以把方法调用中的 当前代码替换为新的编译代码。

初看上去,OSR 似乎很不错。好像 JVM 可以处理任何代码结构,同时提供最佳性能。不幸的是,OSR 有一个不太为人所知的缺陷:在使用 OSR 时,代码质量可能是次优的。例如,OSR 有时候无法提升循环、消除数组边界检查或解开循环(参见 参考资料)。如果使用 OSR,可能无法得到最佳性能。

假设希望获得最佳性能,那么解决 OSR 问题的惟一方法是了解什么时候会出现 OSR,并调整代码结构来避免它。这通常需要把关键的内部循环放在单独的方法中。例如,清单 4 中的代码可以改写为清单 5:


清单 5. 改写后的代码不再受 OSR 的影响

在清单 5 中,addxor 方法会分别被调用 1,000,000 次,所以它们应该会完整地 JIT 编译为优化形式。在我的配置上,这段代码前三次运行的执行时间是 10.81、10.79 和 10.80 秒。而 清单 4(所有循环放在 main 中,因此触发 OSR)的执行时间高了一倍。(前三次的执行时间是 21.61、21.61 和 21.6 秒)。

关于 OSR 的最后一点提示:通常,只有程序员很懒惰,把所有东西都放在一个方法(比如 main)中时,它才会给基准测试带来性能问题。在真实的应用程序中,程序员通常会(而且应该)编写许多细粒度的方法。另外,影响性能的代码通常会长时间运行,并涉及多次调用关键方法。所以,真实的代码通常不会受到 OSR 性能问题的影响。在您的应用程序中,不需要过分担心这个问题,不必为此破坏优雅的代码(除非可以证明它确实造成了损害)。注意,Benchmark 在默认情况下会多次执行任务来收集统计数据,多次执行的副作用是消除 OSR 对性能的影响。

消除死代码

另一个微妙的问题是消除死代码(DCE),参见 参考资料。在某些情况下,编译器可以判断出某些代码根本不影响输出,所以编译器会消除这些代码。清单 6 给出一个静态执行(即在编译时由 javac 执行)死代码消除的典型示例:


清单 6. 受 DCE 影响的示例代码

                
private static final boolean debug = false;

private void someMethod() {
    if (debug) {
        // do something...
    }
}

javac 知道清单 6 中 if (debug) 块中的代码根本不会执行,所以会消除它。动态编译器(尤其是在进行方法内联之后)通过许多方法来判断死代码。DCE 在基准测试期间造成的问题是,执行的代码可能只是全部代码的一个小子集 — 完整的编译可能不会发生 — 这会导致错误地报告很短的执行时间。

我还没有找到出色地描述编译器用来判断死代码的所有条件的文档(参见 参考资料)。不可达代码显然是死代码,但是 JVM 采用的 DCE 策略常常更激进

例如,请重新考虑 清单 4 中的代码:注意,main 不只计算 result,而且在输出中使用 result。假设进行一个简单修改,从 println 中删除 result。在这种情况下,激进的编译器可能认为它根本不需要计算 result

这不是一个单纯的理论问题。请考虑清单 7 中的代码:


清单 7. 通过在输出中使用 result 停止 DCE

清单 7 中的代码在我的配置上的执行时间是总是 4.91 秒。如果删除 println 语句中对 result 的引用(代码变成 System.out.println("Execution time: " + ((t2 - t1) * 1e-9) + " seconds to compute result"); ),执行时间就是 0.08 秒。显然,DCE 消除了整个计算过程。(另一个 DCE 示例参见 参考资料)。

要想保证 DCE 不会消除您希望进行基准测试的计算,惟一的方法是让计算生成结果,然后以某种方式使用结果(例如,像清单 7 中的 println 那样在输出中使用)。Benchmark 类支持这种做法。如果任务是 Callable,就要确保 call() 方法返回计算所获得的结果。如果任务是 Runnable,就要确保任务的 toString 方法(这个方法必须覆盖 Object 对象的方法)使用的某个内部状态是用这个计算获得的。如果遵守这些规则,Benchmark 应该会完全防止 DCE。

与 OSR 一样,对于真实的应用程序 DCE 常常不是问题(除非您希望在特定时间内执行代码)。但是与 OSR 不同,DCE 对于编写得很糟糕的基准测试会造成严重问题:OSR 只会使结果不太精确,而 DCE 可能导致完全错误的结果

资源回收

典型的 JVM 会自动执行两种资源回收:垃圾收集和对象终结(GC/OF)。从程序员的角度来看,GC/OF 几乎是不确定的:它在根本上不受您的控制,可以在 JVM 认为需要的任何时候发生。

在基准测试中,结果应该包含由于任务本身造成的 GC/OF 时间。例如,如果仅仅因为任务的最初执行时间很短,就认为这个任务很快,可能是不可靠的,因为它最终可能产生很大的 GC 时间。(但是注意,一些任务不需要创建对象。相反,它们只需访问已经创建的对象。假设一次基准测试希望度量出访问某个数组元素所用的时间:这个任务应该不用创建数组。相反,应该在其他地方创建数组,这个任务可以使用数组的引用)。

但是,还需要把任务的 GC/OF 与同一 JVM 会话中其他代码造成的 GC/OF 分开。惟一的方法是在执行基准测试之前尝试清理 JVM,还要尝试确保任务本身的 GC/OF 在度量结束前完全完成。

System 类提供了 gcrunFinalization 方法,可以用这些方法清理 JVM。但是注意,这些方法的 Javadoc 仅仅声明 “当控制从方法调用返回时,Java 虚拟机会尽可能执行 GC/OF”。

第 2 部分 中提供的 Benchmark 类按照以下步骤处理 GC/OF:

  1. 在执行任何度量之前,它调用 cleanJvm 方法,这个方法根据需要多次调用 System.gcSystem.runFinalization,直到内存使用量稳定下来,并且所有对象已经终结。
  2. 在默认情况下,它执行 60 次执行度量,每次至少持续 1 秒(如果必要的话,对于每次度量多次调用任务,以此确保时间达到 1 秒)。所以总的执行时间应该至少 1 分钟,这么长的时间应该可以确保把足够的 GC/OF 生命周期包含在 60 次度量中,从而精确地度量任务的完整情况。
  3. 完成所有度量之后,最后一次调用 cleanJvm,但是这一次度量这个调用所花的时间。如果这个最终清理步骤花费的时间超过任务总执行时间的 1%,基准测试报告就会警告说,度量可能没有充分考虑 GC/OF 成本。
  4. 因为 GC/OF 对于每次度量来说就像是噪音源,所以使用统计数据来提取可靠的结果。

一个注意事项:在我最初编写 Benchmark 时,尝试用清单 8 中的代码在每次度量中考虑 GC/OF 成本:


清单 8. 考虑 GC/OF 成本的错误方法

                
protected long measure(long n) {
    cleanJvm();    // call here to cleanup before measurement starts

    long t1 = System.nanoTime();
    for (long i = 0; i < n; i++) {
        task.run();
    }
    cleanJvm();    // call here to ensure that task's GC/OF is fully included
    long t2 = System.nanoTime();
    return t2 - t1;
    }

问题在于,在度量循环内调用 System.gcSystem.runFinalization 会歪曲 GC/OF 成本。尤其是,System.gc 会用一个 stop-the-world 收集器对所有代进行一次全面的垃圾收集(参见 参考资料)。(这是默认行为,但是也可以通过 -XX:+ExplicitGCInvokesConcurrent-XX:+DisableExplicitGC 等 JVM 选项来控制)。而实际上,应用程序通常所用的垃圾收集器的操作方式可能很不一样。例如,它可能被配置成并发地工作,可能执行成本很小的许多次部分收集(特别针对年轻的代)。同样,终结通常是后台任务,所以它们常常在系统的空闲时间执行。

缓存

硬件/操作系统缓存有时候会使基准测试复杂化。一个简单例子是文件系统缓存,这种缓存可以在硬件或操作系统中发生。如果想对从文件读取字节所花费的时间进行基准测试,但是基准测试代码多次读取同一个文件(或者多次执行相同的基准测试),那么在第一次读取之后 I/O 时间会显著下降。如果希望对随机文件读取进行基准测试,很可能需要确保读取不同的文件,以避免缓存。

主内存的 CPU 缓存极其重要,需要特别关注(参见 参考资料)。近 20 年来,CPU 的速度呈指数式快速增长,而主内存的增长慢得多,大致是直线式的。为了调和这种差异,现代的 CPU 大量使用了缓存技术(目前现代 CPU 上的大多数晶体管都用于缓存)。适当利用 CPU 缓存的程序可以大大提高性能(大多数实际工作负载只使用了 CPU 理论吞吐量的一小部分)。

有许多因素影响程序是否适当地利用 CPU 缓存。例如,现代 JVM 在优化内存访问方面做了大量工作:它们可能重新布置堆空间、把值从堆转移到 CPU 寄存器、执行堆栈分配或执行对象分解(参见 参考资料)。但是,一个重要因素是数据集的大小。假设用 n 表示任务数据集的大小(例如,假设它使用一个数组的长度 n)。那么,只涉及单一 n 值的任何基准测试结果都很不可靠;必须针对各种 n 值执行一系列基准测试。J. P. Lewis 和 Ulrich Neumann 所写的文章提供了一个出色的示例(参见 参考资料)。他们制作了 Java FFT 性能与 C 的对比图,并采用 n(在这里是数组大小)的函数形式,由此发现 Java 的性能在比 C 快两倍到慢两倍之间振荡,具体性能取决于选择的 n

准备

开发出基准测试框架并不能一劳永逸地解决基准测试问题。在系统上运行任何基准测试程序之前,还应该解决一些系统问题。

电源

一个低级硬件问题是,要确保电源管理系统(例如,Advanced Power Management [APM] 或 Advanced Configuration and Power Interface [ACPI])在基准测试期间不进行状态转换,这在笔记本电脑上尤其重要。重大的电源状态变化(比如计算机转入休眠状态)可能不是由于基准测试本身的 CPU 活动导致的,或者很容易探测。但是,其他电源状态变化比较棘手。假设一个基准测试最初出现 CPU 瓶颈,在基准测试期间操作系统决定关闭硬盘驱动器的电源,然后任务在运行的末期希望使用这个硬盘驱动器:在这种情况下,基准测试会完成,但是 I/O 活动可能花费更长时间。另一个例子是,使用 Intel SpeedStep 或相似技术的系统会对 CPU 电源进行节流。在执行基准测试之前,应该通过配置操作系统避免这些问题。

其他程序

因为基准测试是一个任务,显然不应该同时运行其他程序(除非测试目的是检查您的任务在有负载机器上的表现如何)。应该关闭所有不重要的后台进程,并避免调度的进程(比如屏幕保护和病毒扫描程序)在基准测试期间启动。

Windows 提供了 ProcessIdleTask API,可以通过它在执行基准测试之前执行所有未完成的空闲进程。可以从命令行执行 Rundll32.exe advapi32.dll,ProcessIdleTasks 来访问这个 API。注意,它可能要花费几分钟,尤其是在一段时间内没有调用它的情况下。(后续执行常常只需几秒就可以完成)。

JVM 选项

有许多 JVM 选项会影响基准测试。比较重要的选项包括:

  • JVM 的类型:服务器(-server)与客户机(-client)。
  • 确保有足够的内存可用(-Xmx)。
  • 使用的垃圾收集器类型(高级的 JVM 提供许多调优选项,但是要小心使用)。
  • 是否允许类垃圾收集(-Xnoclassgc)。默认设置是允许类 GC;使用 -Xnoclassgc 可能会损害性能。
  • 是否执行 escape 分析(-XX:+DoEscapeAnalysis)。
  • 是否支持大页面堆(-XX:+UseLargePages)。
  • 是否改变了线程堆栈大小(例如,-Xss128k)。
  • 使用 JIT 编译的方式:总是使用(-Xcomp)、从不使用(-Xint)或只对热点使用(-Xmixed;这是默认选项,产生的性能最好)。
  • 在执行 JIT 编译之前(-XX:CompileThreshold)、后台 JIT 编译期间(-Xbatch)或分级的 JIT 编译期间(-XX:+TieredCompilation)收集的剖析数据量。
  • 是否执行偏向锁(biased locking,-XX:+UseBiasedLocking);注意,JDK 1.6 及更高版本会自动执行这个特性。
  • 是否激活最近的试验性性能调整(-XX:+AggressiveOpts)。
  • 启用还是禁用断言(-enableassertions-enablesystemassertions)。
  • 启用还是禁用严格的本机调用检查(-Xcheck:jni)。
  • 为 NUMA 多 CPU 系统启用内存位置优化(-XX:+UseNUMA)。

第 1 部分结束语

基准测试是极其困难的。许多因素会影响结果,其中一些因素很微妙。为了获得精确的结果,需要一个全面的解决方案,通过使用基准测试框架可以解决一部分问题。

分享到:
评论

相关推荐

    健壮的Java基准测试,第1部分:问题

    本文是分两部分的文章系列的第一篇,讨论与Java:trade_mark:代码基准测试相关的许多问题。第2部分讨论基准测试的统计并提供一个执行Java基准测试的框架。因为几乎所有新语言都是基于虚拟机的,所以本文讨论的基本...

    健壮的Java基准测试,第2部分:统计和解决方案

    本系列的第1部分解释了与Java代码基准测试相关联的许多问题。本文讨论另外两个领域。首先,讨论一些有助于克服基准测试中不可避免的度量偏差的统计技术。然后,介绍一个软件基准测试框架并对一系列示例使用这个框架...

    如何编出健壮的代码,java编程30条规则

    如何编出健壮的代码,java编程30条规则

    保持代码健壮性的小技巧

    讲述了保持代码健壮性的小技巧和原理 更深入学习java原理

    java核心技术第八版源代码(全)

    第1章 Java 程序设计概述 1.1 Java 程序设计平台 1.2 Java 白皮书的关键术语 1.2.1 简单性 1.2.2 面向对象 1.2.3 网络技能 1.2.4 健壮性 1.2.5 安全性 1.2.6 体系结构中立 1.2.7可移植性 1.2.8 解释型 1.2.9 高性能 ...

    [java.核心技术.第八版].Core.Java..8th.Edition源代码 示例代码

    第1章 Java 程序设计概述 1.1 Java 程序设计平台 1.2 Java 白皮书的关键术语 1.2.1 简单性 1.2.2 面向对象 1.2.3 网络技能 1.2.4 健壮性 1.2.5 安全性 1.2.6 体系结构中立 1.2.7可移植性 1.2.8 解释型 1.2.9 高性能 ...

    Java2实用教程提取码

    第1章 Java入门 第2章 标识符、关键字和数据类型 第3章 运算符、表达式和语句 第4章 类、对象和接口 第5章 字符串 第6章 时间、日期和数字 第7章 AWT组件及事件处理 第8章 建立对话框 第9章 Java多线程机制 第10章 ...

    Java核心技术 卷Ⅰ:基础知识 【中文】(第八版)

    第1章概述Java与其他程序设计语言不同的性能。解释这种语言的设计初衷,以及在哪些方 面达到了预期的效果。然后,简要叙述Java诞生和发展的历史。 第2章详细地论述如何下载和安装JDK以及本书的程序示例。然后,通过...

    Java零基础 - Java的健壮性.md

    本文介绍了Java的健壮性特点,包括异常处理、断言和错误处理。同时,提供了相应的Java示例代码。 内容概要 本文档涵盖以下内容: 异常处理机制及示例 断言的使用及示例 错误处理机制及示例 学到什么 通过阅读本文...

    狂神java基础源代码.zip

    javaSE源代码,学习笔记,查漏补缺。...Java具有简单性、面向对象、分布式、健壮性、安全性、平台独立与可移植性、多线程、动态性等特点 。Java可以编写桌面应用程序、Web应用程序、分布式系统和嵌入式系统应用程序等.

    corejava基础重要知识点总结

    1:跨平台(平台:指的是操作系统) 常见的操作系统:Windows Unix Linux Solaris(Sun) 跨平台:用java开发出来的应用程序不受底层操作系统的限制 底层的功臣:JVM = java虚拟机 = 1 + 2 + 3 = 秘书 + 保镖 + ...

    Java程序设计基础:Java语言概述.pptx

    1996年1月,Java的第一个开发包JDK1.0发布。 Java历史网站 Java语言特点 Java极其流行,其设计特性:“一次编译,到处运行”。 简单性(simple) 面向对象(object oriented) 分布式(distributed) 解释型...

    java手册中英文java8,java9手册

    太阳微系统对Java语言的解释是:“Java编程语言是个简单、面向对象、分布式、解释性、健壮、安全与系统无关、可移植、高性能、多线程和动态的语言” Java不同于一般的编译语言或解释型语言。它首先将源代码编译成...

    Java开发手册-代码规范

    《Java开发手册-代码规范》是一本旨在帮助Java开发者提高代码质量和可维护性的指南。该手册由阿里巴巴集团编写,总结了多年的开发经验和最佳实践。以下是该手册的主要内容: 命名规范:包括类名、方法名、变量名等...

    Java核心技术 卷I 基础知识 原书第10版.z02高清PDF(第二部分)

    下载第一部分和第二部分(2分)本来想免费,但是只能选择最低分了,抱歉,解压其中一个就可以。 一直以来,《Java核心技术》都被认为是面向高级程序员的经典教程和参考书,它内容翔实、客观准确,不拖泥带水,是想为...

    遍历、代码健壮性、如何看公司代码

    遍历、代码健壮性、如何看公司代码。对于刚开始编程序的童鞋来说值得看看学习一下。

    Java 代码编码规范

    代码规范相当重要....好的代码风格不仅会提高可读性,而且会使代码更健壮,更为重要的是在修改时不容易出错。 Java 编码规范 http://www.ossez.com/forum.php?mod=viewthread&tid=13486&fromuid=426

Global site tag (gtag.js) - Google Analytics