JIT编译

Mr.ZhangJava大约 13 分钟

JIT编译

提示

请解释下方法内联、逃逸分析等动态编译优化方法

上一节中,我们概述了Java编译执行的整个过程,其中包括:前端编译、类加载、解释执行、JIT编译、AOT编译。本节我们详细讲解其中的JIT编译。尽管JIT编译相关的知识比较偏底层,但对于程序员来说也并非完全没有感知,比如在做性能测试时,我们经常会发现,循环多次执行被测代码时,被测代码的执行速度会变快。这是因为前几次代码的执行为解释执行,但循环执行多次之后,虚拟机便发现代码为热点代码,就会启动JIT编译,在进行编译的同时进行编译优化,将字节码编译为高效的机器码。之后执行代码便直接执行高效的机器码,而非解释执行字节码,因此,代码的执行速度就变快了。实际上,JIT编译过程会涉及非常多的细节内容,这其中就包括JIT编译器、分层编译、热点探测、编译优化,接下来我们就详细讲解一下JIT编译的这4部分内容。

一、JIT编译器

HotSpot虚拟机支持两种JIT编译器:Client编译器和Server编译器。其中,Client编译器也叫做C1编译器,Server编译器也叫做C2编译器。两种编译器的区别在于编译时间和编译优化程度有所不同。编译时间和编译优化程度成反比。编译优化程度越高,最终生成的机器码就越高效,当然,也要付出更多的编译时间作为代价。Client编译器只进行局部的编译优化,是一种编译时间短、编译优化程度低的编译器,Server编译器进行局部和全局的编译优化,是一种编译时间长、编译优化程度高的编译器。

局部优化只关注局部的代码,分析局部是否有值得优化的地方,全局优化关注全局的代码,综合更多的信息,分析代码是否有大的值得优化的地方。这就有点类似《设计模式之美》中讲到的小重构和大重构。小重构针对局部的函数、类进行改动,耗时比较少,而大重构针对全局的代码结构进行改动,耗时比较多,对代码质量的提高也更加显著。

我们常说JVM有两种运行模式:Client模式和Server模式,实际上,这两种运行模式就是基于JIT编译器类型来区分的。在Java7及其以前版本中,我们可以通过-client或-server这两个VM参数来指定JIT编译器的类型。对于长时间运行的程序,我们可以牺牲一些编译时间,生成一些更加高效的机器码,因此,服务器一般选择Server编译器,对应Server工作模式。相反,客户端一般选择Client编译器,对应Client工作模式。

二、分层编译

在Java7之前,虚拟机要么选择Client编译器,要么选择Server编译器,两种编译器无法同时使用。为了解决这个问题,Java7引入了分层编译的技术,对编译类型做了更加细化的区分,能够让虚拟机根据不同代码、不同时刻的实际运行情况,选择不同的编译类型,编译工作更加精细化和有针对性。

分层编译主要分为以下5个层级。我们只需要大概知道有这么一回事即可,不需要深入研究各个层级到底都做了哪些编译优化。

第一级:解释执行

第二级:使用不带编译优化的Client编译器

第三级:使用仅带部分编译优化的Client编译器

第四级:使用带有所有编译优化的Client编译器

第五级:使用Server编译器

分层编译技术在Java7中并不是特别成熟,因此,JVM默认是不开启的。我们需要使用-XX:+TieredCompilation参数来开启分层编译。随着技术演进,分层编译技术在Java8中变得稳定成熟,因此,JVM默认开启分层编译技术,并且,无论开启还是关闭分层编译,-client和-server参数都不再有效。当分层编译技术被关闭时,JVM直接选择使用Server编译器。

三、热点探测

前面提到,只有当代码多次运行,被判定为热点代码时,JVM才会触发JIT编译。那么,具体什么样的代码才是热点代码呢?实际上,主要这样两类会被认定为热点代码:第一类是被多次执行的方法,第二类是被多次执行的循环。需要注意的是,JIT编译的对象是方法。对于第二类热点代码,编译器对包含这个循环的方法进行编译,而非只编译循环这一小部分代码。

