|
众所周知,软件工程师常常受到性能问题的困扰,有时候甚至很过分。虽然有时候性能在一个软件项目中是最重要的需求,例如在为高速交换机开发协议路由软件时便是如此,但在大多数情况下,需要在性能需求与其他需求之间进行平衡,例如功能性、可靠性、可维护性、可扩展性、投入市场的时间以及其他业务和工程上的考虑。 即使性能不是当前项目的一个关键需求,甚至没有被标明为一个需求,通常也难于忽略性能问题,因为您可能会认为忽略性能问题将使自己成为“差劲的工程师”。开发人员在以编写高性能代码为目标的时候,常常会编写小的基准程序来度量一种方法相对于另一种方法的性能。不幸的是,正如您在 December 撰写的 "动态编译与性能测量" 这期文章中所看到的,与其他静态编译的语言相比,评论用 Java 语言编写的给定惯用法(idiom)或结构体的性能要困难得多。 一个有缺陷的微基准 清单 1. 有缺陷的 SyncLockTest 微基准 interface Incrementer { void increment(); } class LockIncrementer implements Incrementer { private long counter = 0; private Lock lock = new ReentrantLock(); public void increment() { lock.lock(); try { ++counter; } finally { lock.unlock(); } } } class SyncIncrementer implements Incrementer { private long counter = 0; public synchronized void increment() { ++counter; } } class SyncLockTest { static long test(Incrementer incr) { long start = System.nanoTime(); for(long i = 0; i < 10000000L; i++) incr.increment(); return System.nanoTime() - start; } public static void main(String[] args) { long synchTime = test(new SyncIncrementer()); long lockTime = test(new LockIncrementer()); System.out.printf("synchronized: %1$10d\n", synchTime); System.out.printf("Lock: %1$10d\n", lockTime); System.out.printf("Lock/synchronized = %1$.3f", (double)lockTime/(double)synchTime); } } SyncLockTest 定义了一个接口的两种实现,并使用 System.nanoTime() 来计算每种实现运行 10,000,000 次的时间。在保证线程安全的情况下,每种实现增加一个计数器;其中一种实现使用内建的同步,而另一种实现则使用新的 ReentrantLock 类。此举的目的是回答以下问题:“哪一个更快,同步还是 ReentrantLock?”让我们看看为什么这个表面上没有问题的基准最终没能成功地度量出想要度量的东西,甚至没有度量出任何有用的东西。 构想上的缺陷 暂时先不谈实现上的缺陷, SyncLockTest 首先从构想上就存在缺陷 —— 它误解了它要回答的问题。这个基准的目的是要度量同步和 ReentrantLock 的性能代价,它们是用于协调多个线程的行为的不同技术。然而,该测试程序只包含一个线程,因而显然不存在竞争。它没有首先测试那些真正与锁相关的场景! 在早期的 JVM 实现中,无竞争的同步比较慢,这是众所周知的。然而,从那以后无竞争的同步的性能从本质上已经有了很大的提高。(请参阅参考资料中列出的描述 JVM 用来优化无竞争同步性能的一些技术的文章)。另一方面,有竞争的同步比起无竞争同步来仍然要慢得多。当一个锁处于争用状态下时,JVM 不但要维护一个等待线程队列,而且还必须使用系统调用来阻塞和消除阻塞不能立即得到锁的线程。而且,在高度竞争环境下的应用程序表现出来的吞吐量通常会更低,这不仅是因为花在调度线程上的时间更多了,花在做实际工作上的时间更少了,而且当线程为了等待某一个锁而被阻塞时,CPU 可能处于空闲状态。用来度量同步性能的基准应该考虑实际的竞争程度。 方法上的缺陷 除了设计上的失败,在执行方面至少也有两大败笔 —— 它只在单处理器系统(对于高并发性程序来说,这是一种不寻常的系统,其同步性能与在多处理器系统上可能有本质上的差别)上,并且只在一个平台上执行。在测试一个给定的原语或惯用语的时候,特别是与底层硬件交互很多的原语或惯用语时,在得出关于性能方面的结论之前,需要在很多平台运行基准。当测试像并发这样复杂的东西时,为了得到给定惯用语的总体性能情况,建议采用十来种不同的系统,应用多个处理器(更不用说内存配置和处理器的代数(generation)了)。 实现上的缺陷 至于实现方面,SyncLockTest 忽略了动态编译的很多方面。在12 月份的文章中可以看到,HotSpot JVM 首先以解释的方式执行代码路径,然后在经过一定量的执行后,才将其编译成机器代码。如果没有让 JVM 适当地“热身”,那么 JVM 可能在两个方面导致性能度量上的偏差。首先,测试的运行时间当中包含了 JIT 用于分析和编译代码路径所花的时间。最重要的是,如果编译是在测试运行的过程当中进行的,那么测试结果就变成一定量的解释执行,加上 JIT 编译时间,再加上一定量的优化执行的总时间和,这些并不能让您清楚代码的真正性能。而且,如果在运行测试之前代码没有经过编译,在测试的过程当中也没有进行编译,那么整个测试运行都需要解释,这样就不能体现所要测试的惯用语的真正性能。 SyncLockTest 还沦为在12 月份的文章中所讨论的内联(inlining)和反优化(deoptimization)问题的牺牲品,在这些篇文章中,第一个计时度量的是那些已经与单一调用转换(monomorphic call transformation)内联的代码,而第二个计时所度量的代码,由于 JVM 要装载另一个扩展相同基类或接口的类,因而经过了反优化。当使用 SyncIncrementer 的一个实例来调用计时测试方法时,运行库将认为只装载了一个实现 Incrementer 的类,并且会把对 increment() 的虚方法调用转换为对 SyncIncrementer 的调用。然后,当使用 LockIncrementer 的一个实例调用计时测试方法时,test() 将被重新编译成使用虚方法调用,这意味着与第一个计时相比,通过 test() 来管理方法的第二个计时在每次迭代中要做更多的工作,就好像把测试变成了苹果与橙子之间的比较。这样做会严重扭曲结果,致使无论哪种基准首先执行,看起来都会更快些。 基准代码看上去并不像实际中的代码 通过合理地重写代码,引入一些测试参数(例如竞争程度),并在更多类型的系统中、给测试参数赋予多种不同的值来运行代码,前面所讨论的那些缺陷是可以更正的。但是,对于方法上的一些缺陷,不管如何挽回,都是无法解决的。如果想知道为什么,就应该像 JVM 那样去思考,理解在编译 SyncLockTest 的时候会发生哪些情况。 Heisenbenchmark 原则 编写用于度量一个语言原语(例如同步)的性能的微基准的过程实际上是与 Heisenberg 原则作斗争的过程。您想要度量操作 X 有多快,所以除了 X 外您不想做其他任何事。但是,这样做得到的往往是一个不做任何事的基准,在您不知情的情况下,编译器可能将此操作部分地或者完全地优化掉,使得测试运行起来比预期更快。如果在基准中加入无关的代码 Y,那么现在度量的就是 X+Y 的性能,更糟糕的是,由于 Y 的存在,现在 JIT 优化 X 的方式又发生了变化。如果没有足够的额外填充物和数据流依赖,编译器可能会将整个程序优化至无形,但是如果填充物太多,那么真正需要度量的东西又会迷失在噪音当中,因此要编写一个良好的微基准,就意味着要抓住二者之间微妙的平衡。 因为运行时编译使用概要数据来指导优化,所以 JIT 对测试代码的优化可能不同于对实际代码的优化。对于所有的基准,都存在这样一个很大的风险,即编译器能够优化掉整个基准,因为它将(正确地)认识到基准代码实际上没有做任何事情,或者没有产生任何有用的结果。在编写有效的基准时,要求我们能够“愚弄”编译器,即使它认识到代码没有用处,也不能让它将代码砍掉。在 Incrementer 类中使用计数器变量骗不到编译器,在删除无用代码方面我们对编译器给予了信任,但编译器比我们想象的还要聪明。 此外,还有一个问题是,同步是一种内建的语言特性。JIT 编译器可以随意变动同步锁,以减少它们的性能成本。在某些情况下,同步可能被完全消除,并且在同一个监视器上,同步的邻近同步锁可能被合并。如果我们要度量同步的成本,这些优化实际上害了我们,因为我们不知道有多少同步会被优化掉(在这个例子中,很可能是全军覆没!)。更糟糕的是,JIT 对于 SyncTest.increment() 中不做事的代码的优化与对实际中的程序的优化在方式上有很大的不同。 更糟的还在后面。这个微基准表面上的目的是测试同步与 ReentrantLock 哪个更快。由于同步是内建在语言中的,而 ReentrantLock 是一个普通的 Java 类,编译器对于不做事的同步的优化与对于不做事的 ReentrantLock 的优化在方式上又有不同。这样的优化会使不做事的同步看上去更快些。编译器对此二者的优化方式存在差别,加上对基准和对实际代码的优化方式也是不相同的,因此程序的结果几乎无法告诉我们实际情况下两者在性能上存在的差别。 无用代码的消除在12 月份的文章中,我讨论了基准中无用代码的消除问题 —— 由于基准常常不做有用的事,因此编译器可能会整块地砍掉基准代码,从而歪曲了对执行时间的度量。基准在很多方面都存在这样的问题。虽然编译器消除无用代码这件事对我们要做的事还不一定会造成致命打击,但这里的问题是,编译器对于两种代码路径可以执行不同程度的优化,这从根本上 | |
| 初学者入门:软件测试从零开始 深入浅出基于Java的代理设计模式 杂谈小议:如何制定软件项目测试计划 IT 架构和应用程序的端到端测试 跳出程序员的视界 感悟软件测试 如何制定软件项目测试计划 测试:确保软件质量的最重要一环 软件质量:如何提高软件的可测试性 使用RUP统一过程构建Web解决方案 性能的测试:软件测试的重中之重 |
| 文章评论 | |||