Hope is a good thing, and maybe the best thing of all

编程不止是一份工作,还是一种乐趣!!!

JIT即时编译

我们都知道,执行java程序需要通过javac将程序源代码编译成class字节码,JVM通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。由于解释器在解释执行的过程中,每次只能看到一行代码,所以很难生成高效指令序列;而编译器可以事先看到所有代码,因此,一般来说,解释性代码比编译性代码要慢。为了提高Java程序的执行速度,引入了JIT技术。

什么是JIT


1、动态编译(dynamic compilation)指的是“在运行时进行编译”;与之相对的是事前编译(ahead-of-time compilation,简称AOT),也叫静态编译(static compilation)。

2、JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT编译是动态编译的一种特例,JIT编译一词后来被泛化,时常与动态编译等价;但要注意广义与狭义的JIT编译所指的区别。

3、自适应动态编译(adaptive dynamic compilation)也是一种动态编译,但它通常执行的时机比JIT编译迟,先让程序“以某种式”先运行起来,收集一些信息之后再做动态编译,这样的编译可以更加优化。

当JVM执行代码时,它并不立即开始编译代码。这主要有两个原因:首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码直接解释执行相对于编译这段代码并执行代码来说,要快很多;其次,是追求最优化,随着程序的运行,JVM会更加了解代码结构,在编译代码的时候就做出相应的优化。

在主流JVM中,Java程序一开始是通过解释器进行解释执行的。当JVM发现某个方法或代码块运行特别频繁时,就会把这些代码认定为热点代码(Hot Spot Code),然后JVM会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为:即时编译器(Just In Time Compiler,JIT)。


解释器与编译器并存


尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。


HotSpot编译器


HotSpot虚拟机内置了两个即时编译器,分别称为Client Compiler和Server Compiler,习惯上将前者称为C1,后者称为C2。HotSpot默认采用解释器和其中一个编译器直接配合的方式工作,使用那个编译器取决于虚拟机运行的模式,HotSpot会根据自身版本和宿主机器硬件性能自动选择模式,用户也可以使用“-client”或”-server”参数去指定。

  • 混合模式(Mixed Mode)

    默认的模式,如上面描述的这种方式就是mixed mode。

  • 解释模式(Interpreted Mode)

    可以使用参数“-Xint”,在此模式下全部代码解释执行。

  • 编译模式(Compiled Mode)

    参数“-Xcomp”,此模式优先采用编译,但是无法编译时也会解释(在最新的HotSpot中此参数被取消)。


分层编译


由于编译本地代码需要占用程序时间,要编译出优化程度更高的代码所花费的时间可能更长,且此时解释器还要替编译器收集性能监控信息,这对解释执行的速度也有影响,所以,为了在程序启动响应时间与运行效率之间达到最佳平衡,HotSpot在JDK1.6中引入了分层编译(Tiered Compilation)的概念并在JDK1.7的Server模式JVM中作为默认策略被开启。

分层编译根据编译器编译、优化的规模与耗时,划分了不同的编译层次:

  • 0:解释性代码(Interpreted code)
  • 1:简单的C1编译代码(Simple C1 compiled code)
  • 2:受限的C1编译代码(Limited C1 compiled code)
  • 3:完整的C1编译代码(Full C1 compiled code)
  • 4:C2编译代码(C2 compiled code)

实施分层编译后,C1和C2将会同时工作,许多代码会被多次编译,用C1获取更高的编译速度,用C2来获取更好的编译质量,且在解释执行的时候解释器也无须再承担收集性能监控信息的任务。

Tips:我们平时做压力测试时,通常要先对系统进行预热,原因就是分层编译。


热点代码


当JVM发现某个方法或代码块运行特别频繁时,就会把这些代码认定为热点代码,包括两类:

  • 被多次调用的方法

    一个方法被多次调用,理应称为热点代码,这种编译也是虚拟机中标准的JIT编译方式。

  • 被多次执行的循环体

    编译动作由循环体出发,但编译对象依然会以整个方法为对象。这种编译方式由于编译发生在方法执行过程中,因此形象的称为:栈上替换(On Stack Replacement- OSR编译,即方法栈帧还在栈上,方法就被替换了)。


