首页 » Java程序员修炼之道 » Java程序员修炼之道全文在线阅读

《Java程序员修炼之道》6.6 HotSpot的JIT编译

关灯直达底部

正如我们在第1章所讲,Java是一种“动态编译”语言。也就是说在程序运行时,其中的类还会再进行一次编译,然后转换成机器码。

这个过程称为即时编译或JITing,并且通常是一次处理一个方法。要在庞大的代码库中找出其中的重要部分,理解这个过程是关键。

下面是一些与JIT编译有关的基本事实。

  • 几乎所有现代JVM中都有某种JIT编译器。
  • 相比较而言,纯粹解释型的VM要慢得多。
  • 编译过的方法在运行速度上要比解释型的代码快很多,非常多。
  • 先编译用得最多的方法,这是有道理的。
  • 在做JIT编译时,先处理唾手可得的编译很重要。

按照最后一点,我们应该先研究编译过的代码,因为在正常情况下,所有仍然处于解释状态下的方法都没有已经编译过的方法运行频繁。偶尔会有无法编译的方法,但非常罕见。

方法一开始都是以字节码形态存在的,有调用时JVM只会对字节码进行解释并执行,同时记录方法被调用的次数及其他一些统计数据。当被调用次数达到某个阈值(默认10 000次)后,如果它是合格的方法,就会有个JVM线程在后台把它的字节码编译成机器码。如果编译成功,以后所有对该方法的调用都会用它的编译结果,除非出现了某些导致检验失效的情况,或者出现了逆优化1。

1 JVM的动态优化技术可能会基于一些大胆(甚至不安全)的假设来编译字节码。比如假定要处理的数据都属于某一类,而在编译结果中只保留处理该类数据的程序分支。如果假设不成立,则JVM只能放弃编译结果,回去解释并执行原来的字节码,这一过程被称为逆优化。——译者注

根据实际情况,方法编译后产生的机器码运行速度可能比解释模式下的字节码快100倍。改善性能通常都要先弄明白程序中哪些方法比较重要,以及哪些重要的方法被编译了。

为什么要动态编译?

有时人们会问,Java平台为什么要费心去做动态编译——为什么不提前编译好(像C++一样)。第一个答案通常都是:因为用平台无关的东西(.jar和.class文件)作为基本部署单位要比为每个目标平台做一份不同的编译好的二进制文件更轻松。

另外一种答案是动态编译会给编译器提供更多信息。具体地说,提前(AOT)编译的语言得不到运行时的任何信息——比如某个指令是否可用,其他的硬件细节以及代码运行情况的统计数据。这些变数让事情变得很有趣,使得Java这样的动态编译语言实际上可能会比提前编译的语言运行得更快。

在接下来对JITing机制的讨论中,我们所说的JVM特指HotSpot。后续讨论中很多通用内容也适用于其他VM,但在具体细节上可能会有很大出入。

我们会先介绍一下HotSpot提供的几个JIT编译器,然后解释HotSpot中最有力的两项优化技术(内联和独占派发)。在本节的结尾,我们会告诉你如何打开方法编译日志,以便你可以看到被编译的确切方法。下面有请HotSpot。

6.6.1 介绍HotSpot

Oracle收购Sun时拿到了HotSpot VM(原来收购BEA时还拿到一个JRockit)。HotSpot是OpenJDK的基础。它有两种运行模式——客户端模式和服务器端模式。可以在启动JVM时指定-client-server选项来选择不同的模式。(必须是命令行中的第一个选项。)每种模式都有各自适用的应用程序。

1.客户端编译器

客户端编译器主要用于GUI应用程序。在这个领域中,操作的一致性至关重要,所以客户端编译器(有时叫C1)在编译时所做的决定往往更保守。也就是说它不能因为要取消一个经证实不正确或基于错误假设的优化决定而意外暂停。

2.服务器端编译器

相反,服务器端编译器(C2)在编译时会大胆假设。为了确保代码正确运行,C2会快速地做一次运行时检查(通常被称为警戒条件),以确保假设有效。如果假设无效,它会取消这次编译,并尝试别的编译。这种大胆假设的方式比保守的客户端编译器产生的编译结果性能好很多。

3,.实时Java

近年来出现了一种实时Java平台,有些开发人员好奇为什么那些需要表现出高性能的代码不直接用这个平台(它是独立的JVM,不是HotSpot选件)。那是因为实时系统不一定是最快的。

实时编程的关注点实际上是承诺能否兑现。从统计角度讲,实时系统是为了让执行操作的时间尽量保持一致,并且为了达成这个目的,它可能会牺牲一些平均等待时间。为了让运行状况保持一致,整体性能是可以受到轻微影响的。

图6-11中有两组代表等待时间的点阵。系列2(上面那组点阵)的平均等待时间在增长(因为它的等待时间刻度更高),但方差在减小,因为这些点比系列1中的点更靠近自己的平均值,系列1的点阵相较而言分布更加广泛。

图6-11 方差和均值的变化

但希望实现高性能表现的团队想要的是更低的平均等待时间,即便以更高的方差为代价,所以他们通常会选择服务器端编译器的大胆优化策略(对应系列1)。

