JVM优化系列-第二部分-编译器

作者:Eva Andreasson    原文链接  译者:sooerr   校对:赵峰

JVM性能优化系列中,第二篇章里Java编译器是主要部分。Eva Andreasson介绍了不同类型的编译器,并且就客户端、服务器端、分层编译进行了性能对比。她也总结了JVM优化的一些概况,例如死代码的消除、代码内嵌和循环优化。

Java编译器是Java著名的跨平台特性的根源。软件开发者写出了一个理想的Java应用程序,在编写高效、平稳的代码的背后,正是编译器可以保证其运行在潜在的目标平台。不同种类的编译器可以满足多种应用程序的需要,产生出具体期望的性能测试结果。你对编译器了解的越多,例如工作原理和可用种类,那么你将更具备Java应用程序调优的能力。

JVM调优系列的第二篇文章展示并说明了各种虚拟机编译器的不同点。同时,我也会讨论使用Just-In-Time (JIT) 编译器可以实现的共同的优化点。(JVM总揽和介绍请看本系列第一部分”JVM性能调优PART1″)

编译器是什么?

简而言之,编译器就是将编程语言作为一种输入,然后生产出一种可执行语言。大家都知道这个工具便是javac了,所有标准JDK都包含它。 javac将Java代码作为输入,将其翻译为字节码(JVM可执行语言)。字节码存储在”.class”文件之中,当java程序启动时,这些文件将被载入到java运行时环境。

字节码不能直接被标准CPU读取,它需要被转译为指令语言,以便底层的执行平台能够解读。JVM中负责将字节码转译为可执行平台命令的组件是另外一个编译器。有些JVM编译器可以处理多个级别的转译,例如,有一个编译器,在字节码最终被转译为实际执行机器的命令之前,可以创建不同级别的字节码中间表示形式。

字节码和JVM

如果读者想了解更多字节码知识,请阅读Bytecode basics” (Bill Venners, JavaWorld)。

就一个平台来说,从不可知观点看,我们希望尽可能的保持代码的平台独立化,以便于最后一层的转译处理——从最低级的表达到真正的机器代码——可以作为在具体某平台处理器构架上执行的有力控制点。最高级别的层级划分应该是在静态与动态编译器之间。这样,我们就可以根据执行环境、期望的性能测试结果、有限的资源作出我们的选择。在Part1我已经简要的论述了静态和动态编译器。接下来的部分,我将会解释更多内容。

静态编译 vs 动态编译

静态编译器的典型案例就是之前提到过的javac。通过静态编译器,输入的代码会被进行一次解析,输出的可执行格式文件在程序执行的时候会被使用。除非你改变源程序并且重新编译代码,不然之前所输出的代码的执行结果将不会被改变。因为此类输入是一种静态输入,并且编译器是静态编译器。

静态编译,以下是Java代码

[code]
staticint add7(int x){
return x+7;
}
[/code]

将产生出一些相似的字节码:

[code]
iload0
bipush 7
iadd
ireturn
[/code]

动态编译器可以动态的将一种语言转译为另一种语言,这意味着转译的过程伴随着代码的执行,在运行时!动态编译和优化给运行时带来了一个优势,即在应用载入中,运行时环境能够及时对变化作出调整。动态编译器十分适合Java运行时,因为它都是在难以预测并且千变万化的环境下执行的。大部分JVM使用动态编译器,例如JIT编译器。关键是动态编译器和代码优化有时需要额外的数据结构、线程和CPU资源。优化或字节码分析操作越优异,编译过程开销的资源就越多。因此在大部分环境中,和字节码的重要性能收益相比,动态编译器和代码优化的开销并不算大。

JVM变量和JAVA平台独立性

所有JVM的实现都具备同一个原则,那就是要把应用的字节码转译为机器指令。有些JVM在加载的时候解析应用代码,并使用性能计数器来关注“热点代码”(频繁使用的代码)。有些JVM跳过解析环节,并只依赖于编译环节。这样的话,编译资源的密集性可能会成为个更大的问题(特别是对客户端应用),但是这仍然支持了更多的高级优化。

如果你是个Java初学者,JVM的错综复杂将会让你十分困扰。好消息是其实你根本就没必要困扰!JVM掌管着代码编译和优化,因此你不需要担心机器命令,以及编写对底层平台构架的代码的优化。

