JIT即时编译器
解释器与编译器
在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” (Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,简称 JIT 编译器)。
在HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称C1编译器和C2编译器。Client Compiler编译器开启的时间较早,应用启动后不久Client Compiler就开始进行编译,而Server Compiler运行时间较晚,它会等程序运行一段时候后才开始进行编译,但是Server Compiler编译的代码性能较高。
HotSpot虚拟机会根据自身版本与宿主机器的硬件性能会自动选择运行模式,用户也可以使用 -client
和 -server
来参数去强制指定虚拟机运行在 Client 模式和 Server 模式。
无论采用的编译器是Client Compiler还是Server Compiler,解释器与编译搭配使用的方式在虚拟机中称为“混合模式”,
用户可以通过参数 -Xint
强制虚拟机运行于“解释器模式”,这里编译器完全不介于工作,全部代码都使用解释方式执行。
另外,也可以使用参数 -Xcomp
强制虚拟机运行于“编译器模式”,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
这里我们也是可以查询一下我们机器上所采用的方式,它默认的是“混合模式”(Mixed Mode),如下:
分层编译及代码缓存
基于上述所说的Client Compiler和Server Compiler两种编译器的优缺点,虚拟机一般会启动分层编译的策略(开启分层编译参数:-XX:+TieredCompilation
),分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层,程序解释执行,解释器不开始性能监控功能,可触发第1层编译。
- 第1层,也称为C1编译,将字节码编译出本地代码,进行简单、可靠的优化,如果必要将加入性能监控的逻辑。
- 第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启动一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更多的编译质量。
编译阶段既然会将字节码编译出本地机器码的话,那么肯定分配内存进行存储的,–XX:ReservedCodeCacheSize
参数就是设置了JIT编译器代码缓存的最大值,如果JIT代码缓存用完了,一般会抛出“CodeCache is full”错误。jdk7默认值为 32m~48m,jdk8默认值为240m。
编译对象与触发条件
我们一开始就说了虚拟机将会把热点代码编译成与本地平台相关的机器码,那么什么是热点代码呢?
判断一段代码是不是热点代码,是不是需要出发即时编译,这样的行为称为热点探测,主要的热点探测判定方式有两种,分别如下:
-
基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。
基于采样的热点探测的好处就是实现简单、搞笑,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易受到线程堵塞或别的外界因素的影响而扰乱热点探测。 -
基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”,这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
HotSpot虚拟机就是基于第二种——基于计数器的热点探测,它为每个方法准备了两类计数器:方法调用计数器和回变计数器
方法调用计数器
用于统计方法被调用的次数,它默认的阈值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次,这个阈值可以通过虚拟机参数 -XX:CompileThreshold
来人为设定。
当一个方法被调用时,会先检查该方法是否存在JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行,如果不存在已被编译过的版本,则将此方法的调用计数器加1,然后判断方法调用计数器与回边调用计数器之和是否超过方法调用计数器的阈值,如果已超过阈值,那么就会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,及一段时间之内方法被调用的次数。当超过一定的时间限制,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减。
对于上述所说的方法调用计数器热度的衰减,我们也是可以通过虚拟机参数 -XX:-UseCounterDecay
进行关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,绝大部分代码就会被编译器编译出本地代码。另外,还可以使用 -XX:CounterHalfLifeTime
参数来设置半衰周期的时间,单位是秒。
回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳的指令称为“回边”。与方法调用计数器不同的是,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。
方法内联
说方法内联之前,我们先来看一段代码,如下:
我们觉得上述红框中的两种方法,哪一种性能会更好,我们从虚拟机执行的角度来看,可能会觉得第二种方式直接性能好,因为第一次方法调用了其Getter方法,我们都知道一个方法被调用,都意味着这个方法就会被封装成栈帧,然后会进行入栈出栈操作,所以性能就会差一点。
但是现在我们的虚拟机默认的是开启了方法内联,它会自动将我们调用第一种调用方式转化为第二种调用方式,这就是方法内联。方法内联就避免了把方法打包成栈帧、出入栈等操作。
其实方法内联也是有条件的,虚拟机会判断一个方法的热度,以及方法的大小,虚拟机判定方法内联的热点是无法调整的。我们可以调整的是方法编译后的字节码的大小,如果一个方法热点足够的话,它的字节码大小默认小于325的时候才会进行方法内联,这个值可以通过 -XX:MaxFreqInlinesSize
进行调整。
另外就是一个方法编译后的字节码如果小于35个字节,那么无论这个方法的热度是否足够,这个方法一定会被虚拟机进行方法内联处理。
逃逸分析
有关逃逸分析的介绍,我们在 Java内存区域 中的栈上分配就进行过详细的介绍 ,它在虚拟机中是默认开启的。