上面的方法和循环体都说“多次”,那么多少算多?换个说法就是编译的触发条件。判断一段代码是不是热点代码,是不是需要触发JIT编译,这样的行为称为:热点探测(Hot Spot Detection),有几种主流的探测方式:

  1. 基于计数器的热点探测(Counter Based Hot Spot Detection)

    虚拟机会为每个方法(或每个代码块)建立计数器,统计执行次数,如果超过阀值那么就是热点代码。缺点是维护计数器开销。

  2. 基于采样的热点探测(Sample Based Hot Spot Detection)

    虚拟机会周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,那么就是热点代码。缺点是不精确。

  3. 基于踪迹的热点探测(Trace Based Hot Spot Detection)

    Dalvik中的JIT编译器使用这种方式。


HotSpot使用的是第1种,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)

  1. 方法计数器

    默认阀值,在Client模式下是1500次,Server是10000次,可以通过参数“-XX:CompileThreshold”来设定。当一个方法被调用时会首先检查是否存在被JIT编译过得版本,如果存在则使用此本地代码来执行;如果不存在,则将方法计数器+1,然后判断“方法计数器和回边计数器之和”是否超过阀值,如果是则会向编译器提交一个方法编译请求。默认情况下,执行引擎并不会同步等待上面的编译完成,而是会继续解释执行。当编译完成后,此方法的调用入口地址会被系统自动改写为新的本地代码地址。还有一点,热度是会衰减的,也就是说不是仅仅+,也会-,热度衰减动作是在虚拟机的GC执行时顺便进行的。

  2. 回边计数器

    它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。默认阀值,Client下13995,Server下10700。它的调用逻辑和方法计数器差不多,只不过遇到回边指令时+1、超过阀值时会提交OSR编译请求以及这里没有热度衰减。


JIT调优


Client 或 Server

即时编译器有两种类型,client和server。 一般情况下,对编译器进行优化,要做的就是选择那一类编译器。可以通过在启动java 的命令中,传入参数-client-server来选择编译器C1或C2。这两种编译器的最大区别就是,编译代码的时间点不一样。client编译器(C1)会更早地对代码进行编译,因此,在程序刚启动的时候,client编译器比server编译器执行得更快。而server编译器会收集更多的信息,然后才对代码进行编译优化,因此,server编译器最终可以产生比client编译器更优秀的代码。

可能大家都有一个困扰,JVM为什么要将编译器分为client和server,为什么不在程序启动时,使用client编译器,在程序运行一段时间后,自动切换为server编译器? 其实,这种技术是存在的,一般称之为:tiered compilation。Java7和Java 8可以使用选项-XX:+TieredCompilation来打开(-server选项也要打开)。在Java 8中,-XX:+TieredCompilation默认是打开的。


Code Cache优化

JVM在编译代码的时候,会在Code Cache中保存一些汇编指令。由于Code Cache的大小是固定,一旦它被填充满了,JVM就无法编译其它代码了。如果Code Cache很小,就会导致部分热点代码没有被编译,应用的性能将会急剧下降(执行解释性代码)。

Code Cache的最大值,可以通过-XX:ReservedCodeCacheSize=N(单位是字节)来指定,初始大小使用-XX:InitialCodeCacheSize=N来指定。有初始大小是会自动增加的,所以一般不需要设置-XX:InitialCodeCacheSize参数。


编译阈值

前面我们说过,HotSpot是否对代码进行编译受两个计数器的影响:方法计数器回边计数器。当JVM执行一个JAVA方法的时候,它都会检查这两个计数器,以便确定是否需要对方法进行编译。当JVM执行一个JAVA方法的时候,它会检查这两个计数器值的总和,然后确定这个方法是否需要进行编译。如果需要进行编译,这个方法会放入编译队列。如果方法里面是一个循环,虽然方法只调用一次,但是循环在不停执行。此时,JVM会对这个循环的代码进行编译。这种编译称之为栈上替换OSR编译。因为即使循环被编译了,这还不够,JVM还需要能够在循环正在执行的时候,转为执行编译后的代码。JVM采用的方式就是将编译后的代码替换当前正在执行的方法字节码。由于方法在栈上执行,所以这个操作又称之为:栈上替换。

