JIT即时编译技术在JVM中的应用及分析

目录

  • JIT即时编译技术在JVM中的应用及分析
    • 什么是JIT?
    • 解释执行和编译执行
    • JIT的发展历史
    • Hotspot虚拟机的工作模式
    • Hotspot热点探测技术
      • 方法调用计数器
      • 回边计数器
    • 分层编译
    • JIT优化技术
      • 逃逸分析
      • 栈上分配
      • 标量替换
      • 方法内联
    • 总结

JIT即时编译技术在JVM中的应用及分析

什么是JIT?

JIT(Just In Time),即时编译技术,它能够将Java中的字节码实时地编译成本地机器码并执行。JIT编译器并不止在Java中存在,在其他语言的VM中也可能存在JIT编译器。JIT的作用是将高级语言代码转换成本地机器码,以提高程序的执行效率和性能。通过解释执行与编译执行的结合,JIT编译器可以使得虚拟机更高效地运行代码。

解释执行和编译执行

在详细理解JIT原理前,需要先理解解释执行和编译执行的概念。

  • 解释执行(Interpreted Execution):在解释执行中,代码会被逐行解释器(Interpreter)逐行解释并执行。解释器会读取源代码的一条语句,然后将其转换成机器指令并立即执行。每执行一条语句,都需要解释器进行一次转换和执行操作。它的优点是:跨平台性好,即时性高(例如Python的交互式开发界面)。缺点是性能较差,并且同一段代码存在多次解释浪费性能的问题。
  • 编译执行(Compiled Execution):在编译执行中,程序的源代码会先经过编译器(Compiler)的处理,将源代码转换成本地机器码或字节码(中间代码),再执行。编译执行性能较好,并且可以进行许多优化。但是缺点是编译时间长,平台依赖性高,需要针对不同硬件平台进行适配。

为了中和两种模式,JIT编译器应运而生。

JIT介绍

JIT的发展历史

在JDK 10之前,Java的Hotspot虚拟机中内置了两种编译器,分别是:C1和C2编译器。两款编译器均使用C++语言编写。

C1编译器是一个简单快速的编译器,只关心编译,几乎不做优化,适合执行时间较短或对启动性能有要求的程序。应用启动后不久C1就会开始工作。

C2编译器是为长期运行的服务器程序做性能优化的编译器,适合为长期运行的应用程序做性能调优。在应用启动后一段时间,C2编译器才开始工作。

由于C2编译器的代码超级复杂,已几乎无法继续维护。所以从JDK10开始,C2编译器被使用Java语言编写的Graal取代。

Hotspot虚拟机的工作模式

无论采用的编译器是C1还是C2,解释器与编译器搭配使用的方式在虚拟机中称为“混合模式”。使用java -version指令能看到虚拟机当前所在的模式。

混合模式

可通过参数 -Xint 强制虚拟机运行在“解释器模式”下,所有代码将解释执行,编译器不会工作。

-Xcomp参数则强制虚拟机运行在“编译器模式”下,这会使虚拟机优先采用编译的方式执行程序,但解释器在编译器无法编译的情况下仍然会介入执行。

Hotspot热点探测技术

JIT会基于热点代码的信息来进行相关的优化,所以需要引入热点代码探测的技术。热点代码会在运行时被编译并缓存,以供下次调用。JVM提供了一个参数-XX:ReservedCodeCacheSize来设置CodeCache的大小,JDK7默认是为 32M-48M,JDK8默认值为240M。如果超过了这个缓存的大小,则JIT无法继续编译代码,会自动转为解释执行,性能就会下降。

目前,热点代码的探测方式主要有以下两种:

  1. 基于采样方式的热点探测(Sample Based Hot Spot Detection):周期性地扫描各线程的栈顶,若发现某方法出现在栈顶的频次很高,则认为该方法是热点方法。这样做的优点是:实现简单,缺点是无法精确地判定一个方法的热度,容易受到阻塞线程或其它外接因素干扰热点的探测。
  2. 基于计数器的热点探测(Counter Based Hot Spot Detection):虚拟机会为每个方法,甚至是代码块建立计数器,统计每个方法的执行次数,如果某个方法超过阈值则判定该方法为热点方法。

HotSpot虚拟机就是基于上述的第二种方法进行热点探测。它为每个方法准备了两类计数器:方法调用计数器回边计数器。只需大于任意一个计数器的阈值,就能够触发JIT编译。

方法调用计数器

方法调用计数器用于统计方法被调用的次数,若JVM运行在Client模式下,则默认阈值是1500次,而在Server模式下则是10000次。这个阈值也可通过XX:CompileThreshold参数来进行设定。

回边计数器

JVM使用回边计数器来记录循环代码中的回边(即循环的迭代)。当一个回边被频繁执行时,它的计数值会增加。回边计数器在Server模式中的阈值是10700

分层编译

基于代码探测技术探测出来的代码热度数据,Hotspot能够将代码的执行过程分为多个阶段,每个阶段使用不同的编译策略,并根据代码热度来决定优化程度,以平衡编译时间和执行性能。

分层编译

上图是一个JVM分层编译模型。

HotSpot虚拟机中的分层编译通常包含以下几个层次:

  • 解释执行层:初始阶段,Java虚拟机会使用解释器逐行执行Java字节码。解释执行的优点是即时性,不需要等待编译过程,但执行性能较低。
  • C1编译层:也称为Client编译器,对于热点代码会进行简单的优化,并将其编译为本地机器码。C1编译层编译速度快,适用于那些执行次数较少的代码段。
  • C2编译层:也称为Server编译器,对于经常执行的热点代码会进行更深入的优化,生成高度优化的本地机器码。C2编译层的优点是执行性能高,适用于执行频率较高的代码段。

