深入理解JVM之JIT编译器(二)

上篇是分析了一下前段编译器,主要过程完成从java代码到字节码的转变,它的改进顶多是提高程序的编码速度和效率。本篇尝试探索JIT编译器,它能够完成从字节码到本地机器码的转变,从而真正的影响程序的运行效率。

概念

部分商用虚拟机,程序最初是通过解释器(Interpreter)进行解释执行,当发现某个部分代码频繁执行的时候,就会将这些代码认定为「热点代码」(即 Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成与本地平台相关的机器码,并进行各种优化,完成这个任务的编译器成为即时编译器(Just In Time Compiler,简称JIT编译器)。(下文所指均为JIT)关于即时编译器需要知道的几点:

  • 它并不是VM必需的部分,java虚拟机规范并没有规定它必须存在,所以也没限定如何去实现。
  • 即时编译器编译性能的好坏、代码优化程度高低是衡量商用虚拟机优秀与否最关键指标之一。

Hotspot VM的JIT编译器

解释器和编译器

不是所有Java虚拟机均采用二者并存的架构,但是一些主流商用虚拟机如Hotspot、J9都会同时包含二者。关于二者的优势如下:

  1. 程序刚启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行字节码。
  2. 程序运行之后,经过一段时间,编译器可以逐渐发挥作用,把更多的代码编译为本地机器码,获得更高的执行效率。

在整个虚拟机执行架构中,解释器和编译器经常配合工作,如下图所示:

上图中内置了两个JIT编译器,分别称Client Compiler和Server Compiler,简称为C1编译器和C2编译器。目前主流的Hotspot VM(jdk1.7及以前版本虚拟机),默认采用与其中一个编译器直接配合,程序用哪个,取决于虚拟机运行模式,用户可以使用-client和-server参数强制指定虚拟机运行在client和server模式。
另外,通过下图的三种命令可以强制虚拟机运行的模式,分别为:Mixed Mode(混合模式,默认情况下解释器和编译器搭配使用)、Interpreter Mode(解释模式)、Compiled Mode(编译模式)。


了解完解释器和编译器之后,我们想知道那些代码会被编译?在什么情况下会触发编译?基于这两个问题,来了解编译对象和触发条件。

编译对象

在运行过程中会被即时编译器编译的「热点代码」有两类,如下:

  • 被多次调用的方法
  • 被多次执行的循环体

关于这两种情况,有两点解释:对于第一种情况,是由方法调用触发的编译,理所当然的会以整个方法体作为编译对象,这种编译是虚拟机中标准的JIT编译。对于第二种情况,编译动作由循环体触发,但是编译器仍旧会以整个方法作为编译对象,由于这种方法发生在方法执行过程中,所以称为栈上替换(On Stack Replacement,简称OSR编译,因为方法栈帧还在栈上)。

触发条件

正如上面所说的“多次”并不够严谨和具体,那么如何才算是“多次”呢?以及怎么样去统计一个方法和一段代码被执行多少次呢?回答了这两个问题就是回答了触发条件。判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为叫「热点探测」。关于热点探测有两种方式,如下:

  • 基于采样的热点探测
    虚拟机会周期性检查各个线程的栈顶,如果发现某些方法长期占据栈顶,那么会被认为是「热点代码」。简单、高效,但是不够精确,因为某些方法会因为线程阻塞或其他原因扰乱热点探测。
  • 基于计数器的热点探测(Hotspot VM采用)
    虚拟机为每个方法或者代码块建立计数器,统计执行次数,超过一定阀值会被认为是「热点代码」。

Hotspot VM使用第二种,并且为每个方法准备了两种计数器:方法调用计数器和回边计数器(在字节码中遇到控制流向后跳转的指令称为“回边 ”)。在确定虚拟运行参数的情况下,这两个计数器都有一个确定的阀值,超过这个阀值就会触发JIT编译。方法调用计数器触发即时编译的交互过程如下图:

注意:方法调用计数器统计的并不是被调用的绝对次数,而是一个相对的执行频率,即一段时间被被调用的次数。

编译过程

在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译还未完成之前,都仍然按照解释方式执行,而编译动作则在后台编译线程中进行。至于在后台如何执行编译过程,Client Compiler和Server Compiler的编译过程是不同的。二者大致区别如下:

  • Client Compiler:主要关注点在局部优化,而放弃了耗时较长的全局优化手段。
  • Server Compiler:是专门面向服务器端的典型应用并为服务端的性能配置特别调整过。

从即时编译的角度看,Server Compiler是比较慢,但其编译速度又远远超过「静态优化编译器」,而且比Client Compiler输出的代码质量高,可以减少本地代码执行时间,从而抵消额外的编译时间。

编译优化技术

之所以有编译方式执行本地代码比解释方式更快这样的共识,原因很简单,是因为虚拟机设计团队几乎把对代码所有的优化措施集中在了即时编译器之中,因此一般来说,即时编译器产生的本地代码会比Javac产生的字节码更优秀。常用优化技术如下:

公共子表达式消除

语言无关,比如像:b乘c、c乘b这样的表达式值都是一样的可以直接替换。

数组范围消除

语言相关,主要思路就是尽可能把运行期检查提任务前到编译器进行,以至于在循环遍历的时候不需要每次都要判断变量大小是否超过数组范围,带来隐式开销,只要在编译期根据数据流获得数组的length,并且判断下标没有越界,执行的时候就无需判断了。

方法内联

为了消除方法调用的成本,同时为其他优化手段建立好的基础。因为很多方法分开看是有意义的,如果不做方法内联,即使进行了无用代码消除,也无法发现任何“Dead Code”。

逃逸分析

并不是直接的优化手段,而是为其他优化手段提供分析技术。逃逸分析的基本行为就是分析对象的作用域,比如一个对象在一个方法中被定义,可能被外部方法所引用,例如作为方法参数传递到其他方法中,这被称为方法逃逸。甚至可能被其他线程访问,例如赋值给类变量或其他线程访问的实例变量,就是线程逃逸。

因此,如果能证明一个不会发生方法或者线程逃逸,则可以为这个变量进行一些高效优化。优化措施如下:

  • 栈上分配
    将确定不会逃逸的对象在在栈帧上进行创建分配内存,这样方法结束时,对象就会随着栈帧出栈而销毁,减少堆内存垃圾回收的压力。
  • 同步消除
    如果确定一个变量不会线程逃逸,也就说明该变量不会发生线程竞争,从而消除掉该变量的同步措施。
  • 标量替换
    像java中的原始数据类型如int、long均称为标量(表示无法分解为更小的数据来表示了),如果一个数据可以继续分解,则为聚合量,而java中的对象则为典型的聚合量。如果一个对象不会被外部访问,而这个对象可以被拆散分解,那么就不去创建这个对象,而是直接创建被使用到的成员变量。而这些成员变量除了被分配在栈上(栈上的数据很容易会被虚拟机分配到物理高速寄存器)进行读和写,还为后续优化创造基础条件。

本篇主要了解解释器和编译器的特点和各自优势、然后总结了编译对象和编译条件,最后介绍了编译过程以及用到的一些主要的编译优化技术,算是对即时编译器有个基本的理解了。

-EOF-