Java字节码的只读存储执行(校对:从Java字节码到执行)

一旦你将java代码编译成字节码,下一步将是把字节码指令转译为机器指令。这可以由一个解析器或者编译器完成。

解析

最简单的字节码编译形式称为解析。解析器只是简单地为每个字节码指令查找对应的硬件指令,并将其发送至CPU去执行。

你可以将解析比作使用字典:每个具体的单词(字节码指令)都有一个准确的翻译(机器代码指令)。自从解析器读取并即刻执行了一个字节码指令那一刻,就已经没有机会去优化指令集了。解析器同样,在每次字节码被读取时,不得不执行解析操作,并且这个过程是十分缓慢的。解析是一种能够执行代码的精确方法,但是这种不可优化的输出指令集,对目标平台处理器来说,可能不会得到最高性能的指令序列。

编译

另一方面,编译器将全部要执行的代码载入到运行时环境。当它转译字节码的时候,它还具备检查整体或部分运行时上下文的能力,并且判断如何准确的转译这些代码。它的判断是基于代码图的分析,例如不同的指令执行分支和运行时上下文数据。

当一个字节码序列被转译为机器指令集,并且可以对指令集进行优化时,用以替换的指令集(优化过的)被存储进一个叫做代码缓存的结构中。下一次这些字节码再被执行,之前优化过的代码可以被快速的从代码缓存中定位到,并且用于执行。有些情况下,性能计数器可能去除并重写之前的优化,即编译器运行了新的优化。代码缓存的优点是,结果指令集可以被即刻执行——不需要解析查找或者编译!这样就提高了执行速度,特别是对于java应用,同样的方法要被调用很多次。

优化

使用动态编译器可以带来插入性能计数器的机会。比如,编译器可能会插入一个性能计数器,在每次字节码代码块被调用时,进行计算。编译器使用“热点代码”相关的数据,来决定在正在运行的应用程序中,哪部分的代码优化给程序最好的影响。运行时的切面数据使编译器制作出一些正在运行、或长远提高性能的代码优化方案。随着更多的提炼的代码切面数据有效利用,它可以用于更多更好的优化决策,例如,如何在语言编译中,获得更好的输出指令,是否需要替换为更有效的指令集,甚至是否需要去除多余的操作。

参考如下代码

[code]
staticint add7(int x){
    return x+7;
}
[/code]

以下是使用javac静态编译后的指令

[code]
iload0
bipush 7
iadd
ireturn
[/code]

当这个方法被调用时,字节码块将被动态地编译为机器指令。当性能计数器(如果监控了此段代码)触发门限时,它也可能被优化。最终结果可能和以下机器指令集相似:

[code]
lea rax,[rdx+7]
ret
[/code]

不同应用程序的不同编译器

不同的Java应用具有不同需求。需要长期运行的企业级服务器端应用程序需要更多的优化,相对来说小型一些的客户端应用程序也许需要以最小得资源开销快速执行。让我们看看三种不同编译器套件和他们各自的好处于不足。

客户端编译器

C1是一个著名的优化编译器,可以通过JVM启动选-client来生效。正如它的启动名称一样,C1是个客户端编译器。它被设计为帮助客户端应用使用更少的资源,并且在很多情况下,它对应用程序启动时间有所感知。C1使用性能计数器对代码现状切面进行简单的、毫无侵入性的优化。

服务器端编译器

对于长期运行的应用程序,例如企业级服务器段Java程序来说,一个客户端编译器可能不够用。服务器端编译器例如C2可以拍上用场了。通常C2是通过JVM启动命令-server来生效的。由于大部分服务器端程序需要长时间的运行,启动了C2,和短时间运行的轻量级客户端应用相比,用户可以收集更多的切面数据。因此,用户可以实施更高级的优化技术和算法。

贴士:为你的服务器端编译器热身

由于服务器端部署的应用在编译器对一些初始”热点”代码产生优化之前会消耗一些时间,因此服务器端通常需要一个”热身”环节。在服务器端开始实施一些性能度量之前,请确保你的应用程序已经到达平稳的运行状态!从而给编译器足够的时间进行适当的编译,来为你创造收益。(想了解更多编译器热身和切面原理,请看JavaWorld文章”Watch your HotSpot compiler go“)