HotSpot虚拟机使用计数器来统计方法或循环的执行次数,以此来判断方法或循环是否是热点代码。JVM对每个方法维护两个计数器:方法调用计数器和回边计数器。方法调用计数器用来统计方法的执行次数。回边计数器用来统计方法内循环的执行次数。

当某个方法的方法调用计数器的值和回边计数器的值的总和超过某个阈值时,虚拟机就会对这个方法进行JIT编译。在Client编译器下,这个阈值默认为1500,在Server编译器下,这个阈值默认为10000。当然,我们也可以通过参数-XX:CompileThreshold指定阈值。不过,如果虚拟机开启了分层编译,那么虚拟机不再使用以上固定的阈值,转而使用动态阈值。动态阈值根据当前编译方法数以及编译线程数动态计算得到。

从上述描述,我们可以发现,随着运行时间的增长,只要某个代码一直在执行,就总是会出现调用次数高于阈值的那一刻。这就会导致一些不怎么频繁运行的代码也会被判定为热点代码。为了解决这个问题,虚拟机引入了热度衰减机制。在超过一定的时间限制之后(通过参数-XX:CounterHalfLifeTime来设置),如果某个方法没有达到触发JIT编译的阈值要求,那么这个方法的方法计数器的值就减半。当然,我们可以通过设置-XX:-UseCounterDecay来关闭热度衰减机制。需要注意的是,只有方法计数器存在热度衰减机制,回边计数器不存在热度衰减机制。

四、编译优化

不管是前端编译、JIT编译,还是AOT编译,编译器要做的工作除了基本的编译之外,还有另外一项非常关键的工作,那就是编译优化。所谓编译优化指的是,编译器在编译代码时,对代码进行优化,减少无效、冗余代码,以便生成更加高效的机器码。在一定程度上,编译优化的质量决定了编译器是否优秀、编程语言是否高效。

JIT编译的编译优化策略有很多,比如经典的方法内联、逃逸分析、无用代码消除、循环展开、消除公共子表达式、范围检查消除、空值检查消除等等。在这个网页open in new window中罗列了HotSpot虚拟机中的JIT编译器所用到的编译优化策略。对于这些编译优化策略,我们没有必要去深入研究,毕竟编译优化对于大部分程序员来说都是黑盒子,没有感知。不过,为了让你对编译优化策略有一些直观的认识,我们拿其中的方法内联和逃逸分析作为示例来讲解。

1)方法内联

在第2节中,讲到函数调用的底层原理时,我们讲到,函数调用会涉及到栈帧的压栈、出栈,现场的保存和恢复,相对于普通的代码执行,要慢很多。为了减少函数调用以提高代码执行效率,对于一些比较短小的函数,比如getter、setter函数,虚拟机在编译代码时,直接将这类函数代码嵌入到函数调用处。示例如下所示。当然,这会导致同一份函数代码被复制到各个地方,内存消耗增多,因此,这是一种空间换时间的优化手段。

public int getArrayMax(List<Integer> list) {
    int maxVal = Integer.MIN_VALUE;
    for (Integer data : list) {
      maxVal = max(maxVal, data);
    }
    return maxVal;
}

public int max(int a, int b) {
    return a>=b?a:b;
}

//max()函数内联到getArrayMax()函数
public int getArrayMax(List<Integer> list) {
    int maxVal = Integer.MIN_VALUE;
    for (Integer data : list) {
      maxVal = (maxVal>=data?maxVal:data);
    }
    return maxVal;
}

当然,并不是所有的函数都可以被内联的,除了刚刚讲到的函数比较短小的要求之外,函数的调用次数也要达到一定阈值要求。如果方法调用次数多(默认大于等于100次),默认情况下,方法的字节码大小小于325字节就会进行内联。如果方法调用次数少(少于100次),默认情况下,方法的字节码大小小于35字节才会进行内联。

实际上,除了可以减少函数调用之外,应用方法内联还可以为其他编译优化打基础。如下示例代码所示。在没有将print()函数内联到doLogic()函数之前,我们无法对print()函数进行优化,doLogic()调用print()函数,仍然需要执行分支判断语句。在将print()函数内联到doLogic()函数之后,因为type值明确为1,那么,编译器便可以触发无用代码删除这一编译优化,将其他无用代码分支删除,只保留一条print语句。

  public void print(String rawStr, int type) {
    if (type == 1) {
      System.out.println("hey " + rawStr);
    } else if (type == 2) {
      System.out.println("hi " + rawStr);
    } else {
      System.out.println("hello " + rawStr);
    }
  }

  public void doLogic() {
    //....
    print("wangzheng", 1);
    //...
  }
  
  //print()函数内联到doLogic()函数,并做优化,移除无用的代码分支
