jvm_后端编译与优化详解

1、 概述

如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。如果读者阅读过本书的第2版,可能会发现本章的标题已经从“运行期编译与优化”悄然改成了“后端编译与优化”,这是因为在2012年的Java世界里,虽然提前编译(Ahead Of Time,AOT)早已有所应用,但相对而言,即时编译(Just In Time, JIT)才是占绝对主流的编译形式。不过,最近几年编译技术发展出现了一些微妙的变化,提前编译不仅逐渐被主流JDK所支持,而且在Java编译技术的前沿研究中又重新成了一个热门的话题,所以再继续只提“运行期”和“即时编译”就显得不够全面了,在本章中它们两者都是主角。

无论是提前编译器抑或即时编译器,都不是Java虚拟机必需的组成部分,《Java虚拟机规范》中从来没有规定过虚拟机内部必须要包含这些编译器,更没有限定或指导这些编译器应该如何去实现。但是,后端编译器编译性能的好坏、代码优化质量的高低却是衡量一款商用虚拟机优秀与否的关键指标之一,它们也是商业Java虚拟机中的核心,是最能体现技术水平与价值的功能。在本章中,我们将走进Java虚拟机的内部,探索后端编译器的运作过程和原理。

既然《Java虚拟机规范》没有具体的约束规则去限制后端编译器应该如何实现,那这部分功能就完全是与虚拟机具体实现相关的内容,如无特殊说明,本章中所提及的即时编译器都是特指HotSpot虚拟机内置的即时编译器,虚拟机也是特指HotSpot虚拟机。不过,本章虽然有大量的内容涉及了特定的虚拟机和编译器的实现层面,但主流Java虚拟机中后端编译器的行为会有很多相似相通之处,因此对其他虚拟机来说也具备一定的类比参考价值。

2、 即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。本节我们将会了解HotSpot虚拟机内的即时编译器的运作过程,此外,我们还将解决以下几个问题:

为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?
程序何时使用解释器执行?何时使用编译器执行?
哪些程序代码会被编译为本地代码?如何编译本地代码?
如何从外部观察到即时编译器的编译过程和编译结果?

3、解释器与编译器

3.1、解释器与编译器

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟机,譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作,其交互关系如图11-1所示。

jvm_后端编译与优化详解_第1张图片

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和 C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。Graal编译器目前还处于实验状态,本章将安排出专门的小节对它讲解与实战,在本节里,我们将重点关注传统的C1、C2编译器的工作过程。

在分层编译(Tiered Compilation)的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。可以通过虚拟机的“-version”命令的输出结果显示出这三种模式,内容如代码清单11-1所示,请读者注意黑体字部分。

代码清单11-1 虚拟机执行模式
$java -version
java version “11.0.3” 2019-04-16 LTS
Java™ SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot™ 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)
$java -Xint -version
java version “11.0.3” 2019-04-16 LTS
Java™ SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot™ 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)
$java -Xcomp -version
java version “11.0.3” 2019-04-16 LTS
Java™ SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot™ 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译的概念其实很早就已经提出,但直到JDK 6时期才被初步实现,后来一直处于改进阶段,最终在JDK 7的服务端模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次

3.2、什么是分层编译?

从JDK6开始,Java引入了分层编译,这种方式综合了 C1 和 C2 的优势,不过这时它还只是初步的实现。
而到JDK7才开始应用到server模式中,而server模式也常常是一些硬件系统的默认选择,所以也可看做是JDK7默认推荐使用分层编译了。当然我们还是可以通过前面的手动指令去 【-client】或【-server】强制选择。
分层编译将 JVM 的执行状态分为了 5 个层次:
level 0:解释器解释执行,默认开启profiling。
level 1:C1 编译,执行不带profiling的C1代码,不开启 profiling。
level 2:C1 编译,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 代码,开启部分profiling。
level 3:C1 编译,执行带所有 Profiling 的 C1 代码,开启 profiling。
level 4:C2 编译,执行C2的代码,C2编译也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

profiling(性能监控):
其中profiling就是收集能够反应程序执行状态的监控数据,它由多项数据组成,其中最基本的就是方法的调用次数和循环回边的执行次数(这块内容后面小节会详解)。
各层次执行效率:

