JVM之早期编译和晚期编译

在Java中,很多人都比较熟悉javac,因为我们刚开始学习java的时候,都是用javac把java文件编译成class文件,然后在用java命令运行class文件,对JIT的认识可能会模糊一点,所以我今天把java的两段编译过程给梳理了一下,看看每段编译过程做了什么。我们一般把从java文件编译为class文件的过程称为前端编译,把JIT称为后端编译。

前端编译

前端编译简单说就是把.java文件转为.class文件的过程。我们看看在转换过程中前端编译器做了哪些工作。主要是三个过程:

  1. 解析与填充符号表。
  2. 插入式注解处理器的注解处理过程。
  3. 分析与字节码的生成过程。

    1.解析与填充符号表

    1.1 词分分析和语法分析

    词法分析是将源代码字符流转变为标记(Token)集合,单个字符是代码 编写的时候最小的元素,而标记是编译时最小的元素,关键字、变量名、字面量、运算符都可以成为标记。
    词法分析是根据标记(Token)序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的属性表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构。例如 :包,类型,修饰符,运算符,接口,返回值甚至注释等都可以是一个语法结构。

    1.2 填充符号表

    符号表是一组由符号地址和符号信息构成的表格,符号表中所登记的信息在编译的不同阶段都要用到,语义分析中,符号表登记的信息用于语义检查和生成中间代码,在目标代码生成阶段,当对符号进行地址分配时,符号表时 地址分配的依据。

    2.注解处理器

    在JDK1.5之后,Java语言提供了对注解(Annotation)的支持,这些注解与代码一样在运行期发挥作用,JDK6中实现了JSR-269规范,提供了一组插入式注解处理器的标准API在编译期对注解进行处理,我们可以把它看作是一组编译器的插件,在这些插件里面可以读取、修改、添加抽象语法树中的任意元素,如果这些插件对抽象语法树进行了修改,编译器将会回到解析与填充符号表的过程重新处理,直到所有插入式注解处理器没有在对抽象语法树进行任何修改,每一次循环称为一个Round。

    3.语义分析和字节码生成

    3.1 标注检查

    标注检查步骤检查的内容包括诸如变量使用前是否已经声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查步骤还有一个重要的动作就是常量折叠。
    int a=1+2;
    在语法树上依然能够看到字面量“1”,“2”以及操作符“+”;但是经过常量折叠之后,它会被折叠为自变量“3”,所以在代码定义“int a=1+2”,比起直接定义“int a=3”并不会增加程序运行期哪怕一个CPU指令的运算量。

    3.2 数据及控制流分析

    数据以及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出局部变量在使用前是否已经赋值,方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理等问题,编译期间的数据以及控制流分析与类加载时的数据以及控制流分析目的基本是一致的,但是检验范围有所区别。

    3.3 解语法糖

    语法糖,也称糖衣语法,是一个术语指在计算中语言中添加某种语法,这种语法对计算机的功能并没有影响,但是更方便程序员使用,简单的
    说,使用语法糖可以增加程序的可读性,从而减少代码出错的机会。
    Java中最常用的语法糖主要有:泛型,变长参数,自动装箱|拆箱等,虚拟机运行时不支持这些语法,他们在编译阶段还原为基础的语法结构,这个过程称为解语法糖!
    3.3.1 可变长参数
    源代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public static void main(String[] args){
    String [] params=new String[]{
    "111","222","333","444"
    };
    print("AAA","BBB","CCC","DDD");
    print(params);
    }
    public static void print(String... params)
    {
    System.out.println();
    for (int i = 0; i < params.length; i++)
    {
    System.out.print(params[i]+"~");
    }
    }

print方法的参数为可变长的参数,而我们传入了params参数,print方法也接收了,说明可变长参数是通过数组实现的,反编译.class文件后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] paramArrayOfString)
{
String[] arrayOfString = { "111", "222", "333", "444" };

print(new String[] { "AAA", "BBB", "CCC", "DDD" });
print(arrayOfString);
}

public static void print(String[] paramArrayOfString)
{
System.out.println();
for (int i = 0; i < paramArrayOfString.length; ++i)
{
System.out.print(paramArrayOfString[i] + "~");
}
}

3.3.2 泛型与类型擦出

泛型是JDK 1.5的一项新特性,它的本质是参数化类型(Parameterized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。
源代码:

1
2
3
4
5
6
7
8
9
public static void main(String[] args){
List <Integer> listInt=new ArrayList <Integer>();
List <String> listString=new ArrayList <String>();

Map<String, String> map = new HashMap<String, String>();
map.put("AAA", "BBB");
map.put("CCC", "DDD");
System.out.print(map.get("AAA"));
}

反编译后的代码:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] paramArrayOfString)
{
ArrayList localArrayList1 = new ArrayList();
ArrayList localArrayList2 = new ArrayList();

HashMap localHashMap = new HashMap();
localHashMap.put("AAA", "BBB");
localHashMap.put("CCC", "DDD");
System.out.print((String)localHashMap.get("AAA"));
}

Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,就已经被替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码。因此对于运行期的Java语言来说,ArrayList 与ArrayList编译出来的代码是一样的,所以说泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型被称为伪泛型。

3.3.3 foreache循环

!http://www.jianshu.com/p/628568f94ef8

3.3.4 自动装箱/拆箱和条件编译

!http://www.jianshu.com/p/946b3c4a5db6

3.3.5 枚举类型

!http://www.jianshu.com/p/ae09363fe734

3.3.5 内部类和闭包

!http://www.jianshu.com/p/f55b11a4cec2

前端编译器总结

分析完前端编译器我们可以看到,前端编译器的一个工作是分析源代码的合法性,然后把源代码转换为class文件,另一个工作就是引入语法糖,可以提高程序员的工作效率,提高安全检查。

