Java编译器在JVM性能优化系列的第二篇文章中占据中心位置。 Eva Andreasson介绍了不同种类的编译器,并比较了客户端,服务器和分层编译的性能结果。最后,她概述了常见的JVM优化,例如消除死代码,内联和循环优化。
Java编译器是Java著名的平台的独立性的来源。软件开发人员会尽力编写最好的Java应用程序,然后编译器会在幕后进行工作,以为目标目标平台生成高效且性能良好的执行代码。不同种类的编译器可满足各种应用程序需求,从而产生特定的所需性能结果。你对编译器了解得越多,就它们的工作方式和可用的种类而言,你就越能够优化Java应用程序性能。
什么是编译器?
简而言之,编译器将编程语言作为输入,并产生可执行语言作为输出。 一种常见的编译器是Javac,它包含在所有标准Java开发工具包(JDK)中。 javac将Java代码作为输入并将其转换为字节码-JVM的可执行语言。 字节码存储在.class文件中,当启动Java进程时,该文件将加载到Java运行时中。
字节码不能被标准CPU读取,需要转换为底层执行平台可以理解的指令语言。 JVM中负责将字节码转换为可执行平台指令的组件是另一个编译器。 一些JVM编译器可以处理多个级别的转换。 例如,编译器可能在将字节码转换成实际的机器指令(即翻译的最后一步)之前,创建字节码的各种中间表示形式。
从平台不可知的角度来看,我们希望尽可能使代码独立于平台,以便最后的翻译级别(从最低表示到实际的机器代码)是将执行锁定到特定平台的处理器体系结构的步骤 。 静态和动态编译器之间的最高级别隔离。 从那里开始,我们有选择,这取决于我们要针对的执行环境,所需的性能结果以及需要满足的资源限制。 我在本系列的第1部分中简要讨论了静态和动态编译器。 在以下各节中,我将进一步解释。
静态与动态编译
静态编译器的一个示例是前面提到的javac。 对于静态编译器,输入代码将被解释一次,而输出可执行文件的形式将在执行程序时使用。 除非你对原始源代码进行更改并重新编译代码(使用编译器),否则输出将始终产生相同的结果。 这是因为输入是静态输入,而编译器是静态编译器。
在静态编译中,
static int add7( int x ) {
return x+7;}
会导致类似于以下字节码的内容:
iload0
bipush 7
iadd
ireturn
动态编译器会动态地将一种语言翻译成另一种语言,这意味着它会在执行代码时发生-在运行时! 动态编译和优化为运行时提供了能够适应应用程序负载变化的优势。 动态编译器非常适合Java运行时,这些运行时通常在不可预测且不断变化的环境中执行。 大多数JVM使用动态编译器,例如即时(JIT)编译器。 问题是动态编译器和代码优化有时需要额外的数据结构,线程和CPU资源。 优化或字节码上下文分析越高级,编译消耗的资源就越多。 与输出代码的显着性能相比,在大多数环境中,开销仍然很小。
JVM种类和Java平台的独立性
所有JVM实现都有一个共同点,那就是它们试图将应用程序字节码转换为机器指令。 一些JVM在加载时解释应用程序代码,并使用性能计数器来关注“热”代码。 一些JVM跳过解释,仅依靠编译。 编译的资源密集性可能会受到更大的影响(尤其是对于客户端应用程序),但它还可以实现更高级的优化。
从Java字节码到执行
将Java代码编译为字节码后,下一步就是将字节码指令转换为机器码。 这可以由解释器或编译器完成。
解释
字节码编译的最简单形式称为解释。 解释器只需为每个字节码指令查找硬件指令,然后将其发送出去以由CPU执行。
你可能会想到解释,类似于使用字典:对于特定单词(字节码指令),存在确切的翻译(机器码指令)。 由于解释器一次读取并立即执行一个字节码指令,因此没有机会对指令集进行优化。 每次调用字节码时,解释器也必须执行解释,这使它相当慢。 解释是执行代码的一种准确方法,但是未优化的输出指令集可能不是目标平台处理器的最高性能序列。
总结
另一方面,编译器会将要执行的整个代码加载到运行时中。在翻译字节码时,它可以查看整个或部分运行时上下文,并就如何实际翻译代码做出决策。它的决策基于对代码图的分析,例如对指令和运行时上下文数据的不同执行分支。
当将字节码序列转换为机器码指令集并可以对该指令集进行优化时,替换指令集(例如优化序列)将存储到称为代码缓存的结构中。下次执行该字节码时,先前优化的代码可以立即位于代码缓存中,并用于执行。在某些情况下,性能计数器可能会加入并覆盖之前的优化,在这种情况下,编译器将运行新的优化序列。代码缓存的优点是可以立即执行生成的指令集-无需解释性查找或编译!这加快了执行时间,尤其是对于Java方法,其中多次调用相同的方法。
优化
除了动态编译外,还可以插入性能计数器。 例如,编译器可能会插入一个性能计数器,以在每次调用字节码块(例如对应于特定方法)时进行计数。 编译器使用有关给定字节码有多“热”的数据来确定代码中的最优化将对运行中的应用程序产生最佳影响。 运行时概要分析数据使编译器可以即时制定丰富的代码优化决策集,从而进一步提高代码执行性能。 随着更完善的代码概要分析数据的可用,它可以用于做出其他更好的优化决策,例如:如何更好地以编译为语言对指令进行排序,是否用更高效的指令集代替指令集,甚至 是否消除冗余操作。
例
考虑一下Java代码:
static int add7( int x ) {
return x+7;}
这可以由javac静态编译为字节码:
iload0
bipush 7
iadd
ireturn
调用该方法时,字节码块将动态编译为机器指令。 当性能计数器(如果存在于代码块中)达到阈值时,它可能也会得到优化。 对于给定的执行平台,最终结果可能类似于以下机器指令集:
lea rax,[rdx+7]
ret
不同应用程序的不同编译器
不同的Java应用程序有不同的需求。 长期运行的企业服务器端应用程序可以进行更多优化,而较小的客户端应用程序可能需要以最小的资源消耗来快速执行。 让我们考虑三种不同的编译器设置及其各自的优缺点。
客户端编译器
著名的优化编译器是C1,它是通过-client JVM启动选项启用的编译器。 顾名思义,C1是客户端编译器。 它是为客户端应用程序设计的,这些客户端应用程序具有较少的可用资源,并且在许多情况下对应用程序启动时间敏感。 C1使用性能计数器进行代码性能分析,以实现简单,相对无干扰的优化。
服务器端编译器
对于长时间运行的应用程序(例如服务器端企业Java应用程序),客户端编译器可能不够。 可以使用类似C2的服务器端编译器。 通常通过在启动命令行中添加JVM启动选项-server来启用C2。 由于大多数服务器端程序预计将运行很长时间,因此启用C2意味着你将比使用运行时间短的轻量级客户端应用程序收集更多的性能分析数据。 因此,你将能够应用更高级的优化技术和算法。
服务器编译器比客户端编译器处理更多的概要分析数据,并且允许进行更复杂的分支分析,这意味着它将考虑哪种优化路径会更有利。具有更多可用的概要分析数据可产生更好的应用程序结果。当然,进行更广泛的分析和分析需要在编译器上花费更多的资源。启用了C2的JVM将使用更多的线程和更多的CPU周期,需要更大的代码缓存,依此类推。
分层编译
分层编译将客户端和服务器端编译组合在一起。 Azul首先在其Zing JVM中提供了分层编译。最近(从Java SE 7开始),Oracle Java Hotspot JVM已采用它。分层编译利用了JVM中客户端和服务器编译器的优势。客户端编译器在应用程序启动期间最活跃,并处理由较低的性能计数器阈值触发的优化。客户端编译器还会插入性能计数器并为更高级的优化准备指令集,服务器端编译器将在稍后阶段解决这些问题。分层编译是一种非常节省资源的性能分析方法,因为编译器能够在影响较小的编译器活动期间收集数据,以后可以将其用于更高级的优化。与仅使用解释的代码配置文件计数器所获得的信息相比,这种方法还可以产生更多的信息。
图1中的图表架构描述了纯解释,客户端,服务器端和分层编译之间的性能差异。 X轴显示执行时间(时间单位),Y轴性能(操作数/时间单位)。
与纯解释代码相比,使用客户端编译器可以使执行性能(以ops / s为单位)提高约5至10倍,从而提高了应用程序性能。增益的变化当然取决于编译器的效率,启用或实现的优化方式以及(在较小程度上)应用程序相对于目标执行平台的良好设计。不过,后者确实是Java开发人员永远不必担心的事情。
与客户端编译器相比,服务器端编译器通常可将代码性能提高30%到50%。在大多数情况下,性能改进将平衡额外的资源成本。
分层编译结合了两种编译器的最佳功能。客户端编译可缩短启动时间并加快优化速度,而服务器端编译可在执行周期后期提供更高级的优化。
一些常见的编译器优化
消除无效代码
消除无效代码听起来像是:消除从未被调用的代码-即 无效 代码。 如果编译器在运行时发现某些指令是不必要的,它将仅从执行指令集中消除它们。 例如,在清单1中,从不使用变量的特定值分配,并且可以在执行时将其完全忽略。 在字节码级别,这可能对应于永远不需要执行将值加载到寄存器中的操作。 不必执行加载意味着更少的CPU时间,从而缩短了代码执行速度,进而缩短了应用程序的时间-尤其是当代码很热并且每秒被调用几次时。
清单1显示了Java代码,该代码示例了一个从未使用过的变量,这是不必要的操作。
清单 1. 清除无效代码
int timeToScaleMyApp(boolean endlessOfResources) {
int reArchitect = 24;
int patchByClustering = 15;
int useZing = 2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;}
在字节码级别上,如果加载了一个值但从未使用过,则编译器可以检测到该值并消除死代码,如清单2所示。从不执行加载可以节省CPU时间,从而提高程序的执行速度。
清单2.优化后的相同代码
int timeToScaleMyApp(boolean endlessOfResources) {
int reArchitect = 24;
//unnecessary operation removed here...
int useZing = 2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;}
冗余消除是类似的优化,它删除重复的指令以提高应用程序性能。
内联
许多优化尝试消除机器级别的跳转指令(例如,用于x86架构的JMP)。 跳转指令更改指令指针寄存器,从而传输执行流程。 相对于其他ASSEMBLY指令,这是一项昂贵的操作,这就是为什么它是减少或消除的常见目标的原因。 针对此的非常有用且众所周知的优化称为内联。 由于跳转很昂贵,因此将许多频繁调用具有不同入口地址的小型方法的调用内插会很有帮助。 清单3至5中的Java代码体现了内联的好处。
清单3.调用者方法
int whenToEvaluateZing(int y) {
return daysLeft(y) + daysLeft(0) + daysLeft(y+1);}
清单4.调用的方法
int daysLeft(int x){
if (x == 0)
return 0;
else
return x - 1;}
清单5.内联方法
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; }
在清单3至清单5中,调用方法对一个小方法进行了3次调用,出于示例的考虑,我们认为对内联方法而言,跳转到三遍更为有益。
内联很少调用的方法可能不会有太大的区别,但是内联经常调用的所谓“热”方法可能意味着性能上的巨大差异。 内联还经常为进一步的优化让路,如清单6所示
清单6.内联之后,可以应用更多的优化
int whenToEvaluateZing(int y){
if(y == 0) return y;
else if (y == -1) return y - 1;
else return y + y - 1;}
循环优化
循环优化在减少执行循环带来的开销方面发挥着重要作用。 在这种情况下,开销意味着昂贵的跳转,条件检查的次数,非最佳指令流水线(即导致CPU不工作或额外循环的指令顺序)。 循环优化有很多种,总计有很多优化。 值得注意的包括:
合并循环:当两个附近的循环重复相同的时间时,如果主体中没有相互引用的情况,则编译器可以尝试合并循环的主体,以同时(并行)执行,即它们彼此完全独立。
反转循环:基本上,你将常规的while循环替换为do-while循环。并且do-while循环设置为if子句。这种替换导致更少的两次跳跃。但是,它增加了条件检查,因此增加了代码大小。此优化是一个很好的示例,说明了如何使用更多的资源来提高代码效率-编译器必须在运行时动态评估和决定成本与收益的平衡。
切片循环:重新组织循环,以便对大小适合缓存的数据块进行迭代。
展开循环:减少必须评估循环条件的次数以及跳转次数。你可以将其视为“内联”要执行的主体的多次迭代,而不会越过循环条件。展开循环会带来风险,因为展开循环可能会损害流水线并导致多次冗余指令提取,从而降低性能。再次,这是编译器在运行时做出的判断调用,即,如果增益足够,则成本可能是值得的。
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。