健壮的 Java 基准测试,第 1 部分: 问题
程序性能一直是受到关注的问题,即使在现在这样的高性能硬件时代,也是如此。本文是分两部分的文章系列的第一篇,讨论与 Java™ 代码基准测试相关的许多问题。 第 2 部分 讨论基准测试的统计并提供一个执行 Java 基准测试的框架。因为几乎所有新语言都是基于虚拟机的,所以本文讨论的基本原则适用于许多编程语言。
当今的 CPU 速度已经达到数 GHz,出现了多核处理器和数 GB 的内存,即使在这样的时代,程序性能问题仍然受到持续的关注。随着硬件功能的每次提高,都会出现具有挑战性的新型应用程序(或者增加了程序员的 “惰性”)。基准测试代码(以及从基准测试结果得出正确的结论)总是存在问题和困难,而且几乎没有比 Java 更难进行基准测试的语言,尤其是在先进的现代虚拟机上。
这个分两部分的文章系列只讨论程序执行时间,不考虑执行程序时的其他重要性质,比如内存使用量。即使在如此狭义的性能定义之下,精确地进行代码基准测试仍然有很多困难。这些问题的数量和复杂性使大多数基准测试都不太精确,常常导致误解。本文的第一部分只讨论这些问题,并给出了在编写自己的基准测试框架时需要考虑的各个方面。
我首先通过一个性能难题演示一些基准测试方面的问题。请考虑清单 1 中的代码(参见 参考资料 获得本文的完整示例代码链接):
protected static int global; public static void main(String[] args) { long t1 = System.nanoTime(); int value = 0; for (int i = 0; i < 100 * 1000 * 1000; i++) { value = calculate(value); } long t2 = System.nanoTime(); System.out.println("Execution time: " + ((t2 - t1) * 1e-6) + " milliseconds"); } protected static int calculate(int arg) { //L1: assert (arg >= 0) : "should be positive"; //L2: if (arg < 0) throw new IllegalArgumentException("arg = " + arg + " < 0"); global = arg * 6; global += 3; global /= 2; return arg + 2; } |
以下哪个版本运行得最快呢?
- 保持此代码不变(
calculate
中没有arg
测试) - 只取消
L1
行的注释标志,但是禁用断言(使用-disableassertions
JVM 选项;这也是默认行为) - 只取消
L1
行的注释标志,但是启用断言(使用-enableassertions
JVM 选项) - 只取消
L2
行的注释标志
您至少应该猜出 A 版本(没有测试)一定是最快的,还可能猜到 B 应该与 A 差不多一样快,因为在关闭断言的情况下,L1
行是死代码,良好的动态优化编译器应该会消除它。这种猜测对吗?不幸的是,可能错了。清单 1 中的代码取自 Cliff Click 在 2002 JavaOne 上的发言稿(参见 参考资料)。他的幻灯片报告了下面的执行时间:
- 5 秒
- 0.2 秒
- (他没有报告这种情况下的数据)
- 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
选项
我得到了以下结果:
- 38.601 ms
- 56.382 ms
- 38.502 ms
- 39.318 ms
B 现在明显比 A、C 和 D 慢。但是,结果仍然很奇怪:B 应该与 A 差不多,可是它比 C 还慢,这很让人吃惊。注意,我对每个版本做了 4 次度量,都获得了大体类似的结果(偏差在 1 ms 之内)。
Click 的幻灯片讨论了为什么会得到奇怪的结果(他把这种现象归因于复杂的 JVM 行为;还牵涉到一个 bug)。Click 是 HotSpot JVM 的架构师,所以他的解释应该是合理的。但是,普通的程序员有办法进行正确的基准测试吗?
答案是肯定的。在本文的 第 2 部分 中,我将提供一个 Java 基准测试框架,您可以放心地下载和使用它,因为它处理了许多基准测试问题。这个框架很容易满足大多数基准测试需求:只要把目标代码打包成特定类型的任务对象(Callable
或 Runnable
),然后调用 Benchmark
类。其他所有工作(性能度量、统计数据计算和结果报告)都会自动完成。
为了演示这个框架的使用方法,我把 main
替换为清单 2 中的代码,从而重新对 清单 1 中的代码进行基准测试:
public static void main(String[] args) throws Exception { Runnable task = new Runnable() { public void run() { int value = 0; for (int i = 0; i < 100 * 1000 * 1000; i++) { value = calculate(value); } } }; System.out.println("Cliff Click microbenchmark: " + new Benchmark(task)); } |
在我的配置上运行此代码产生了以下结果:
- mean = 20.241 ms ...
- mean = 20.246 ms ...
- mean = 26.928 ms ...
- mean = 26.863 ms ...
结果终于符合预期了:A 和 B 的执行时间基本相同。C 和 D(它执行相同的参数检查)的执行时间也差不多,但是长一点儿。
在这种情况下,使用 Benchmark
获得了预期的结果,这可能是因为它在内部执行 task
许多次,丢弃出现稳定的执行状态之前的 “预热(warmup)” 数据,然后执行一系列精确的度量。与之相反,清单 1 中的代码马上开始度量执行时间,这意味着它的结果与实际代码的执行时间关系不大,但与 JVM 行为 密切相关。尽管在上面的结果中省略了(由 ... 表示),但是 Benchmark
还执行一些有意义的统计计算,这些计算表明了结果的可靠性。
但是,请不要直接使用这个框架。您应该在一定程度上熟悉本文,尤其是熟悉与 动态优化 有关的一些复杂问题,以及 第 2 部分 中讨论的一些解释问题。不要盲目地相信任何数字。要了解这些数字是如何获得的。
![]() ![]() |
![]()
|
从原理上看,度量代码的执行时间很简单:
- 记录开始时间。
- 执行代码。
- 记录停止时间。
- 计算时间差。
大多数 Java 程序员可能会编写出与清单 3 相似的代码:
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:
分辨率 | 平台 | 来源(参见 参考资料) |
---|---|---|
55 ms | Windows 95/98 | Java Glossary |
10 ms | Windows NT, 2000, XP 单处理器 | Java Glossary |
15.625 ms | Windows XP 多处理器 | Java Glossary |
~15 ms | Windows(可能是指 XP) | Simon Brown |
10 ms | Linux 2.4 内核 | Markus Kobler |
1 ms | 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
相同。但是,我没有找到证明这一点的任何文档,而且有一个报告指出它的精度更低(参见 参考资料)。我对getCurrentThreadCpuTime
和nanoTime
做了对比试验,发现前者产生的平均执行时间比较小。在我的桌面电脑配置上,执行时间大约降低了 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 可能认为一些类已经成了垃圾,因此决定卸载它。这不太可能严重影响性能,但是仍然会使基准测试结果产生偏差。
可以在基准测试之前和之后调用 ClassLoadingMXBean
的 getTotalLoadedClassCount
和 getUnloadedClassCount
方法,以此判断在基准测试过程中是否发生了类装载/卸载(参见 参考资料)。如果两次的结果不同,就是还未达到稳定状态。
在执行即时(Just-in-time,JIT)编译之前,现代的 JVM 通常会运行代码一段时间(常常是纯解释式运行),从而收集剖析信息(参见 参考资料)。这对基准测试的影响在于,任务可能需要执行许多次,才能达到稳定状态。例如,Sun 的客户机/服务器 HotSpot JVM 当前的默认行为是,必须对一个代码块进行 1,500(客户机)或 10,000(服务器)次调用,之后才对包含这个代码块的方法进行 JIT 编译。
注意,我在这里使用了一个一般性短语 “代码块(code block)”,这不仅仅可以指完整的方法,还可以指一个方法中的块。例如,许多先进的编译器可以识别出构成 “热” 代码的循环代码块,即使它只包含对包含方法的一个调用。我将在本文的 堆栈上替换 一节中详细解释这一点。
因此,对稳定状态下的性能进行基准测试需要以下步骤:
- 执行
task
一次,以便装载所有类。 - 执行
task
足够多次,以确保出现稳定状态的执行数据。 - 再多执行
task
几次,以获得执行时间的估计值。 - 使用步骤 3 计算
n
,这是执行task
的次数,这些次执行的累计时间必须足够大。 - 度量对
task
的n
次调用的总执行时间t
。 - 估算执行时间,
t/n
。
度量 task
的 n
次执行的目的在于,让累计的执行时间足够大,从而减少前面讨论的所有时间度量误差的影响。
步骤 2 比较棘手:怎么能够知道 JVM 什么时候完成了对这个任务的优化?
一种看似聪明的方法是不断度量执行时间,直到结果值收敛。这种方式似乎很好,但是如果 JVM 正在收集剖析信息,然后在您开始步骤 5 之后突然应用剖析信息执行 JIT 编译,这种方法就无效了;这在 未来 更可能引起问题。
另外,如何量化 “收敛” 的概念呢?
![]() |
|
另一种方法是在一个预先确定的长度合理的时间段内连续执行任务(Benchmark
类就使用这种方法)。10 秒的预热阶段应该足够了(参见 Click 发言稿的第 33 页)。这种方法可能并不比度量执行时间直至收敛的方法更可靠,但是更容易实现。它还更容易参数化:用户应该很容易理解这种方法的概念,而且知道预热时间越长,结果就越可靠(但以长时间的基准测试为代价)。
如果可以判断什么时候 JIT 编译,就可以更有把握地确定稳定状态性能。尤其是,如果您认为已经到了稳定状态并开始基准测试,但是随后发现在基准测试期间发生了编译,那么可以中止并重试。
根据我的知识,还没有探测 JIT 编译是否发生的完美方法。最好的技术是在基准测试之前和之后调用 CompilationMXBean.getTotalCompilationTime
。不幸的是,CompilationMXBean
的实现非常拙劣,所以这种方法有许多问题。另一种技术是,在使用 -XX:+PrintCompilation
JVM 选项的情况下,解析(或人工观察)stdout
(参见 参考资料)。
![]() ![]() |
![]()
|
除了预热问题之外,JVM 的动态编译涉及另外几个影响基准测试的问题。这些问题很微妙。而且更糟糕的是,只能靠基准测试程序员来解决这些问题,基准测试框架对此没有帮助。(本文的 缓存 和 准备 两节也讨论一些由基准测试程序员负责解决的问题,但是这些问题基本上靠常识就能够解决)。
另一个问题是去优化(参见 参考资料):编译器可以停止使用已编译的方法,并对它进行一段时间的解释,然后重新编译它。当执行优化的动态编译器做出的假设已经过时时,就会发生这种情况。一个例子是使单态调用转换失效的类装载。另一个例子是不常用的分支:在最初编译一个代码块时,只编译最常用的代码路径,而不常用的分支(比如异常路径)仍然采用解释方式。但是,如果不常用的分支变成了经常执行的,它们就成了热点,这会触发重新编译。
因此,即使按照前一节中的建议实现了稳定状态,也要注意性能仍然可能突然下降。这是需要探测在基准测试期间是否发生 JIT 编译的另一个原因。
另一个问题是堆栈上替换(OSR),这种高级 JVM 特性有助于优化某些代码结构(参见 参考资料)。请考虑清单 4 中的代码:
private static final int[] array = new int[10 * 1000]; static { for (int i = 0; i < array.length; i++) { array[i] = i; } } public static void main(String[] args) { long t1 = System.nanoTime(); int result = 0; for (int i = 0; i < 1000 * 1000; i++) { // outer loop for (int j = 0; j < array.length; j++) { // inner loop 1 result += array[j]; } for (int j = 0; j < array.length; j++) { // inner loop 2 result ^= array[j]; } } long t2 = System.nanoTime(); System.out.println("Execution time: " + ((t2 - t1) * 1e-9) + " seconds to compute result = " + result); } |
如果 JVM 只考虑方法调用,那么根本不会使用 main
的编译版本,因为它只被调用一次。为了解决这个问题,JVM 可以考虑方法内代码块的执行。尤其是,对于清单 4 中的代码,JVM 可以跟踪执行每个循环的次数。(循环的最后一个括号构成一个 “向后分支”)。在默认情况下,任何循环在达到一定的迭代次数(比如 10,000 次)之后,就应该触发整个方法的编译。因为 main
不会被再次调用,所以简单的 JVM 不会使用它的编译版本。但是,使用 OSR 的 JVM 非常机智,可以把方法调用中的 当前代码替换为新的编译代码。
初看上去,OSR 似乎很不错。好像 JVM 可以处理任何代码结构,同时提供最佳性能。不幸的是,OSR 有一个不太为人所知的缺陷:在使用 OSR 时,代码质量可能是次优的。例如,OSR 有时候无法提升循环、消除数组边界检查或解开循环(参见 参考资料)。如果使用 OSR,可能无法得到最佳性能。
假设希望获得最佳性能,那么解决 OSR 问题的惟一方法是了解什么时候会出现 OSR,并调整代码结构来避免它。这通常需要把关键的内部循环放在单独的方法中。例如,清单 4 中的代码可以改写为清单 5:
public static void main(String[] args) { long t1 = System.nanoTime(); int result = 0; for (int i = 0; i < 1000 * 1000; i++) { // sole loop result = add(result); result = xor(result); } long t2 = System.nanoTime(); System.out.println("Execution time: " + ((t2 - t1) * 1e-9) + " seconds to compute result = " + result); } private static int add(int result) { // method extraction of inner loop 1 for (int j = 0; j < array.length; j++) { result += array[j]; } return result; } private static int xor(int result) { // method extraction of inner loop 2 for (int j = 0; j < array.length; j++) { result ^= array[j]; } return result; } |
在清单 5 中,add
和 xor
方法会分别被调用 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
执行)死代码消除的典型示例:
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 中的代码:
public static void main(String[] args) { long t1 = System.nanoTime(); int result = 0; for (int i = 0; i < 1000 * 1000; i++) { // sole loop result += sum(); } long t2 = System.nanoTime(); System.out.println("Execution time: " + ((t2 - t1) * 1e-9) + " seconds to compute result = " + result); } private static int sum() { int sum = 0; for (int j = 0; j < 10 * 1000; j++) { sum += j; } return sum; } |
清单 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
类提供了 gc
和 runFinalization
方法,可以用这些方法清理 JVM。但是注意,这些方法的 Javadoc 仅仅声明 “当控制从方法调用返回时,Java 虚拟机会尽可能执行 GC/OF”。
第 2 部分 中提供的 Benchmark
类按照以下步骤处理 GC/OF:
- 在执行任何度量之前,它调用
cleanJvm
方法,这个方法根据需要多次调用System.gc
和System.runFinalization
,直到内存使用量稳定下来,并且所有对象已经终结。
- 在默认情况下,它执行 60 次执行度量,每次至少持续 1 秒(如果必要的话,对于每次度量多次调用任务,以此确保时间达到 1 秒)。所以总的执行时间应该至少 1 分钟,这么长的时间应该可以确保把足够的 GC/OF 生命周期包含在 60 次度量中,从而精确地度量任务的完整情况。
- 完成所有度量之后,最后一次调用
cleanJvm
,但是这一次度量这个调用所花的时间。如果这个最终清理步骤花费的时间超过任务总执行时间的 1%,基准测试报告就会警告说,度量可能没有充分考虑 GC/OF 成本。
- 因为 GC/OF 对于每次度量来说就像是噪音源,所以使用统计数据来提取可靠的结果。
一个注意事项:在我最初编写 Benchmark
时,尝试用清单 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.gc
和 System.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 的类型:服务器(
-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
)。
![]() ![]() |
![]()
|
基准测试是极其困难的。许多因素会影响结果,其中一些因素很微妙。为了获得精确的结果,需要一个全面的解决方案,通过使用基准测试框架可以解决一部分问题。第 2 部分 将介绍一个健壮的 Java 基准测试框架。
学习
- 您可以参阅本文在 developerWorks 全球网站上的 英文原文。
- 本系列的配套网站:在这里可以找到本系列的完整示例代码和其他补充资料。
- How NOT To Write A Microbenchmark:阅读 Cliff Click 在 JavaOne 2002 上的发言稿的 11-22 页。
System
类:System
类的 API 文档,包括它的currentTimeMillis、nanoTime、gc 和 runFinalization
方法。
- Java Glossary
time
entry 和 Millisecond accuracy in Java:表 1 列出的分辨率的来源。
- Clocks and Timers — General Overview:对
System.nanoTime
的详细讨论。
- Accuracy and precision:这篇 Wikipedia 文章解释了这两个术语。
- ThreadMXBean:
ThreadMXBean
接口的 API 文档,包括getCurrentThreadCpuTime
方法。
- Are you really Multi-Core?:了解
getCurrentThreadCpuTime
与基准测试的关系。
- Accurate CPU timing on Windows — How?:这个论坛帖子讨论了
getCurrentThreadCpuTime
的问题。
- ClassLoadingMXBean:ClassLoadingMXBean 的 API 文档,包括它的
getTotalLoadedClassCount
和getUnloadedClassCount
方法。
- Just-in-time compilation:讨论 JIT 编译的 Wikipedia 文章。
- Java Virtual Machine (JVM) — best way to tell when JIT compiling occurs?:讨论如何探测 JIT 编译。
- “ Java 理论与实践: 动态编译与性能测量”(Brian Goetz,developerWorks,2004 年 12 月):了解动态编译造成的去优化和其他基准测试问题。
- The Java HotSpot Performance Engine: On-Stack Replacement Example:解释了 OSR。
- How to stop a compiler:通过这篇博客中 Dec 23, 2007 11:43:10 AM 和 Feb 25, 2008 8:31:53 AM 的跟帖了解 OSR 的一些限制。这篇博客还包含一个 DCE 示例。
- Dead code elimination 和 Unreachable code:这些 Wikipedia 文章解释了 DCE 和不可达代码。
- Virtual Machine (JVM) - dead-code elimination — what are all the rules?:阅读这篇论坛文章,了解编译器识别死代码的标准是 “非常复杂的,而且因 JVM 版本而异,因此不可能清楚地解释”。
- Java SE 6 HotSpot Virtual Machine Garbage Collection Tuning:了解
System.gc
的 stop-the-world 垃圾收集机制。
- CPU cache 和 Memory part 2: CPU caches:了解 CPU 缓存的重要性。
- High Performance Java Technology in a Multi-Core World:了解现代的 JVM 如何优化内存访问。
- Performance of Java versus C++(参见 “Don't characterize the speed of a language based on a single benchmark of a single program” 一节中的图)和 CacheKiller — array size effects in cache performance:讨论数据集的大小如何影响性能。
- 浏览 技术书店 中关于这些主题和其他技术主题的图书。
- developerWorks Java 技术专区:数百篇关于 Java 编程各个方面的文章。
健壮的 Java 基准测试,第 2 部分: 统计和解决方案
程序性能一直是受到关注的问题,即使在现在这样的高性能硬件时代,也是如此。本文是分两部分的文章系列的第二篇,讨论基准测试的统计问题并提供一个框架,可以用这个框架对各种 Java™ 代码进行基准测试,包括自我包含的微基准测试和调用整个应用程序的代码等等。
本系列的 第 1 部分 解释了与 Java 代码基准测试相关联的许多问题。本文讨论另外两个领域。首先,讨论一些有助于克服基准测试中不可避免的度量偏差的统计技术。然后,介绍一个软件基准测试框架并对一系列示例使用这个框架,以说明要点。
如果只需执行一次执行时间度量,然后就可以用单一度量值比较不同代码的性能,那就太方便了。遗憾的是,这种方法虽然很流行,但是很不可靠。有许多因素会导致结果的偏差,所以无法信任单一度量值的精确性。在 第 1 部分 中,我提到过时钟分辨率、复杂的 JVM 行为和自动的资源回收,这些都是噪声源,它们会影响结果的精确性;这些只是能够随机或系统化地影响基准测试的众多因素的一小部分。可以采取一些措施来减轻一些因素的影响;如果有充分的理解,甚至可以执行去卷积(deconvolution)(参见 参考资料)。但是,这些补救措施都不完美,所以最终必须由您来处理偏差。惟一的方法是执行多次度量,然后用统计技术生成可靠的结果。正如 “He who refuses to do arithmetic is doomed to talk nonsense” 中指出的,忽略统计技术是很危险的(参见 参考资料)。
![]() |
|
我在本文中只讨论解决以下这些常见性能问题所需的统计技术:
- 任务 A 是否比任务 B 执行得快?
- 这个结论可靠吗?还是由于度量的误差造成的假像?
如果做多次执行时间度量,那么首先要计算的统计数据可能是一个代表典型值 的单一数字(参考资料 中的一篇 Wikipedia 文章定义了本文涉及的统计学概念)。最常用的计算方法是算术平均值,通常称为平均值 或均值,也就是所有度量值的和除以度量值的数量:
mean x = Summation i=1,n(x i) / n
本文配套网站上的补充资料(参见 参考资料)讨论了除平均值之外的其他统计数据;参见其中的 Alternatives to the mean 一节。
用几个度量值的平均值来量化性能,肯定比使用单一度量值更准确,但这还不能判断哪个任务执行得更快。例如,假设任务 A 的平均执行时间是 1 毫秒,任务 B 的平均执行时间是 1.1 毫秒。能够由此得出任务 A 比任务 B 快的结论吗?如果您知道任务 A 的度量值范围是 0.9 到 1.5 毫秒,任务 B 的度量值范围是 1.09 到 1.11 毫秒,恐怕就不会下这样的结论了。因此,还需要解决度量值的跨度。
描述度量值跨度(或者说漂移)的最常用统计技术是标准偏差(standard deviation):
sd x = sqrt{ Sum i=1,n( [x i - mean x] 2 ) / n }
标准偏差如何量化度量值漂移?它依赖于您对度量值的概率密度函数(probability density function,PDF)的认识。您做的假设越强,得到的结论就越好。本文补充资料的 Relating standard deviation to measurement scatter 一节详细解释了这个概念并得出以下结论:在基准测试上下文中,合理的经验规则是至少 95% 的度量值应该落在平均值的三倍标准偏差的范围内。
那么,如何用平均值和标准偏差判断两个任务中哪一个更快呢?根据上面的经验规则,最简单的情况是两个任务的平均值之差超过了三倍标准偏差(选用两个标准偏差中较大者)。在这种情况下,平均值小的任务显然在大多数时候都更快,见图 1:
图 1. 平均值之差超过三倍标准偏差,这表示可以明确分辨出性能差异
不幸的是,两个任务的重叠部分越大,判断就越困难(例如,如果平均值只相差一个标准偏差),见图 2:
我们可以这样做:根据平均值判断两个任务的性能水平,但是要注意数据的重叠程度并相应地指出结论的可靠程度。
要解决的另一个问题是,这些平均值和标准偏差统计值本身的可靠性如何。显然,这些数据是从度量值计算出来的,所以另一组度量值很可能产生不同的统计值。现在,假设度量过程是有效的。(注意:不可能测量出 “真实的” 标准偏差。补充资料的 Standard deviation measurement issues 一节解释了这个问题)。那么,如果重复执行度量过程,平均值和标准偏差会有多大变化?另一组度量值会产生明显不同的结果吗?
要回答这些问题,最明显的方法是为统计数据构造置信区间(confidence interval)。置信区间并不是统计数据的单一计算值(估值点[point estimate]),而是一个估值范围。与这个范围相关联的概率 p 称为置信水平(confidence level)。在大多数情况下,设 p 为 95%,这个值在置信区间比较期间保持不变。置信区间的意义很直观,因为它们的大小表示可靠性:窄的区间表示统计数据比较精确,宽的区间表示统计数据不太确定。例如,如果任务 A 的平均执行时间的置信区间是 [1, 1.1] 毫秒,任务 B 是 [0.998, 0.999] 毫秒,那么 B 的平均值就比 A 可靠性高,还可以确认它的值比 A 小(在置信水平上)。补充资料的 Confidence intervals 一节详细讨论了这个问题。
在以前,只能针对一些常见的 PDF(比如 Gaussian)和简单的统计数据(比如平均值)计算置信区间。但是,在 20 世纪 70 年代晚期,开发出了一种称为 bootstrapping 的技术。这是一种用来产生置信区间的通用技术。它适用于任何统计数据,而不只是平均值这样的简单数据。另外,非参数化形式的 bootstrapping 不需要对底层 PDF 做任何假设。它不会产生非物理结果(例如,置信区间的下限为负的执行时间);与对 PDF 做出错误假设的情况(比如假设是 Gaussian)相比,bootstrapping 产生更窄更精确的置信区间。对 bootstrapping 的详细讨论超出了本文的范围,但是下面讨论的框架包含一个 Bootstrap
类,这个类会执行 bootstrapping 计算;更多信息参见它的 Javadoc 和源代码(参见 参考资料)。
总之,您必须:
- 执行许多次基准测试度量。
- 根据度量值计算出平均值和标准偏差。
- 使用这些统计数据判断是否可以清楚地区分两个任务的速度(平均值之差超过三倍标准偏差),还是相互重叠。
- 计算平均值和标准偏差的置信区间,以此表示这些数据的可靠性。
- 用 bootstrapping 技术计算置信区间是最好的方法。
![]() ![]() |
![]()
|
到目前为止,我已经讨论了 Java 代码基准测试的一般原理。现在要介绍一个简便的基准测试框架,这个框架解决了前面提到的许多问题。
请从本文的配套网站下载项目的 ZIP 文件(参见 参考资料)。这个 ZIP 文件包含源代码和二进制文件,以及一个简单的构建环境。它包含本文中的所有代码清单。把它的内容解压到任意目录中。在顶层目录中的 readMe.txt 文件中可以找到更多信息。
在这个框架中,主要的类是 Benchmark
。大多数用户只需要使用这个类;其他东西都是起辅助的。API 的基本用法很简单:把要进行基准测试的代码提供给一个 Benchmark
构造函数。然后,就会完全自动地执行基准测试过程。通常只有一个后续步骤,即生成 结果报告。
显然,必须有一些代码,希望对其执行时间进行基准测试。惟一的限制是,代码应该包含在 Callable
或 Runnable
中。除此之外,目标代码可以是能够用 Java 语言表达的任何代码,包括自我包含的微基准测试和调用整个应用程序的代码等等。
把任务编写为 Callable
常常更方便。Callable.call
允许抛出受检查的 Exception
,而 Runnable.run
要求实现 Exception
处理。另外,正如第 1 部分的 消除死代码 一节指出的,与 Runnable
相比,Callable
更容易防止 DCE。把任务编写为 Runnable
的优点是,这可以尽可能降低创建对象和垃圾收集的开销;更多信息参见补充资料的 Task code: Callable versus Runnable 一节。
要获得基准测试结果报告,最容易的方法是调用 Benchmark.toString
方法。这个方法产生一个单行的汇总报告,其中只包含最重要的结果和 警告。通过调用 Benchmark.toStringFull
方法,可以获得详细的多行报告,其中包含所有结果和完整的解释。除此之外,还可以调用 Benchmark
的各种访问函数来生成定制的报告。
Benchmark
会尝试诊断一些常见的问题,如果探测到这些问题,就会在 结果报告 中提出警告。警告包括:
- 度量值太低(Measurements too low):第 1 部分的 资源回收 一节指出,如果
Benchmark
认为度量过程没有充分考虑到垃圾收集和对象终结的成本,它就会提出警告。
- 离群值和序列相关性(Outliers and serial correlation):执行时间度量值属于离群值(outlier) 和序列相关性(serial correlation) 统计测试。离群值说明发生了重大的度量错误。例如,如果在度量期间在计算机上启动了其他活动,就会产生较大的离群值;小的离群值可能暗示出现了 DCE。序列相关性表示 JVM 还没有达到稳定状态(其特征是执行时间有小幅随机波动)。具体地说,正的序列相关性表示一种趋势(向上或向下),负的序列相关性表示平均值反转(例如,执行时间振荡);这些都不是好现象。
- 标准偏差不精确:更多信息参见补充资料的 Standard deviation warnings 一节。
下面用清单 1 中的代码片段演示前面讨论的要点:
清单 1. 对计算第 35 个 Fibonacci 数的过程进行基准测试
public static void main(String[] args) throws Exception { Callable<Integer> task = new Callable<Integer>() { public Integer call() { return fibonacci(35); } }; System.out.println("fibonacci(35): " + new Benchmark(task)); } protected static int fibonacci(int n) throws IllegalArgumentException { if (n < 0) throw new IllegalArgumentException("n = " + n + " < 0"); if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); } |
在清单 1 中,main
把要测试的代码定义为 Callable
,然后提供给 Benchmark
构造函数。Benchmark
构造函数多次执行一个任务,首先确保完成 代码预热,然后收集执行时间统计数据。构造函数返回之后,它所在的代码上下文(即 println
的内部)隐式地调用新的 Benchmark
实例的 toString
方法,这会报告基准测试的汇总统计数据。通常,Benchmark
的使用就这么简单。
在我的配置上,获得了以下结果:
fibonacci(35): first = 106.857 ms, mean = 102.570 ms (CI deltas: -35.185 us, +47.076 us), sd = 645.586 us (CI deltas: -155.465 us, +355.098 us) WARNING: execution times have mild outliers, SD VALUES MAY BE INACCURATE |
下面解释这些结果:
- 第一次调用
fibonacci(35)
时,执行的时间是 106.857 毫秒。
- 执行时间的平均估值点是 102.570 毫秒。平均值的 95% 置信区间相对于估值点波动 -35/+47 微秒,也就是 [102.535, 102.617] 毫秒。这个区间相当窄,所以这个值是可信的。
- 执行时间的标准偏差估值点是 645.586 微秒。标准偏差的 95% 置信区间相对于估值点波动 -155/+355 微秒,也就是 [490.121, 1000.684] 微秒。这个区间相当宽,所以这个值不太可信。实际上,末尾的警告指出标准偏差 度量不精确。
- 结果还指出出现了离群值。在这个示例中,可以不理会这些警告;但是,如果您不放心的话,可以重新运行代码并改用
Benchmark
的toStringFull
方法,这会列出所有统计数据,以便进一步分析。
![]() ![]() |
![]()
|
为了更好地说明基准测试问题以及如何使用 Benchmark
解决它们,下面对几种常用数据结构的访问时间进行基准测试。
只度量一种数据结构的访问时间是一种真正的微基准测试。的确,微基准测试有许多问题。(例如,它们对于考察完整应用程序的性能意义不大)。但是,这是情有可原的,因为它们很难得到精确的结果。这种度量涉及许多有意思的问题(比如缓存的影响),所以适合作为示例。
请考虑清单 2 中的代码,这段代码对数组访问时间进行基准测试(针对其他数据结构的代码是相似的):
protected static Integer[] integers; // values are unique (integers[i] == i) protected static class ArrayAccess implements Runnable { protected int state = 0; public void run() { for (int i = 0; i < integers.length; i++) { state ^= integers[i]; } } public String toString() { return String.valueOf(state); } } |
在理想情况下,这段代码仅仅访问 integers
数组。但是,为了方便地访问 integers
的所有元素,我使用了一个循环,这给基准测试带来了寄生循环开销。注意,我使用的是一个老式的显式 int
循环,而不是更方便的 for (int i : integers)
改进型 for
循环,因为它的速度快一点儿(参见 参考资料)。这里使用的循环应该算是一个缺陷,但是影响不大,因为所有即时(JIT)编译器都会执行循环展开,这会降低循环的影响。
但最为重要的是防止 DCE 所需的代码。首先,选用 Runnable
作为任务类,因为这可以尽可能降低创建对象和垃圾收集的开销,这对于这样的微基准测试很重要。第二,既然这个任务类是 Runnable
,为了防止 DCE,必须把数组访问的结果赋值给 state
字段(以及覆盖的 toString
方法中需要使用的 state
)。第三,对数组访问和以前的 state
值执行位 XOR 计算,以确保执行每个 访问。(如果只执行 state = integers[i]
,聪明的编译器可能会发现可以跳过整个循环,直接执行 state = integers[integers.length - 1]
)。这四个额外操作(字段读、把 Integer
自动转换为 int
、位 XOR、字段写)是不可避免的开销,这些开销会歪曲基准测试的结果:实际上度量的不只是数组访问时间。所有其他数据结构基准测试也有相似的问题,但是对于访问时间相当长的数据结构,这种影响可以忽略。
图 3 和图 4 给出在我常用的桌面配置上对两个不同大小的 integers
的访问时间结果:
图 3. 数据结构访问时间(1024 个元素)
图 4. 数据结构访问时间(1024 × 1024 个元素)
下面对结果做一些说明:
- 这些图只显示平均执行时间的估值点。(平均值的置信区间都很窄,宽度通常小于平均值的千分之一,所以没有在图上显示出来)。
- 所有基准测试都只使用一个线程;同步的类不涉及线程争用。
- 数组元素访问(不同步)代码已经在 清单 2 中给出。
- 数组元素访问(同步)代码基本相同,惟一的差异是循环体是:
synchronized (integers) { state ^= integers[i]; }
- 图 3 和图 4 所示的两个
ConcurrentHashMap
之间的惟一差异是所使用的ConcurrentHashMap
构造函数:第一种情况指定concurrencyLevel = 1
,第二种情况使用默认值(16)。因为所有基准测试都只使用一个线程,所以第一种情况应该会稍微快些。
- 每个基准测试都有一个或多个警告:
- 几乎所有基准测试都有离群值,但是这些离群值都不严重,对结果影响不大。
- 1024 × 1024 个元素的所有结果存在序列相关性。这个问题的影响程度不明确;1024 个元素的所有结果没有序列相关性。
- 标准偏差总是不可能度量的(微基准测试的典型情况)。
这些结果可能符合您的预期:不同步的数组访问是最快的数据结构。ArrayList
的速度处于第二位,几乎与原始数组一样快。(可能是因为服务器 JVM 对底层数组的直接访问进行了出色的内联处理)。它比同步的相似 Vector
快得多。更慢一点儿的数据结构是 HashMap
,然后是 ThreadLocal
(这基本上是一个以当前线程作为键的特殊散列表)。实际上,在这些测试中,HashMap
几乎与 Vector
一样快,这是因为使用 Integer
作为键,而且 hashCode
的实现非常快(仅仅返回 int
值)。接下来是 ConcurrentHashMap
,最后是最慢的结构 TreeMap
。
我还在另一台完全不同的机器上执行了完全相同的基准测试(SPARC-Enterprise-T5220,运行频率 1.167GHz,32GB RAM,使用 SunOS asm03 5.10;使用的 JVM 版本和设置与我的桌面机相同,但是把 Benchmark
配置为度量 CPU 时间而不是流逝时间,因为测试都是单线程的,而 Solaris 支持单线程),但是这里没有给出结果。各种结构的相对结果与上面的结果相同。
在上面的结果中,惟一重大的不正常情况是同步的数组访问时间:我原来认为它们应该与 Vector
差不多,但是却慢了三倍。我猜测其原因可能是一些与锁相关的优化(比如锁省略或锁 biasing)无法进行(参见 参考资料)。在 Azul 的 JVM 中没有出现这种异常情况,而这种 JVM 使用定制的锁优化,这似乎证明了我的猜测是正确的。
另一个比较小的异常情况是,ConcurrentHashMap
的第一种情况只在使用 1024 × 1024 个元素时比第二种情况快,在使用 1024 个元素时实际上略微慢一点儿。这个异常情况可能是由不同数量的表片段的内存放置效应造成的。这种效应在 T5220 机器上不存在(无论使用多少个元素,第一种情况总是比第二种情况快一点儿)。
另一个不正常情况是 HashMap
比 ConcurrentHashMap
快。这两种情况的代码与 清单 2 相似,但是把 state ^= integers[i]
替换为 state ^= map.get(integers[i])
。integers
的元素按顺序出现(integers[i].intValue() == i
)并以相同的次序作为键使用。这说明 HashMap
中的散列预处理函数的顺序缓存位置比 ConcurrentHashMap
更好(因为 ConcurrentHashMap
需要更好的高位分布)。
这就引出了一个有意思的问题:上面给出的结果在多大程度上依赖于按顺序遍历 integers
?内存位置效应是否对于某些数据结构更有利?为了回答这些问题,我重新运行这些基准测试,但是这一次随机选择 integers
的元素,而不是顺序选择。(我用一个软件线性反馈偏移寄存器来生成伪随机值;参见 参考资料。这会给每次数据结构访问增加 3 纳秒的开销)。对于 1024 × 1024 个元素的 integers
,基准测试结果见图 5:
图 5. 数据结构访问时间(1024 × 1024 个元素,随机访问)
与 图 4 相比,HashMap
现在的性能与 ConcurrentHashMap
差不多相同(这符合预期)。TreeMap
的随机访问性能非常差。数组和 ArrayList
数据结构仍然是最好的,但是它们的相对性能变差了(与顺序访问相比,它们的随机访问性能差了大约 10 倍,而 ConcurrentHashMap
的随机访问性能只差了大约 2 倍)。
另一个要点:图 3、图 4 和图 5 显示的是个别 访问时间。例如,在 图 3 中,可以看到访问 TreeMap
中一个元素所用的时间是 80 纳秒多一点。但是,所有任务都与 清单 2 相似;也就是说,每个任务在内部都执行多次数据访问(即循环遍历 integers
的每个元素)。那么,如何从包含几个相同 操作的任务中提取出个别操作的统计数据呢?
可以以 清单 2 为基础,编写出处理这种任务的代码。可以使用与清单 3 相似的代码:
public static void main(String[] args) throws Exception {
int m = Integer.parseInt(args[0]);
integers = new Integer[m];
for (int i = 0; i < integers.length; i++) integers[i] = new Integer(i);
System.out.println(
"array element access (unsynchronized): " + new Benchmark(new ArrayAccess(), m));
// plus similar lines for the other data structures...
} |
在清单 3 中,使用了 Benchmark
构造函数的两参数版本。第二个参数(在清单 3 中用粗体显示)指定任务包含的相同操作 的数量,在这里是 m = integers.length
。更多信息参见补充资料的 Block statistics versus action statistics 一节。
![]() ![]() |
![]()
|
在本文中,到目前为止只考虑了微基准测试。但是,度量真实应用程序的性能才是基准测试真正的用途。
证券投资者可能感兴趣的一个示例是 Markowitz 均值-方差投资组合优化,金融顾问使用这种标准技术建立具有出色的风险/收益比的投资组合(参见 参考资料)。
WebCab Components 公司提供了一个执行这些计算的 Java 库(参见 参考资料)。清单 4 中的代码对 Portfolio v5.0(J2SE Edition)库在求解有效边界(efficient frontier) 时的性能进行基准测试(参见 参考资料):
protected static void benchmark_efficientFrontier( double[][] rets, boolean useCons, double Rf, double scale ) throws Exception { Benchmark.Params params = new Benchmark.Params(false); // false to meas only first params.setMeasureCpuTime(true); for (int i = 0; i < 10; i++) { double[][] returnsAssetsRandomSubset = pickRandomAssets(rets, 30); Callable<Double. task = new EfTask(returnsAssetsRandomSubset, useCons, Rf, scale); System.out.println( "Warmup benchmark; can ignore this: " + new Benchmark(task, params) ); } System.out.println(); System.out.println("n" + "/t" + "first" + "/t" + "sharpeRatioMax"); for (int n = 2; n <= rets.length; n++) { for (int j = 0; j < 20; j++) { // do 20 runs so that can plot scatter double[][] returnsAssetsRandomSubset = pickRandomAssets(rets, n); Callable<Double> task = new EfTask(returnsAssetsRandomSubset, useCons, Rf, scale); Benchmark benchmark = new Benchmark(task, params); System.out.println( n + "/t" + benchmark.getFirst() + "/t" + benchmark.getCallResult()); } } } |
因为本文讨论的是基准测试,而不是投资组合理论,所以我们不用理会 EfTask
内部类的代码。(简单地说,EfTask
获取资产历史回报数据,由此计算预期的回报和协方差,求出有效边界上的 50 个点,然后返回具有最大 Sharpe 比率的那个点;参见 参考资料。这个最佳 Sharpe 比率表示最佳投资组合对于特定资产集的效果如何,由此在风险调整的基础上识别出最佳回报。细节参见示例代码包中的相关源代码文件)。
这段代码的目的是,判断执行时间和投资组合质量随资产数量变化的趋势,这对于投资组合优化可能很有帮助。清单 4 中的 n
循环完成这个判断。
这个基准测试有几个难点。首先,计算花费的时间很长,尤其是在涉及大量资产时。因此,我避免使用 new Benchmark(task)
这样的简单代码(它在默认情况下执行 60 次度量)。而是创建一个定制的 Benchmark.Params
实例,它指定应该只执行一次度量。(它还指定应该进行 CPU 时间度量,而不是默认的流逝时间度量,这只是为了演示 CPU 时间度量。因为 WebCab Components 库在这个上下文中没有创建线程,所以可以这样做)。但是,在执行这一次度量值基准测试之前,i
循环会执行几次基准测试,让 JVM 能够完全完成代码优化。
第二,通常的 结果报告 对于这个基准测试不够精确,所以我生成一个定制的报告,它应该只提供以制表符分隔的数字,这样就很容易复制到电子表格中,供以后绘图时使用。因为只进行一次度量,所以用 Benchmark
的 getFirst
方法获取执行时间。给定资产集的最大 Sharpe 比率是 Callable
任务的返回值。可以通过 Benchmark
的 getCallResult
方法获得这个值。
另外,我希望以图形方式显示结果的漂移,所以对于给定的资产集,内部的 j
循环执行每个基准测试 20 次。这样的话,对于每个资产数量,在下图中生成 20 个点。(在某些情况下,一些点重叠在一起,所以看起来只有几个点)。
看一下结果。我使用的资产是当前 OEX(S&P 100)索引中的股票。使用过去 3 年(2005 年 1 月 1 日到 2007 年 12 月 31 日)的每周资本收益作为历史回报数据;忽略分红(如果包含分红,会略微提高 Sharpe 比率)。
图 6 是执行时间随资产数量变化的图形:
所以,执行时间随资产数量的三次方增长。这些度量值的漂移是真实的,不是基准测试错误:投资组合优化的执行时间依赖于考虑的资产类型。尤其是,某些协方差类型需要非常仔细(步长很小)的数字计算,这会影响执行时间。
图 7 是投资组合质量(最大 Sharpe 比率)随资产数量变化的图形:
最大 Sharpe 比率最初上升,但是不久就在大约 15 到 20 种资产处停止增加。在此之后,随着资产数量的增加,惟一的效果是最大值的范围逐渐收窄。这个效果也是真实的:这是因为随着资产数量的增加,投资组合中包含所有 “热门” 资产(在最佳投资组合中占据优势地位的资产)的可能性逐渐接近 100%。更多信息参见补充资料的 Portfolio optimization 一节。
![]() ![]() |
![]()
|
微基准测试应该反映真实用例。例如,我选择测试数据结构的访问时间,这是因为在设计 JDK 集合时假设典型的应用程序包含 85% 的读/遍历、14% 的添加/更新和 1% 的删除操作。但是,如果各种操作的比例发生变化,相对性能就会有很大变化。另一个危险因素是复杂的类层次结构:微基准测试常常使用简单的类层次结构,但是在复杂的类层次结构中方法调用开销会显著增加(参见 参考资料),所以精确的微基准测试必须反映真实应用程序中的情况。
要确保基准测试结果是相关的。提示:针对 StringBuffer
和 StringBuilder
的微基准测试不可能说明 Web 服务器的性能怎么样;更高级的体系结构选择的影响可能显著得多,而这些选择不适合进行微基准测试。(但是,在几乎所有代码中都应该使用 ArrayList/HashMap/StringBuilder
,而不是老式的 Vector/Hashtable/StringBuffer
)。
最好不要仅仅依赖于微基准测试的结果。例如,如果希望测试一种新算法的性能,那么不但要在基准测试环境中进行度量,还应该在真实应用程序场景中度量,了解它是否会产生显著的性能差异。
显然,只有在测试了合理的计算机和配置样本之后,才能做出具有普遍意义的性能结论。不幸的是,常常只在开发机器上执行基准测试,并假设在所有机器上都能够得出相同的结论。如果希望彻底测试,就需要多种硬件,甚至不同的 JVM(参见 参考资料)。
不要忽视剖析。应该用能够找到的各种剖析工具运行基准测试,从而确认其行为符合预期(例如,大多数执行时间应该花在您认为的关键方法上)。这还可以确认 DCE 没有歪曲测试结果。
最后,要想得出正确的性能结论,就必须了解低层的操作执行方式。例如,如果希望了解 Java 语言的正弦实现(Math.sin
)是否比 C 的实现快,您可能会发现在 x86 硬件上 Java 的正弦实现慢得多。这是因为 Java 避免使用快速但不精确的 x86 硬件辅助指令。不了解这个原理的人可能认为 C 比 Java 快得多,而实际情况是一个专用(但不精确)的硬件指令比一个精确的软件计算快。
![]() ![]() |
![]()
|
基准测试常常需要执行许多次度量,并使用统计技术解释结果。本文提供的基准测试框架支持这些特性并解决了许多其他问题。无论您是使用这个框架,还是按照本系列提供的信息创建自己的框架,都可以更好地执行基准测试,确保 Java 代码能够高效地执行。
学习
- 您可以参阅本文在 developerWorks 全球网站上的 英文原文。
- 本系列的配套网站:在这里可以找到本系列的完整示例代码和其他补充资料。
- 关于统计的 Wikipedia 文章:了解关于 deconvolution、typical value、arithmetic mean、measurement spread、standard deviation、confidence intervals、probability density functions、bootstrapping、outliers 和 serial correlation 的更多信息。
- “He who refuses to do arithmetic is doomed to talk nonsense”:引述了计算机科学家 John McCarthy 的许多精彩观点。
- Use Enhanced For Loop Syntax With Caution:增强的
for
循环在嵌入式开发中的问题。
- “ Java 理论与实践: Mustang 中的同步优化g”(Brian Goetz,developerWorks,2005 年 10 月):解释了锁省略(和其他概念)。
- “Biased Locking in HotSpot”(David Dice 的博客,2006 年 8 月):了解 HotSpot 中的偏向锁方案。
- Linear feedback shift register:关于这个工程概念的 Wikipedia 文章。
- Modern portfolio theory:这篇 Wikipedia 文章解释了 Markowitz 均值方差投资组合优化、有效边界和其他投资组合分析概念。
- WebCab Portfolio v5.0(J2SE Edition)库:提供本文中一些示例所测试的组件。
- Sharpe ratio:介绍这种测量方法的 Wikipedia 文章。
- “Polymorphism Performance Mysteries Explained”(Heinz M. Kabutz 博士,The Java Specialists' Newsletter,2001 年 4 月):了解方法调用的开销为什么在复杂的类层次结构中会变得很大。
- Sun、IBM 和 BEA 的 JVM:应该在多种平台上执行全面的 Java 基准测试。
- 浏览 技术书店 中关于这些主题和其他技术主题的图书。
- developerWorks Java 技术专区:数百篇关于 Java 编程各个方面的文章。