public void doLogic() {
  //....
  System.out.println("hey wangzheng");
  //...
}

在平时的开发中,我们经常会听到这样的说法,将方法设置为final会触发方法内联,实际上,这样的说法是不对,但是,将方法设置为final确实有助于触发方法内联,特别是在应用多态的情况下。我们举例解释一下,示例代码如下所示。

public void func(B b) {
   //...
   b.f();
   //...
}

基于Java中多态的运行原理,b.f()执行的是b所引用的对象上的f()函数,而b所引用的对象有可能是B类对象,也有可能是B类的任意子类对象。在执行JIT编译时,编译器需要分析B类的继承关系,查看f()函数是否存在重载。如果f()函数存在重载,那么,编译器将无法确定在func()函数中内联哪个f()函数,也就无法进行方法内联。如果f()函数不存在重载,那么,编译器将B类的f()函数内联到func()函数中。

如果某个函数被设置为final,那就说明这个函数无法被重载,于是,编译器便无须耗费时间去分析继承关系、查看重载情况,直接进行内联即可,从而节省了方法内联编译优化的时间。这就是刚刚提到的final有助于触发方法内联的原因,也是很多人误以为将方法设置为final就会触发方法内联的来由。

2)逃逸分析

逃逸分析指的是,JIT编译器通过分析对象的使用范围,来优化对象的内存存储方式和访问方式,以提高代码的执行效率。针对不同的逃逸分析结果,编译器有3种不同的优化策略,它们分别是:栈上分配、标量替换、锁消除。

**我们先来看下栈上分配。**我们知道,函数内的局部变量分配在栈上,只能在函数内部访问,对象分配在堆上,可以被多个函数访问。相对而言,堆上对象的创建和回收的过程,涉及复杂的分配和回收算法,要比栈上数据的创建和回收,慢很多。如果编译器经过分析以后,发现某个对象的使用范围局限于某个函数内部(专业的说法是没有逃逸到方法外),那么,编译器便可以启动栈上分配编译优化,将对象作为局部变量直接分配在栈上,相应的,创建和回收的对象的耗时就减少了很多。

**我们最后看下标量替换。**如果某个对象只在某个函数内使用,并且函数内只访问对象的基本类型成员变量等标量数据,那么,编译器就可以启动标量替换这一编译优化,使用基本类型变量替代对象。示例代码如下所示。

public void func() {
  Student stu = new Student();
  stu.age = 19;
  stu.score = 89;
  //...后续也只访问了stu的基本类型成员变量
}

//标量替换
public void func() {
  int age = 19;
  int score = 89;
  //...
}

**我们再来看下锁消除。**实际上,在第35节讲解synchronized关键字的性能优化手段时,我们已经讲到过锁消除。对不存在多线程并发访问的代码,编译器会去掉其中保证线程安全的加锁逻辑。如下示例代码所示。为了保证多线程操作的安全性,StringBuffer中的append()函数在设计实现时加了锁。但是,在下面的代码中,strBuffer是局部变量,不会被多线程共享,更不会在多线程环境下调用它的append()函数(专业的说法是没有逃逸到线程外)。因此,append()函数的锁可以被优化消除。

public class Demo {
  public String concat(String s1, String s2) {
    StringBuffer strBuffer = new StringBuffer();
    strBuffer.append(s1);
    strBuffer.append(s2);
    return strBuffer.();
  }
}

五、课后思考题

由于JIT编译对于我们来说几乎是一个黑盒子,因此,当编写程序对两段代码进行性能测试时,我们无法确定一段代码的性能比另一段代码的性能高,是源于被测代码本身的性能表现,还是因为触发了JIT编译。也就是说,测试环境不确定,导致测试结果的正确性无法保障,对于这样一个问题,你有什么好的解决方法呢?

Loading...