可以通过配置-XX:CompileThreshold=N来确定计数器的阈值,从而控制编译的条件。但是如果降低了这个值,会导致JVM没有收集到足够的信息就进行了编译,导致编译的代码优化不够,不过影响会比较小。如果编译阈值配置的两个值(一个大,一个小)在性能测试方面表现差不多,那么会推荐使用小一点的配置,主要有两个原因:

  • 可以减小应用的warm-up period。

  • 可以防止某个方法永远得不到编译。这是因为JVM会周期(每到安全点的时候)的对计数进行递减。如果阈值比较大,并且方法周期性调用的时间较长,导致计数永远达不到这个阈值,从而不会进行编译。


编译线程

如果某个方法需要进行编译,它会被放入一个编译队列。这个队列有多个线程进行消费处理,这些线程就是编译线程。编译队列不是严格的FIFO的,而是根据计数器的次数进行排序,这样执行次数最多的代码优先得到编译。

如果JVM使用的client编译器,那么编译线程数量为1;如果使用的是server编译器,那么编译线程数量为2;可以使用-XX:CICompilerCount=N来调整编译线程的数量;如果是分层编译,那么配置的$\frac{1}{3}$线程用于C1队列,其余的用于C2队列。

什么时候需要进行编译线程的调优呢?当使用的单核CPU的时候,使用1个编译线程会提升性能。因为如果编译线程配置的比较多,会加大线程间的调度,降低性能。而且编译线程的数量也只会影响“Warm-Up Period”,在这之后,编译线程会无事可做,进入睡眠。当在多核CPU上面同时运行了多个CPU,并且采用了分层编译,可以适当减少编译线程。

另外一个和编译线程相关的参数是-XX:+BackgroundCompilation,它默认为true,也就是编译线程是后台运行的。如果设置为false,编译线程就是同步的了,也就是说如果方法没有编译完成,它就不会进行执行(也就是不使用解释执行)。


方法内联

方法内联可以提升性能,特别是对于经常使用的get和set方法(它们调用次数很多,并且代码量小),JVM一般都会将它们做内联处理。我们可以通过设置-XX:-Inline来取消内联,不过我们绝对不会这么做。

JVM通过方法执行次数执行次数来决定是否内联一个方法。执行次数一般是JVM内部来计算的,没有参数可以控制。对于大小可以通过-XX:MaxInlineSize=N(单位字节)来设置。


逃逸分析

逃逸分析通过动态分析对象的作用域,为其它优化手段提供依据,发生逃逸行为的情况有两种:方法逃逸线程逃逸

  • 方法逃逸

    当一个对象在方法中定义之后,作为参数传递到其它方法中。

  • 线程逃逸

    如类变量或实例变量,可能被其它线程访问到。

如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配。可以通过-XX:+DoEscapeAnalysis(默认开启)开启逃逸分析。


反优化

反优化指的是编译器会删除之前编译的代码,它会导致应用的性能下降,直到JVM对代码进行了重新编译。有两种情况会导致反优化:Not Entrant CodeMade Zombie

  • Not Entrant Code

    如果JVM发现之前编译过的代码,由于一些原因已经不可能会被执行到了,会进行反优化,释放Code Cache的空间。

  • Made Zombie

    如果采用了分层编译,首先它会采用client编译器,随着代码的执行次数不断增加,最终会使用server编译器再次编译代码,并对原有代码进行替换。在替换的过程中,首先将原有代码设置为Not Entrant,替换完成后再将原有代码设置为Zombie。

被标识为Not Entrant和Zombie的代码,都会进行反优化,因为这些代码都是存放在Code Cache当中,而Code Cache的大小是有限的,反优化会释放Code Cache的空间,有利于编译其它代码。