JIT动态编译技术

一个Java程序执行的过程,就是执行字节码指令的过程,一般这些指令会按照顺序一条一条指令解释执行,这种就是解释执行,解释执行的效率是非常低下的,因为需要先将字节码翻译成机器码,才能执行。

而那些被频繁调用的代码,比如调用次数很高或者for循环次数很多的那些代码,称为热点代码,如果按照解释执行,效率是非常低下的。

为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称JIT编译器。这些被编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。

即时编译器类型

在HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器。

  • C1编译器:是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI应用对界面启动速度就有一定要求,C1也被称为Client Compiler。
  • C2编译器:是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,C2也被称为Server Compiler。

热点代码

热点代码有两类:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

JIT即时编译后的机器码都会放在CodeCache里,JVM提供了一个参数-XX:ReservedCodeCacheSize用来限制CodeCach的大小。如果这个空间不足,JIT就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT编译器会一直尝试去优化代码,从而造成了CPU占用上升。

# java -XX:+PrintFlagsFinal -version | grep ReservedCodeCacheSize
    uintx ReservedCodeCacheSize                     = 251658240                           {pd product}
java version "1.8.0_151"

-XX:ReservedCodeCacheSize默认大小为240M。

热点探测

J9使用过采样的热点探测技术,但是缺点是很难精确的确认一个方法的热度。

在HotSpot虚拟机中采用基于计数器的热点探测,为每个方法建立一个计数器,用于统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。

虚拟机为每个方法准备了两类计数器:方法调用计数器和回边计数器(Back Edge Counter)。

方法调用计数器

方法调用计数器(Invocation Counter):用于统计方法被调用的次数,默认阈值在客户端模式下是1500次,在服务端模式下是10000次,可通过-XX: CompileThreshold来设定。

先来看一下运行模式:

# java -version
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)

从上面的输出可以看出jdk8中默认的运行模式为Server VM

再来看一下阈值的默认值:

# java -XX:+PrintFlagsFinal -version | grep CompileThreshold
     intx CompileThreshold                          = 10000                               {pd product}

回边计数器

回边计数器(Back Edge Counter):用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流在循环边界往回跳转的指令称为“回边”(Back Edge)。

虚拟机运行在服务端模式下,回边计数器的阈值计算公式为方法调用计数器阈值(CompileThreshold)*(OSR 比率(OnStackReplacePercentage)- 解释器监控比率(InterpreterProfilePercentage)/ 100。

其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10000*(140-33)/100=10700。

intx CompileThreshold                          = 10000                               {pd product}
intx InterpreterProfilePercentage              = 33                                  {product}
intx OnStackReplacePercentage                  = 140                                 {pd product}

建立回边计数器的主要目的是为了触发OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器码并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存中的机器码。

分层编译

在Java8中,默认开启分层编译。可以通过java -version命令查看到当前系统使用的编译模式。

# java -version
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)

mixed mode为分层编译。

使用-Xint参数强制虚拟机运行于只有解释器的编译模式下:

# java -Xint -version
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, interpreted mode)

使用-Xcomp参数强制虚拟机运行于只有解释器的编译模式下:

# java -Xcomp -version
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, compiled mode)

分层编译根据编译器编译、优化的规模和耗时,划分出5个不同的层次:

  • 第0层:程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层:使用C1将字节码编译为本地代码,进行简单、可靠的优化,不开启Profiling。
  • 第2层:仍然使用C1编译,仅开启方法调用计数器和回边计数器等Profiling。
  • 第3层:仍然使用C1编译,开启全部Profiling。
  • 第4层:使用C2将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

在不启用分层编译的情况下,当方法的调用次数和循环回边的次数总和,超过由参数-XX:CompileThreshold指定的阈值时,便会触发即时编译;当启用分层编译时,这个参数将会失效,会采用动态调整的方式进行。

编译优化技术

JIT编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。

方法内联

方法内联的优化行为就是把目标方法的字节码复制到发起调用的方法之中,避免发生真实的方法调用,这样就减少了虚拟机栈中一次栈帧的入栈和出栈。