接下来我们会讨论所有运行时(服务器端、客户端和实时)广泛采用的技术,这项技术使它们表现得更好。

6.6.2 内联方法

内联是HotSpot的最大卖点之一。内联的方法不再是被调用,而是将调用方法的代码直接放到调用者内部。

平台有这方面的优势,编译器可以根据运行时的统计数据(方法的调用频率)和其他因素(比如会不会因为调用者方法太多而对代码缓存产生影响)来决定如何处理内联。也就是说HotSpot编译器所做的内联决策比提前编译的编译器更智能。

方法的内联是完全自动的,并且默认参数值几乎适用于任何情况。但也有选项可以用来控制内联方法大小,以及方法在成为内联候选之前的调用频率要达到多高。对于好奇的程序员来说,这些选项对于深入了解内联如何工作很有帮助。通常它们对于生产环境下的代码用处不大,并且应该作为性能调优的最后选择,因为它们对运行时系统的性能可能存在不可预测的影响。

访问器方法怎么处理?

有些开发人员错误地认为访问器方法(访问私有变量的公共方法)不能由HotSpot内联。他们认为变量是私有的,方法调用不能因为优化而去掉,不能在类外访问这个变量。这种想法不对。HotSpot把方法编译成机器码时能够并且会忽略访问控制,不用访问器方法直接访问私有域。这并不违背Java的安全模型,因为所有访问控制都在类加载和连接阶段检查过。

如果你还不信,可以做个练习,写一个跟代码清单6-2类似的测试类,对比一下预热过的访问器方法的速度和直接访问公共域的速度。

6.6.3 动态编译和独占调用

独占调用就是这种大胆优化的例子之一。它是基于大量观察做出的优化,像下面这种对象上的方法调用:

MyActualClassNotInterface obj = getInstance;obj.callMyMethod;  

只会在一种类型的对象上调用。换句话说,就是调用点obj.callMyMethod几乎不会同时碰到一个类和它的子类。这时可以把Java方法查找替换为callMyMethod编译结果的直接调用。

提示 独占派发提供了一个剖析JVM运行时的例子,允许Java平台进行C++这种AOT语言实现不了的优化。

出于非技术的原因,getInstance方法有时不能返回MyActualClassNotInterface类型的对象,而其他情况下不能返回一些子类的对象,但实际上这种情况几乎从没发生过。但为了防止这种情况出现,会有一个运行时检查来确保对象的类型是由编译器按预期插入的。如果这个预期被违背,运行时会取消优化,程序甚至都不会注意也不会犯任何错误。

只有服务器端编译器才会做这种大胆的优化。实时和客户端编译器都不会这样做。

6.6.4 读懂编译日志

我们来看一个例子,了解一下如何使用JIT编译日志。依巴谷星表中详细列出了从地球上可以观测到的星星。我们的程序会处理这个目录,产生能在指定夜晚、指定地址看到的星图。

我们来看这个程序输出的一些日志,看看在星图应用运行时编译了哪些方法。我们用的关键选项是-XX:+Print Compilation。我们前面简单讨论过这个扩展选项。把这个选项加到启动JVM的命令里是告诉JIT编译线程输出标准日志。这些日志表明方法超过编译阈值并被转成机器码的时间。

1        java.lang.String::hashCode (64 bytes)2        java.math.BigInteger::mulAdd (81 bytes)3        java.math.BigInteger::multiplyToLen (219 bytes)4        java.math.BigInteger::addOne (77 bytes)5        java.math.BigInteger::squareToLen (172 bytes)6        java.math.BigInteger::primitiveLeftShift (79 bytes)7        java.math.BigInteger::montReduce (99 bytes)8        sun.security.provider.SHA::implCompress (491 bytes)9        java.lang.String::charAt (33 bytes)1% !     sun.nio.cs.SingleByteDecoder::decodeArrayLoop @ 129 (308 bytes)...39       sun.misc.FloatingDecimal::doubleValue (1289 bytes)40       org.camelot.hipparcos.DelimitedLine::getNextString (5 bytes)41 !     org.camelot.hipparcos.Star::parseStar (301 bytes)...2% !     org.camelot.CamelotStarter::populateStarStore @ 25 (106 bytes)65 s     java.lang.StringBuffer::append (8 bytes) 

这是非常典型的PrintCompilation输出。这些日志表明了“热”到可以编译的方法。跟你想的一样,第一个被编译的方法很可能是平台方法(比如String#hashCode)。再过一段时间,应用方法(比如org.camelot.hipparcos.Star#parseStar方法,在例子中用于分析天文目录里的记录)也会被编译。

这些输出中每行都有个数字,表明了这些方法在这次运行中的编译顺序。注意,由于平台的动态性质,这个顺序在每次运行时可能会稍有变化。这里还有一些其他域。

  • s——表明该方法是同步的。
  • !——表明方法有异常处理。
  • %——当前栈替换(OSR)。这个方法被编译了,并且换掉了运行代码中的解释型版本。注意,OSR方法有它们自己的计数方案,从1开始。