JIT优化技术

逃逸分析

  • 全局逃逸(Global Escape):全局逃逸发生在对象在方法内部创建后,被方法外的代码引用。例如,将对象传递给其他方法、返回对象作为方法的返回值、将对象存储在全局变量或成员变量中等情况。
  • 线程逃逸(Thread Escape):线程逃逸发生在一个线程中创建后,线程中的对象被另一个线程引用。在多线程环境中,对象可能在一个线程中创建,然后通过共享数据传递给其他线程,导致线程逃逸。

逃逸分析能够用于确定在代码中创建的对象是否会逃逸出当前方法的作用域,即是否会被方法外的代码引用。如果一个对象没有逃逸,JIT可以对其进行一些优化,例如栈上分配、标量替换等。逃逸分析还可以减少不必要的对象在堆上的分配和垃圾回收的开销。通过逃逸分析,编译器可以判断一个对象的生命周期,从而决定将其分配在栈上还是堆上。

下面通过一个代码示例查看逃逸分析对性能和GC的影响:

package com.yeliheng.jit;

public class Escape {

    public static void test() {
        Aggregate a = new Aggregate("a", "b");
    }

    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        for(int i = 0;i < 10000000; i++){
            test();
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Time:" + (endTime - startTime));
    }

    static class Aggregate {
        private String s1;
        private String s2;

        public Aggregate(String s1, String s2) {
            this.s1 = s1;
            this.s2 = s2;
        }
    }
}

我们创建一个对象,对象中包含两个未初始化的字符串s1,s2,循环调用对象一千万次,通过计时器查看程序的执行时间。在测试前,我们需要添加两个JVM参数:-XX:+PrintGCDetails -XX:-DoEscapeAnalysis

关闭逃逸分析的效果如下:

逃逸分析关闭

可以看到,在循环调用的过程中,触发了若干次GC,并且执行时间为72毫秒。现在,我们去掉-XX:-DoEscapeAnalysis参数,再次查看效果。

逃逸分析关闭

首先是频繁的GC没有出现,其次是执行时间只有4毫秒,快了整整18倍,优化的效果十分显著。

栈上分配

在JVM中创建的对象都会在堆中进行分配,因为在堆内存上的对象是线程共享的,不同线程只需要拥有该对象的引用即可访问堆中存储的对象数据。但如果一个对象经过JIT的逃逸分析后发现其没有逃逸,那么就不需要将其分配到堆上。将该对象分配到栈上能够让它天然地随着出栈(也就是在方法执行完成后)而被销毁,无需被垃圾收集器进行标记从而浪费GC性能。

值得注意的是,在Hotspot虚拟机中,栈上分配并没有真正实现,而是作为一种思想,通过标量替换来真正实现栈上分配。

栈上分配的流程图如下所示:

栈上分配

标量替换

如果编译器经过逃逸分析后能够确定一个对象不会逃逸,而且它的成员变量也没有逃逸,那么可以将这个对象拆解为其各个成员变量,并将这些成员变量分别当作标量(scalar)处理,从而实现上文所说的“栈上分配”。在Java中原始数据类型如int、long等都属于标量。而其他可以继续分解的数据都称为聚合量(Aggregate),对象就是典型的聚合量。

在这里我们使用真实代码查看JIT中的标量替换。

package com.yeliheng.jit;

public class ScalarReplacement {
    public ScalarReplacement() {
    }

    public static void main(String[] args) {
        int x = getX();
        int y = getY();
        int sum = add(x, y);
        System.out.println("Sum: " + sum);
    }

    public static int getX() {
        return 10;
    }

    public static int getY() {
        return 20;
    }

    public static int add(int a, int b) {
        return a + b;
    }
}

以上的代码在开启标量替换后会被编译成下列代码:

package com.yeliheng.jit;

public class ScalarReplacement {
    public ScalarReplacement() {
    }

    public static void main(String[] args) {
        int x = getX();
        int y = getY();
        int sum = add(x, y);
        System.out.println("Sum: " + sum);
    }

    public static int getX() {
        return 10;
    }

    public static int getY() {
        return 20;
    }

    public static int add(int a, int b) {
        return 30;
    }
}

方法内联

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。

    private int add(int a, int b, int c, int d) {
        return add(a, b) + add(c, d);
    }
    
    private int add(int a, int b) {
        return a + b;
    }

如上的add方法,计算a+b和c+d的和,会被方法内联优化为以下方式:

    private int add(int a, int b, int c, int d) {
        return a + b + c + d;
    }

JVM识别热点方法会对它们进行内联优化,但如果热点方法的方法体过大(默认大于325字节),JVM就不会执行内联操作。可以通过-XX:FreqInlineSize=N来修改执行方法内联方法体大小的阈值。

总结

本文分析了JIT(Just-In-Time)即时编译技术在Java虚拟机中的应用。JIT通过热点代码探测识别高频执行的代码,并进行优化以提高程序执行效率。文章介绍了解释执行和编译执行的概念,以及HotSpot虚拟机的工作模式和分层编译。最后,本文从JIT优化技术:逃逸分析、栈上分配、标量替换和方法内联进行分析,希望能在JIT的探索中对大家有所帮助。

JavaJVM
2025 © Yeliheng的技术小站 版权所有