C2编译器会在解析字节码的过程中完成方法内联。内联后的代码和调用方法的代码,会组成新的机器码,存放在CodeCache区域里。

另外,C2支持的内联层次不超过9层,太高的话,CodeCache区域会被挤爆,这个阈值可以通过-XX:MaxInlineLevel进行调整。相似的,编译后的代码超过一定大小也不会再内联,这个参数由-XX:InlineSmallCode进行调整。有非常多的参数,被用来控制对内联方法的选择,整体来说,短小精悍的小方法更容易被优化。

例如以下方法:

    private int add1(int x1, int x2, int x3, int x4) {
        return add2(x1, x2) + add2(x3, x4);
    }
    private int add2(int x1, int x2) {
        return x1 + x2;
    }

最终会被优化为:

    private int add(int x1, int x2, int x3, int x4) {
        return x1 + x2+ x3 + x4;
    }

下面通过一段代码来演示方法内联的过程:

public class CompDemo {
    private int add1(int x1, int x2, int x3, int x4) {
        return add2(x1, x2) + add2(x3, x4);
    }
    private int add2(int x1, int x2) {
        return x1 + x2;
    }

    public static void main(String[] args) {
        CompDemo compDemo = new CompDemo();
        //方法调用计数器的默认阈值10000次,我们循环遍历超过需要阈值
        for(int i=0; i<1000000; i++) {
            compDemo.add1(1,2,3,4);
        }

    }
}

设置VM参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

  • -XX:+PrintCompilation:在控制台打印编译过程信息。
  • -XX:+UnlockDiagnosticVMOptions:解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断。
  • -XX:+PrintInlining:将内联方法打印出来。

运行结果如下:

...
    159   29 %     4       com.morris.jvm.jit.CompDemo::main @ 10 (32 bytes)
                              @ 21   com.morris.jvm.jit.CompDemo::add1 (15 bytes)   inline (hot)
                                @ 3   com.morris.jvm.jit.CompDemo::add2 (4 bytes)   inline (hot)
                                @ 10   com.morris.jvm.jit.CompDemo::add2 (4 bytes)   inline (hot)
...

方法最后面有个hot关键字,说明已经触发了方法内联。

通过JITWatch工具也能发现触发了方法内联:

在这里插入图片描述
在这里插入图片描述
如果将循环次数减少至1000次,就不会触发方法内联了,修改后的运行结果如下:

....
    116   26       3       com.morris.jvm.jit.CompDemo::add2 (4 bytes)
    116   27       3       com.morris.jvm.jit.CompDemo::add1 (15 bytes)
                              @ 3   com.morris.jvm.jit.CompDemo::add2 (4 bytes)
                              @ 10   com.morris.jvm.jit.CompDemo::add2 (4 bytes)

热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  • 通过设置JVM参数来减小热点阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存。
  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体。

锁消除

在不需要保证线程安全的情况下,尽量不要使用线程安全容器,比如StringBuffer,由于StringBuffer中的append()方法被synchronized关键字修饰,会使用到锁,从而导致性能下降。

但实际上,在以下代码测试中,StringBuffer和StringBuilder的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候JIT编译会对这个对象的方法锁进行锁消除。

public class UnLock {
    public static void main(String[] args) {
        long timeStart1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            testStringBuffer("aaaaa", "bbbbb");
        }
        long timeEnd1 = System.currentTimeMillis();
        System.out.println("StringBuffer cost: " + (timeEnd1 - timeStart1) + "(s)");

        long timeStart2 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            testStringBuilder("aaaaa", "bbbbb");
        }
        long timeEnd2 = System.currentTimeMillis();
        System.out.println("StringBuilder cost: " + (timeEnd2 - timeStart2) + "(s)");
    }

    public static void testStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
    }

    public static void testStringBuilder(String s1, String s2) {
        StringBuilder sd = new StringBuilder();
        sd.append(s1);
        sd.append(s2);
    }
}

运行结果如下:

StringBuffer cost: 229(s)
StringBuilder cost: 190(s)

当我们把锁消除(-XX:-EliminateLocks)关闭后,运行结果如下:

StringBuffer cost: 570(s)
StringBuilder cost: 148(s)