虽然分了5个层次,但其实也没那么复杂,我们可以把level 123看做是一层:C1,因为这三层都是执行进行C1编译后的代码,唯一的区别是带了多少的profiling。因为profiling是影响性能的,所以执行效率上:level1>level2>level3。

那有的人会觉得既然level1是执行C1代码的三个层次中,执行效率最高的,那为什么不一直执行level1呢?
当然,level1因为没有开启profiling,执行效率最高,但是这仅限于C1,如果是C2编译的代码,通常要比C1代码的执行效率高出**30% **!!而profiling,可以理解为就是开启C2编译的条件或是钥匙。

终止状态:
level1由于放弃了钥匙,自然也失去了被编译为C2代码的机会,所以我们可以得知,level1和level4是属于终止状态的。当一个方法执行到终止状态后,除非编译后的代码失效了,否则它就不会再次发出该方法的编译请求了。也就是一旦方法执行到level1和level4,一般它就固定下来了,后面不出意外也会一直执行level1或level4的层次。

分层编译的触发关系:
但是不管是什么方法,它都是从level 0开头的,这层是解释器,不进行任何编译。
下图是几种常见的编译路径

jvm_后端编译与优化详解_第2张图片

①第一条路径是一般情况,热点方法从解释执行,到被第3层的C1编译,最后进入C2编译。
②第二种情况是trivial method,即不重要的方法,例如常见的getset方法,第3层的profiling没有收集到有价值的数据,JVM就会放弃对其进行C2编译,转而进行无profiling的C1编译。因为差别并不大,还不如把宝贵的资源留给更需要进行C2编译的方法。
③当C1忙碌的情况下,解释器在解释执行的过程中profiling,然后直接跳转到C2编译。
④当C2忙碌的情况下,由于第3层C1又比第2层慢不少,为了不浪费资源,先进行第2层的C1,再随着时间进行第3层的C1编译。最后等C2不忙了,才进入C2编译。没错,这条线路不会进入第1层C1,C2再忙也不能直接把可能需要优化的方法简单的用C1处理了就完事了。
⑤还有一种情况就是因为激进优化产生的问题,由于C2激进的策略,导致编译出来的C2代码有问题,这时候会进行反优化,重新进入解释器解释执行的阶段。

分层优化就是这样灵活的在各个层次间反复横跳,以达到效率和质量的平衡点,显然这比单一的使用C1或C2要强大多了。

JDK8开始
并且从JDK8开始,默认启用分层编译Tiered Compilers,这时候已经没有【-client】和【-server】指令了,这时如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想使用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1,其实就相当于只进行到level1层次的分层编译。

4、 编译对象与触发条件

在本章概述中提到了在运行过程中会被即时编译器编译的目标是“热点代码”,这里所指的热点代 码主要有两类,包括:

被多次调用的方法。
被多次执行的循环体。
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执 行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

读者可能还会有疑问,在上面的描述里,无论是“多次执行的方法”,还是“多次执行的代码块”,所谓“多次”只定性不定量,并不是一个具体严谨的用语,那到底多少次才算“多次”呢?还有一个问题,就是Java虚拟机是如何统计某个方法或某段代码被执行过多少次的呢?解决了这两个问题,也就解答了即时编译被触发的条件。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点 探测判定方式有两种,分别是:

基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而 扰乱热点探测。

基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

这两种探测手段在商用Java虚拟机中都有使用到,譬如J9用过第一种采样热点探测,而在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

我们首先来看看方法调用计数器。顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX: CompileThreshold来人为设定。当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。

在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图11-3所示。

jvm_后端编译与优化详解_第3张图片

现在我们再来看看另外一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。

关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。

虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以OSR比率(-XX:OnStackReplacePercentage)除以100。其中-XX:OnStackReplacePercentage默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。

虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX:InterpreterProfilePercentage)的差值)除以100。其中-XX:OnStackReplacePercentage默认值为140,-XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图11-4所示。

jvm_后端编译与优化详解_第4张图片

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

最后还要提醒一点,图11-2和图11-3都仅仅是描述了客户端模式虚拟机的即时编译方式,对于服务端模式虚拟机来说,执行情况会比上面描述还要复杂一些。从理论上了解过编译对象和编译触发条件后,我们还可以从HotSpot虚拟机的源码中简单观察一下这两个计数器,在MehtodOop.hpp(一个methodOop对象代表了一个Java方法)中,定义了Java方法在虚拟机中的内存布局,如下所示:

// |------------------------------------------------------|
// | header |
// | klass |
// |------------------------------------------------------|
// | constMethodOop (oop) |
// | constants (oop) |
// |------------------------------------------------------|
// | methodData (oop) |
// | interp_invocation_count |
// |------------------------------------------------------|
// | access_flags |
// | vtable_index |
// |------------------------------------------------------|
// | result_index (C++ interpreter only) |
// |------------------------------------------------------|
// | method_size | max_stack |
// | max_locals | size_of_parameters |
// |------------------------------------------------------|
// |intrinsic_id| flags | throwout_count |
// |------------------------------------------------------|
// | num_breakpoints | (unused) |
// |------------------------------------------------------|
// | invocation_counter |
// | backedge_counter |
// |------------------------------------------------------|
// | prev_time (tiered only, 64 bit wide) |
// | |
// |------------------------------------------------------|
// | rate (tiered) |
// |------------------------------------------------------|
// | code (pointer) |
// | i2i (pointer) |
// | adapter (pointer) |
// | from_compiled_entry (pointer) |
// | from_interpreted_entry (pointer) |
// |------------------------------------------------------|
// | native_function (present only if native) |
// | signature_handler (present only if native) |
// |------------------------------------------------------|

在这段注释所描述的方法内存布局里,每一行表示占用32个比特,从中我们可以清楚看到方法调用计数器和回边计数器所在的位置和数据宽度,另外还有from_compiled_entry和from_interpreted_entry 两个方法入口所处的位置。

还有一个不太上台面但其实是Java虚拟机必须支持循环体触发编译的理由,是诸多跑分软件的测试用力通常都属于第二种,如果不去支持跑分会显得成绩很不好看。
除这两种方式外,还有其他热点代码的探测方式,如基于“踪迹”(Trace)的热点探测在最近相当流行,像FireFox里的TraceMonkey和Dalvik里新的即时编译器都是用了这种热点探测方式。
准确地说,应当是回边的次数而不是循环次数,因为并非所有的循环都是回边,如空循环实际上就可以视为自己跳转到自己的过程,因此并不算作控制流向后跳转,也不会被回边计数器统计。

5、编译过程

在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。

那在后台执行编译的过程中,编译器具体会做什么事情呢?服务端编译器和客户端编译器的编译过程是有所差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。

在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。客户端编译器大致的执行过程如图 11-5所示。

jvm_后端编译与优化详解_第5张图片
而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度。它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开 (Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测 (Branch Frequency Prediction)等,本章的下半部分将会挑选上述的一部分优化手段进行分析讲解, 在此就先不做展开。

服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如 RISC)上的大寄存器集合。以即时编译的标准来看,服务端编译器无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于客户端编译器编译输出的代码质量有很大提高,可以大幅减少本地代码的执行时间,从而抵消掉额外的编译时间开销,所以也有很多非服务端的应用选择使用服务端模式的HotSpot虚拟机来运行。

在本节中出现了许多编译原理和代码优化中的概念名词,没有这方面基础的读者,可能阅读起来会感觉到很抽象、很理论化。有这种感觉并不奇怪,一方面,即时编译过程本来就是一个虚拟机中最能体现技术水平也是最复杂的部分,很难在几页纸的篇幅中介绍得面面俱到;另一方面,这个过程对 Java开发者来说是完全透明的,程序员平时无法感知它的存在。所幸,HotSpot虚拟机提供了两个可视化的工具,让我们可以“看见”即时编译器的优化过程。下面笔者将实践演示这个过程。

6、 提前编译器

提前编译在Java技术体系中并不是新事物。1996年JDK 1.0发布,Java有了正式的运行环境,第一个可以使用外挂即时编译器的Java版本是1996年7月发布的JDK 1.0.2,而Java提前编译器的诞生并没有比这晚多少。仅几个月后,IBM公司就推出了第一款用于Java语言的提前编译器(IBM High Performance Compiler for Java)。在1998年,GNU组织公布了著名的GCC家族(GNU Compiler Collection)的新成员GNU Compiler for Java(GCJ,2018年从GCC家族中除名),这也是一款Java的提前编译器,而且曾经被广泛应用。在OpenJDK流行起来之前,各种Linux发行版带的Java实现通常就是GCJ。