后端编译器

了解后端编译器之前,我们要先了解Java是怎么运行代码的,前面我们说过,前端编译器把.java代码编译成.class文件,然后jvm把.class文件加载到内存中,因为.class的二进制文件只有jvm能识别,底层的操作系统无法识别,所以要想运行.class文件,还要把.class中的指令翻译成对应机器的指令,这样,才能运行我们的方法。所以就产生了两种运行机制:1,解释执行,就是遇到一条jvm的指令,翻译成对应的机器的指令,这样做的优点是,加载进来的class文件可以马上运行,缺点是每条指令都要通过翻译才能运行,速度比较慢。2,通过JIT编译器执行,JIT编译器全程Just In Time,翻译过来就是即时运行。因为解释执行要一条一条的翻译指令,导致速度变慢,所以JIT的工作就是把常用的方法体或者循环体作为对象,把他们整体编译为机器的字节码,然后保存下来,这样下次运行这些代码的时候就可以直接找到翻译好的机器码执行,提高效率。
在现在的Hotspot VM版本中,一般都是采用解释器和JIT并存的方式。

大家可能会想为什么不直接使用JIT,在方法第一次使用的时候就编译成本地代码,后面就不用解释执行了。原因是:

  1. 使用JIT把方法编译成本地代码耗时很长,某些方法执行次数比较少,如果使用JIT编译的耗时大于方法总体运行的时间,对我们来说是不划算的。
  2. JIT编译之后的本地代码比较大,占用的内存比较多,如果一来上就把所有的方法编译成本地代码,对内存的使用也是不友好的。

    热点代码

    热点代码对象:
    1) 多次被调用的方法体
    2) 多次运行的循环体
    统计热点代码的两种方法:
  3. 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这段方法代码就是“热点代码”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  4. 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。
    在Hotspot中,使用的是第二种方法进行探测热点代码的,所以在方法中为维护两个计数器,一个叫做方法调用计数器,一个叫做回边计数器。这两个计数器在JVM代码在都有体现。
    1
    2
    InvocationCounter* invocation_counter() { return &_invocation_counter; }
    InvocationCounter* backedge_counter() { return &_backedge_counter; }

方法调用计数器

方法调用计数器顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次。这个阈值可以通过参数-XX:CompileThreshold来人为设定。当一个方法被调用时,会检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器和回边计数器值之和是否超过方法调用计数器的阈值。如果已经超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果这个参数不做任何设置,那么方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会少一半,这个过程称为方法的调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作实在虚拟机进行垃圾回收时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为”回边”。显然,建立回边技术其统计的目的就是为了触发OSR编译。关于回边计数器的阈值,虽然HotSpot也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前虚拟机实际上并未使用此参数,因此我们需要设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式如下:
Client模式:方法调用计数器阈值 × OSR比率 / 1000,其中OSR比率默认值933,如果都取默认值,Client模式下回边计数器的阈值应该是13995
Server模式:方法调用计数器阈值 × (OSR比率 – 解释器监控比率) / 100,其中OSR比率默认140,解释器监控比率默认33,如果都取默认值,Server模式下回边计数器阈值应该是10700
当解释器遇到一条回边指令时,会先查找将要执行的代码片段中是否有已经编译好的版本,如果有,它将会优先执行已编译好的代码,否则就把回边计时器的值加1,然后判断方法调用计数器与回边计数器值之和是否已经超过回边计数器的阈值。当超过阈值之后,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法计数器不同,回边计数器没有热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

OSR编译

OSR编译是一种替换正在栈上运行的栈帧的技术,这么说可能比较抽象,具体的OSR的解释大家可以参考R大写的回答:https://www.zhihu.com/question/45910849

分层编译

分层编译的意思是不一次性把方法直接编译为本地代码,而是通过在方法的运行期不断收集数据,然后不断优化,这样既不会过度优化,也可以节约编译时间。
之前提到了client模式和server模式。这两种模式下使用的JIT编译也是不一样的,没有分层编译之前都是通过解释器和一种编译器进行混合使用,有了分层编译之后就放在一起使用了。我们把client模式下的编译器称为c1编译器,server模式下的编译器称为c2编译器。一般分为以下五层:
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)
触发的条件就是根据方法调用计数器和回边计数器的值来确定编译的层级。

大方法的弊端

默认情况下,JIT对方法方法字节码超过8000字节的方法不会开启JIT编译,所以一般建议方法体不要过长,不过也可以通过参数进行修改:

1
2
-XX:+DontCompileHugeMethods
-XX:HugeMethodLimit=8000

DontCompileHugeMethods表示对大方法开启JIT编译,HugeMethodLimit表示方法体的限制。
参考案例:https://www.zhihu.com/question/263322849

JIT优化技术

  1. 标量替换 http://www.javajs.cn/JVM%E4%B9%8B%E6%A0%88%E4%B8%8A%E5%88%86%E9%85%8D/#more
  2. 方法内联 http://www.javajs.cn/JVM%E4%B9%8B%E6%A0%88%E4%B8%8A%E5%88%86%E9%85%8D/#more
  3. 栈上分配 http://www.javajs.cn/JVM%E4%B9%8B%E6%A0%88%E4%B8%8A%E5%88%86%E9%85%8D/#more
  4. 公共子表达式消除
    如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那E的这次出现就成公共子表达式,可以用原先的表达式进行消除。
  5. 数组范围检查
    系统将自动进行数组上下界的范围检查。

<参考> https://www.jianshu.com/p/2c55995581f1
<参考> http://blog.zhuxingsheng.com/blog/the-way-of-jit-optimization.html
<参考> http://www.importnew.com/28453.html
<参考> https://segmentfault.com/a/1190000004649033
<参考> https://www.zhihu.com/question/45910849