动态编译情况下指标评测的风险
级别: 初级 |
首席咨询师, Quiotix
2004 年 12 月
为动态编译的语言(例如 Java)编写和解释性能评测,要比为静态编译的语言(例如 C 或 C++)编写困难得多。在这期的 Java 理论与实践 中,Brian Goetz 介绍了动态编译使性能测试复杂的诸多原因中的一些。请在本文附带的讨论组上与作者和其他读者分享您对本文的看法。 (您也可以选择本文顶部或底部的 讨论 访问论坛。)
这个月,我着手撰写一篇文章,分析一个写得很糟糕的微评测。毕竟,我们的程序员一直受性能困扰,我们也都想了解我们编写、使用或批评的代码的性能特征。当我偶然间写到性能这个主题时,我经常得到这样的电子邮件:“我写的这个程序显示,动态 frosternation 要比静态 blestification 快,与您上一篇的观点相反!”许多随这类电子邮件而来的所谓“评测“程序,或者它们运行的方式,明显表现出他们对于 JVM 执行字节码的实际方式缺乏基本认识。所以,在我着手撰写这样一篇文章(将在未来的专栏中发表)之前,我们先来看看 JVM 幕后的东西。理解动态编译和优化,是理解如何区分微评测好坏的关键(不幸的是,好的微评测很少)。
动态编译简史
Java 应用程序的编译过程与静态编译语言(例如 C 或 C++)不同。静态编译器直接把源代码转换成可以直接在目标平台上执行的机器代码,不同的硬件平台要求不同的编译器。 Java 编译器把 Java 源代码转换成可移植的 JVM 字节码,所谓字节码指的是 JVM 的“虚拟机器指令”。与静态编译器不同,javac 几乎不做什么优化 在静态编译语言中应当由编译器进行的优化工作,在 Java 中是在程序执行的时候,由运行时执行。
第一代 JVM 完全是解释的。JVM 解释字节码,而不是把字节码编译成机器码并直接执行机器码。当然,这种技术不会提供最好的性能,因为系统在执行解释器上花费的时间,比在需要运行的程序上花费的时间还要多。
即时编译
对于证实概念的实现来说,解释是合适的,但是早期的 JVM 由于太慢,迅速获得了一个坏名声。下一代 JVM 使用即时 (JIT) 编译器来提高执行速度。按照严格的定义,基于 JIT 的虚拟机在执行之前,把所有字节码转换成机器码,但是以惰性方式来做这项工作:JIT 只有在确定某个代码路径将要执行的时候,才编译这个代码路径(因此有了名称“即时 编译”)。这个技术使程序能启动得更快,因为在开始执行之前,不需要冗长的编译阶段。
JIT 技术看起来很有前途,但是它有一些不足。JIT 消除了解释的负担(以额外的启动成本为代价),但是由于若干原因,代码的优化等级仍然是一般般。为了避免 Java 应用程序严重的启动延迟,JIT 编译器必须非常迅速,这意味着它无法把大量时间花在优化上。所以,早期的 JIT 编译器在进行内联假设(inlining assumption)方面比较保守,因为它们不知道后面可能要装入哪个类。
虽然从技术上讲,基于 JIT 的虚拟机在执行字节码之前,要先编译字节码,但是 JIT 这个术语通常被用来表示任何把字节码转换成机器码的动态编译过程 即使那些能够解释字节码的过程也算。
HotSpot 动态编译
HotSpot 执行过程组合了编译、性能分析以及动态编译。它没有把所有要执行的字节码转换成机器码,而是先以解释器的方式运行,只编译“热门”代码 执行得最频繁的代码。当 HotSpot 执行时,会搜集性能分析数据,用来决定哪个代码段执行得足够频繁,值得编译。只编译执行最频繁的代码有几项性能优势:没有把时间浪费在编译那些不经常执行的代码上;这样,编译器就可以花更多时间来优化热门代码路径,因为它知道在这上面花的时间物有所值。而且,通过延迟编译,编译器可以访问性能分析数据,并用这些数据来改进优化决策,例如是否需要内联某个方法调用。
为了让事情变得更复杂,HotSpot 提供了两个编译器:客户机编译器和服务器编译器。默认采用客户机编译器;在启动 JVM 时,您可以指定 -server 开关,选择服务器编译器。服务器编译器针对最大峰值操作速度进行了优化,适用于需要长期运行的服务器应用程序。客户机编译器的优化目标,是减少应用程序的启动时间和内存消耗,优化的复杂程度远远低于服务器编译器,因此需要的编译时间也更少。
HotSpot 服务器编译器能够执行各种样的类。它能够执行许多静态编译器中常见的标准优化,例如代码提升( hoisting)、公共的子表达式清除、循环展开(unrolling)、范围检测清除、死代码清除、数据流分析,还有各种在静态编译语言中不实用的优化技术,例如虚方法调用的聚合内联。
持续重新编译
HotSpot 技术另一个有趣的方面是:编译不是一个全有或者全无(all-or-nothing)的命题。在解释代码路径一定次数之后,会把它重新编译成机器码。但是 JVM 会继续进行性能分析,而且如果认为代码路径特别热门,或者未来的性能分析数据认为存在额外的优化可能,那么还有可能用更高一级的优化重新编译代码。JVM 在一个应用程序的执行过程中,可能会把相同的字节码重新编译许多次。为了深入了解编译器做了什么,请用 -XX:+PrintCompilation 标志调用 JVM,这个标志会使编译器(客户机或服务器)每次运行的时候打印一条短消息。
栈上(On-stack)替换
HotSpot 开始的版本编译的时候每次编译一个方法。如果某个方法的累计执行次数超过指定的循环迭代次数(在 HotSpot 的第一版中,是 10,000 次),那么这个方法就被当作热门方法,计算的方式是:为每个方法关联一个计数器,每次执行一个后向分支时,就会递增计数器一次。但是,在方法编译之后,方法调用并没有切换到编译的版本,需要退出并重新进入方法,后续调用才会使用编译的版本。结果就是,在某些情况下,可能永远不会用到编译的版本,例如对于计算密集型程序,在这类程序中所有的计算都是在方法的一次调用中完成的。重量级方法可能被编译,但是编译的代码永远用不到。
HotSpot 最近的版本采用了称为栈上(on-stack)替换 (OSR) 的技术,支持在循环过程中间,从解释执行切换到编译的代码(或者从编译代码的一个版本切换到另一个版本)。
那么,这与评测有什么关系?
我向您许诺了一篇关于评测和性能测量的文章,但是迄今为止,您得到的只是历史的教训和 Sun 的 HotSpot 白皮书的老调重谈。绕这么大的圈子的原因是,如果不理解动态编译的过程,就不可能正确地编写或解释 Java 类的性能测试。(即使深入理解动态编译和 JVM 优化,也仍然是非常困难的。)
为 Java 代码编写微评测远比为 C 代码编写难得多
判断方法 A 是否比方法 B 更快的传统方法,是编写小的评测程序,通常叫做微评测。这个趋势非常有意义。科学的方法不能缺少独立的调查。魔鬼总在细节之中。为动态编译的语言编写并解释评测,远比为静态编译的语言难得多。为了了解某个结构的性能,编写一个使用该结构的程序一点也没有错,但是在许多情况下,用 Java 编写的微评测告诉您的,往往与您所认为的不一样。
使用 C 程