但是提前编译很快又在Java世界里沉寂了下来,因为当时Java的一个核心优势是平台中立性,其宣传口号是“一次编译,到处运行”,这与平台相关的提前编译在理念上就是直接冲突的。GCJ出现之后在长达15年的时间里,提前编译这条故事线上基本就再没有什么大的新闻和进展了。类似的状况一直持续至2013年,直到在Android的世界里,剑走偏锋使用提前编译的ART(Android Runtime)横空出世。ART一诞生马上就把使用即时编译的Dalvik虚拟机按在地上使劲蹂躏,仅经过Android 4.4一个版本的短暂交锋之后,ART就迅速终结了Dalvik的性命[2],把它从Android系统里扫地出门。

尽管Android并不能直接等同于Java,但两者毕竟有着深厚渊源,提前编译在Android上的革命与崛起也震撼到了Java世界。在某些领域、某些人眼里,只要能获得更好的执行性能,什么平台中立性、字节膨胀[3]、动态扩展[4],一切皆可舍弃,唯一的问题就只有“提前编译真的会是获得更高性能的银弹吗?”

[1] GCJ其实包含了整个Java运行时,里面也有解释器和即时编译器存在。
[2] ART干掉Dalvik之后,到Android 7.0时其内部也加入了解释执行和即时编译,这是后话。
[3] 指提前编译的本地二进制码的体积会明显大于字节码的体积。
[4] 指提前编译通常要求程序是封闭的,不能在外部动态加载新的字节码。

7、编译器优化

7.1、 编译器优化

字节码是如何运行的?
解释执行:由解释器一行一行翻译执行
优势在于没有编译的等待时间
性能相对差一些

编译执行:把字节码编译成机器码,直接执行机器码
运行效率会高很多,一般认为比解释执行快一个数量级
带来了额外的开销

那么如何查看自己的java是解释执行还是编译执行呢?

$ java -version
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode)

mixed mode 代表混合执行,部分解释执行、部分编译执行。

-Xint:设置JVM的执行模式为解释执行模式

$ java -Xint -version
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, interpreted mode)

-Xcomp:JVM优先以编译模式运行,不能编译的,以解释模式运行

$ java -Xcomp -version
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, compiled mode)

-Xmixed:以混合模式运行
一般情况下,我们的代码一开始一般由解释器解释执行。但是当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会认为这些代码是热点代码(如何定位?)。为了提高热点代码的执行效率,会用即使编译器(也就是JIT)把这些热点代码编译城与本地平台相关的机器码,并进行各层次的优化(操作系统的不同、CPU架构的不同)

7.2 Hotspot 的即时编译器 C1

是一个简单快速的编译器
主要关注局部性的优化
适用于执行时间较短或启动性能有要求的程序。例如。GUI应用对界面启动速度就有一定要求。、
也被称为 Client Compiler

7.3 Htospot 的即时编译器 C2

是为长期运行的服务器端应用程序做性能调优的编译器
适用于执行时间较长或对峰值性能有要求的程序
也被称为是 Server Compiler

7.4 分层编译

从JDK7开始,正式引入了分层编译的概念,可以细分为 5 种编译级别:
解释执行
简单 C1 编译:会用 C1 编译器进行一些简单的优化,不开启 Profiling(JVM性能监控)
受限的 C1 编译:仅执行带方法调用次数以及循环回边执行次数Profiling的 C1 编译
完全C1编译:会执行带有所有Profiling的C1代码
C2 编译:使用C2编译器进行优化,该级别会启用一些编译耗时较长的优化,一些情况下会根据性能监控信息进行一些非常激进的性能优化
级别越高,应用启动越慢,优化的开销越高,峰值性能也越高。

7.5 分层编译- JVM参数配置示例

只想开启 C2:-XX:-TieredCompilation(禁用中间编译层(123层))
只想开启 C1:-XX:+TieredCompilation -XX:TieredStopAtLevel=1

7.6 如何找到热点代码?思路?

基于采样的热点探测
周期性检查各个线程的栈顶,如果发现某一些方法总是出现在各个栈顶,那就说明是热点代码。

基于计数器的热点探测
大致思路是为每一个方法甚至是代码块建立计数器,然后统计执行的次数,如果超过一定的阈值,那就说明它是热点代码。Hotspot虚拟机采用的就是基于计数器的热点探测。