锁消除关闭后测试发现性能差别有点大。

-XX:+EliminateLocks:开启锁消除(jdk1.8 默认开启)。

-XX:-EliminateLocks:关闭锁消除

逃逸分析与标量替换

通过逃逸分析(Escape Analysis),JVM能够分析出一个新对象的使用范围,从而决定是否要将这个对象分配到堆上。

对象的三种逃逸状态:

  • GlobalEscape(全局逃逸):一个对象的引用逃出了方法或者线程。例如,一个对象的引用是复制给了一个类变量,或者存储在在一个已经逃逸的对象当中,或者这个对象的引用作为方法的返回值返回给了调用方法。
  • ArgEscape(参数逃逸):在方法调用过程中传递对象的引用给调用方法。
  • NoEscape(没有逃逸):该对象只在本方法中使用,未发生逃逸。

下面用一段代码来说明对象的三种逃逸状态:

package com.morris.jvm.gc;

/**
 * 演示逃逸分析的三种状态
 * 1. 全局逃逸
 * 2. 参数逃逸
 * 3. 没有逃逸
 */
public class EscapeStatus {

    private B b;

    /**
     * 给全局变量赋值,发生逃逸(GlobalEscape)
     */
    public void globalVariablePointerEscape() {
        b = new B();
    }

    /**
     * 方法返回值,发生逃逸(GlobalEscape)
     */
    public B methodPointerEscape() {
        return new B();
    }

    /**
     * 实例引用传递,发生逃逸(ArgEscape)
     */
    public void instancePassPointerEscape() {
        methodPointerEscape().printClassName(this);
    }

    /**
     * 没有发生逃逸(NoEscape)
     */
    public void noEscape() {
        Object o = new Object();
    }
}

class B {
    public void printClassName(EscapeStatus escapeStatus) {
        System.out.println(escapeStatus.getClass().getName());
    }
}

可以用JVM参数-XX:+DoEscapeAnalysis来开启逃逸分析,JDK8默认开启。

逃逸分析的性能测试:

package com.morris.jvm.gc;

/**
 * 演示逃逸分析的标量替换
 * VM args:-Xmx50m -XX:+PrintGC -XX:-DoEscapeAnalysis --> 682ms+大量的GC日志
 * VM args:-Xmx50m -XX:+PrintGC --> 4ms,无GC日志
 */
public class EscapeAnalysisDemo {

    public static void main(String[] args) {

        long start = System.currentTimeMillis();
        for (int i = 0; i < 1_0000_0000; i++) {
            allocate();
        }
        System.out.println((System.currentTimeMillis() - start) + " ms");
    }

    private static void allocate() {
        new Person(18, 120.0);
    }

    private static class Person {
        int age;
        double weight;

        public Person(int age, double weight) {
            this.age = age;
            this.weight = weight;
        }
    }

}

使用jvm参数-Xmx50m -XX:+PrintGC -XX:-DoEscapeAnalysis运行程序耗时682ms,控制台会打印大量的GC日志。

使用jvm参数-Xmx50m -XX:+PrintGC运行程序耗时4ms,控制台没有打印GC日志,也就是没有发生GC。由此可以发现开启逃逸分析后,对象分配的性能显著提升。

标量:一个数据无法再分解为更小的数据来表示,Java中的基本数据类型byte、short、int、long、boolean、char、float、double以及reference类型等,都不能再进一步分解了,这些就可以称为标量。

标量替换:如果一个对象只是由标量属性组成,那么可以用标量属性来替换对象,在栈上分配。

例如上面的Persion只是由int和double类型的属性构成,可以进行标量替换,替换后变成类似如下的代码:

    private static void allocate() {
        int age = 18;
        double weight = 120.0;
    }

变成上面的代码后,这样基本数据类型就可以在栈上分配了。

而下面的Person类无法进行标量替换,只能在堆上分配了:

    private static class Person {
        byte[] bytes = new byte[1024]; // 不是标量
        String name;
        int age;
    }

-XX:+EliminateAllocations:开启标量替换(jdk1.8 默认开启)。

-XX:-EliminateAllocations:关闭标量替换。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