服务器编译器比客户端编译器解读更多的切面数据(校对:分析数据),并且允许更复杂的分支分析,这意味着它能够评估出哪个优化方法更有效。拥有更多的切面数据(校对:分析数据)可以产生更好的结果。当然,实施更大范围的切面(校对:分析)和分析(校对:计算)需要扩展编译器的使用资源。使用了C2的JVM将使用更多的线程和CPU周期,需要更大的代码缓存等等。

分层编译器

分层编译结合了客户端和服务器端编译。Azul最早在他的Zing虚拟机中,制造了分层编译。最近(Java SE 7)它已经被Oracle Java Hotspot JVM所采纳。分层编译吸取了客户端和服务器端编译的优点。客户端编译器在应用启动的时期更加主动,并且通过一些较低的性能指标阀指来触发优化处理的动作。客户端编译器也可以插入性能计数器,并且为更高级的优化准备指令集,将在后期阶段被服务器端编译所处理。分层编译器是一个非常高效利用资源的切面(校对:分析)方法,因为它可以在对编译器活动影响极低的时候进行数据收集,在后期高级优化时可以使用。和仅仅使用代码解析计数器相比,这个方法还会生产出更多的信息。

 以下图表描绘了纯解析、客户端、服务器端和混合编译的性能区别。X轴代码执行时间,Y轴代表性能。(校对:插入图表)

和单纯的代码解析相比,使用客户端编译器可以提高大约5-10倍的执行性能,实际上提高了应用的性能。当然,收益的变化还是依赖于编译器性能如何,哪些优化生效了或者被实现了,还有就是对于目标执行平台来说应用程序设计的有多好。后者是Java开发人员从来不需要担心的。

和客户端编译器相比,服务器端编译器通常能够提升可度量的30%-50%的代码效率。在大部分情况下,这性能的提高将平衡掉多余的资源开销。

分层编译结合了两种编译器的优点。客户端编译产生了快速的启动时间和及时的优化,服务器端编译在执行周期的后期,可以提供更多的高级优化。

共同的编译器优化

目前我讨论了代码优化的价值,以及如何、什么时候JVM编译器能优化代码。我将通过编译器实际的优化情况进行总结。JVM优化实际发生在字节码级别(或在更低的语言级别),但是我将讲解使用java语言的优化。在这个部分中,我不可能覆盖所有JVM的优化,我更多的是启发用户进行自我探索,并且了解大量的高级优化,以及编译器技术的发明(请看Resources)。

去除死代码

死代码去除就像听上去的一样:代码的去除从没有被称为”死代码”。如果一个编译器在运行过程中发现了一些执行是没有必要的,它将轻易的将这些指令从执行的指令集中删除。例如,列表1中,被指定了确切值的变量从来没有被使用过,并且完全可以在执行时被省略掉。在字节码级别,这相当于不需要执行将值载入到寄存器。不做不必要载入意味着更少的CPU时间,并促进了代码执行时间,因此特别是热点的代码以及一秒钟要调用很多此的代码,要注意此操作。

清单1 展示了java代码中典型的没用的变量和不需要的操作。

[code]
int timeToScaleMyApp(boolean endlessOfResources){
    int reArchitect =24;
    int patchByClustering =15;
    int useZing =2;
    if(endlessOfResources)
    return reArchitect + useZing;
    else return useZing;
}
[/code]

 在字节码级别,如果一个值被载入,但是从不使用,那么编译器是可以检测到的并可以删除死代码,如代码清单2所示。没有执行载入操作节省了CPU时间,并且也提高了程序执行速度。

清单2 优化后的相同代码

[code]
int timeToScaleMyApp(boolean endlessOfResources){
    int reArchitect =24;
    //unnecessary operation removed here…
    int useZing =2;
    if(endlessOfResources)
    return reArchitect + useZing;
    else
    return useZing;
}
[/code]

多余性的删除是一个相似的优化操作,它将会移除重复的指令,来提高应用的性能。

代码嵌入