7.7 Hotspot 内置的两类计数器

方法调用计数器(Invocation Counter)

用于统计方法被调用的次数,在不开启分层编译的情况下,在 C1 编译器下的默认阈值是 1500 次,在 C2 模式下是 10000次。也可以哦那个 -XX:CompileThreshold=X 指定阈值

回边计数器(Back Edge Counter)
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。在不开启分层编译的情况下,C1 编译器心爱的默认阈值 13995,C2 默认为 10700,可使用 -XX:OnStackReplacePercentage=X指定阈值
建立回边计数器的主要目的是为了触发 OSR (OnStackReplacement)编译,参考文档
当开启分层编译时,JVM会根据当前编译的方法数以及编译线程数来动态调整阈值,-XX:CompileThreshold、-XX:OnStackReplacePercentage 都会失效

1.8 方法调用计数器流程

jvm_后端编译与优化详解_第6张图片
如果不做任何设置,方法调用次数统计的并不是方法被调用的绝对次数,而是一个相对的执行频,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数荏苒不足以让它提交给及时编译器编译,那这个方法的调用计数器就会减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾手机是顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

7.9 回边计数器流程

jvm_后端编译与优化详解_第7张图片

7.10 方法内联

7.10.1 什么是方法内联?示例?

package com.example;

public class InlineTest1 {
  private static int add1(int x1, int x2, int x3, int x4) {
    return add2(x1, x2) + add2(x3, x4);
  }
  public static int add2(int x1, int x2) {
    return x1 + x2;
  }
  // 内联后
  private static int addInline(int x1, int x2, int x3, int x4) {
    return x1 + x2 + x3 + x4;
  }
}

所谓的方法内联就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用,从而减少压栈和出栈的作用

7.10.2 发生方法内联的条件

方法体足够小
热点方法:如果方法体小于325字节会尝试内联,可用 -XX:FreqInlineSize 修改方法体大小。
非热点方法:如果方法体小于35字节,也会尝试内联,可用-XX:MaxInlineSize修改大小。

被调用方法运行时的实现可唯一确定
static方法、private方法及final方法,JIT可以唯一确定具体的实现代码。
public的实例方法,指向的实现可能是自身、父类、子类的代码,当且仅当JIT能够唯一确定方法的具体实现时,才有可能完成内联。

7.10.3 使用方法内联的注意点:

尽量让方法体小一点;
尽量使用 final、private、static关键字修饰方法,避免因为多态,需要对方法做额外检查;
在某些场景下,可通过JVM 参数修改阈值,从而让更多方法内联。

7.10.4 方法内联可能带来的问题

内联是用空间换时间的一种做法,也就是及时编译器在方法调用期间把方法调用连接在一起,但是经过内联的代码会变多,而增加的代码量取决于方法的调用次数以及方法本身的大小。
在一些极端情况下,内联可能会导致
CodeCache(热点代码的缓存区,及时编译代码和本地方法代码)的溢出,导致JVM退化成解释执行模式;

7.10.5 内联相关JVM参数

jvm_后端编译与优化详解_第8张图片
jvm_后端编译与优化详解_第9张图片

7.10.6 方法内联测试代码

public class InlineTest {

    private final static Logger log = LoggerFactory.getLogger(InlineTest.class);
    
    public static void main(String[] args) {
        long cost = compute();
        log.info("执行花费 {} ms", cost);
    }

    private static long compute() {
        long start = System.currentTimeMillis();
        int result;
        Random random = new Random();
        for (int i = 0; i< 10000000; i++) {
            int a = random.nextInt();
            int b = random.nextInt();
            int c = random.nextInt();
            int d = random.nextInt();
            result = add1(a, b, c, d);
        }
        long end = System.currentTimeMillis();
        return end - start;
    }

    public static int add1(int n1, int n2, int n3, int n4) {
        return add2(n1, n2) + add2(n3, n4);
    }

    private static int add2(int n1, int n2) {
        return n1 + n2;
    }
}

jvm_后端编译与优化详解_第10张图片

jvm_后端编译与优化详解_第11张图片
但是,一般来说不建议使用这些JVM参数,默认的就好,现代JVM是相当智能的。

8、逃逸、锁消除、标量替换

你可能感兴趣的:(JVM,java,jvm,开发语言)