很多优化都是尝试去除主机层的jump指令(例如x86构架的JMP指令)。jump指令改变了指令指针寄存器,因此转变了执行流程。和其他ASSEMBLY类指令相比,这是个高成本的操作,这就是为什么要减少或消除jump指令。一个非常有用和著名的优化就是代码嵌入。既然jump非常昂贵,那么这种方式能有所帮助,即在调用区间内,内嵌很多可以频繁调用的,不同入口地址的小方法。代码清单3-5示范了内嵌代码的好处。

调用方法

[code]
int whenToEvaluateZing(int y){
    return daysLeft(y)+ daysLeft(0)+ daysLeft(y+1);
}
[/code]

被调用方法

[code]
int daysLeft(int x){
    if(x ==0) return0;
    else return x -1;
}
[/code]

内嵌方法

[code]
int whenToEvaluateZing(int y){
    int temp =0;
    if(y ==0) temp +=0;else temp += y -1;
    if(0==0) temp +=0;else temp +=0-1;
    if(y+1==0) temp +=0;else temp +=(y +1)-1;
    return temp;
}
[/code]

清单3-5中,在调用的主方法中,其调用了三次小的方法,这样我们假设这个例子的目的是展示内嵌代码比跳跃三次更有利。

对于一个很少调用到的方法,内嵌代码不会产生太大的不同,但是对于频繁调用的热点代码,这将意味着性能上巨大的不同。内嵌也常常对更深远的优化有所帮助,如清单6所示。

内嵌,可以采用更多的优化

[code]
int whenToEvaluateZing(int y){
    if(y ==0) return y;
    else if(y ==-1)return y -1;
    else return y + y -1;
}
[/code]

循环优化

循环优化在降低执行循环代码的开销有着重要的作用。在这种情况下,系统开销意味着昂贵的指针转移、大量的条件判断、没有选择(校对:优化)的指令管道(也就是说,大量的指令集会导致无操作或CPU的额外周期)。循环优化有很多种,以及大量的优化组合。典型的包括:

  • 混合循环:当两个相邻的循环被迭代,循环次数相同,这个编译器能够尝试混合循环的主体,在相同的时间被执行(并行),当然两个循环体内部不能有相互的引用,也就是说,他们必须是完全的相互独立。
  • 反向循环:基本上你可以使用do-while循环替代while循环。因为do-while循环具备一个if子句。这个替换可以减少两次指针转移。然而,这也增加了条件判断和增加了代码数量。这种优化是一个极好的例子,即如何多付出一点资源来换取更加高效的代码,在动态运行时,编译器不得不评估和决定开销和收益的平衡
  • Tiling loops:重组循环,以便于迭代的数据块尺寸适合缓存
  • Unrolling loops:能降低循环条件的判断次数和指针转移次数。你可以将其想象为内嵌两三个要执行的迭代体,并且不需要接触到循环条件。unrolling loops运行有风险,因为它可能会造成管道减少和多余的指令操作,从而降低性能。重申一下,这个判断是编译器运行时作出的,也就是说,如果收益足够,那么付出的开销也是值得的

这就是一个概要,即在字节码级别(或更低级别)编译器做了些什么来改进应用在目标平台上的执行效率。这些优化是共通而普遍的,但是只有一些可选的短小示范。这些非常简单而宽泛的讲解也是为了激发读者的兴趣,从而进行更深度的探索。

综上所述:反射点和亮点

为不同的需求选择不同的编译器

  • 转译是一个字节码翻译为机器指令的最简单形式,并且基于指令查询表工作
  • 编译器基于性能计数器的优化,但将需要一些附加的资源开销(代码缓存、优化线程等)
  • 和转译代码相比,客户端编译器可以大大提高代码执行性能(5-10倍)
  • 服务器端编译器比客户端编译器能提高应用性能30%-50%,但是消耗更多的资源
  • 分层编译器提供了两者的最佳能力。具备客户端编译能力而提高代码执行性能,并且服务端编译随时间而定,而使频繁执行的代码性能更好。

有很多重可行的代码优化。对于编译器来说一个种要的任务就是分析所有可能性,并且基于输出的主机代码的执行速度衡量采用优化的开销。

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: JVM优化系列-第二部分-编译器

  • Trackback 关闭
  • 评论 (0)
  1. 暂无评论

return top