深入浅出 Java即时编译(JIT)原理与调优

2023年 9月 16日 75.9k 0

导读

编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序,例如C++,Golang等常见的编译型语言,都是在程序运行前将代码生成为机器码,然后运行在目标机器上,不过编译的时候要针对目标机器的CPU分别进行编译。

Java具有跨平台性“一次编译,到处运行”的能力,它把编译的过程进行拆解,先把.java文件编译成JVM可识别的.Class字节码,然后再由解释器逐条将字节码解释为机器码运行,这种解释型语言的程序好处就是可以移植到任何有适当解释器的机器上,但是它的运行速度是远不及编译语言。

 

在本文将重点介绍Java(HotSpot JVM)的即时编译,从而了解JVM是如何通过即时编译提升运行效率以及常用的调优手段

 

编译器类别

在java中编译器主要分为三类:

  • 前端编译器:JDK的Javac,即把*.java文件转变成*.class文件的过程
  • 即时编译器:HotSpot虚拟机的C1,C2编译器,Graal编译器,JVM运行期把字节码转变成本地机器码的过程
  • 提前编译器:JDK的Jaotc,GNU Compiler for the Java(GCJ)等
  • 编译器执行过程

  • 前端编译java的编译过程首先由javac将.java文件编译为字节码,这部分通常叫做前端编译编译过程大概分为1个准备过程和3个处理过程,最终会将代码编译为字节码
  • 即时编译
  • 在jvm运行时期,方法被执行前会先检测当前方法是否已经被编译为机器码,如果编译为机器码将直接从CodeCache中获取机器码执行
  • 如果没有编译为机器码,则会进行代码的热点探测,没有达到热点代码的阈值时则由解释器执行。达到阈值时则判断是否为同步编译同步编译则需要等待jvm将当前热点代码编译为机器码后才能执行异步编译则本次热点代码由解释器执行,异步的在后台编译机器码
  • 即时编译器(JIT)

     

    即时(Just In Time) 编译器是Java虚拟机的核心,对JVM性能影响最大的莫过于编译器,如何选择编译器以及如何调优编译器则是需要了解和掌握的

    解释器和编译器

     

    从上图可以看到JVM执行代码时并不会立即将字节码编译为机器码,因为解释器与编译器两者各有优势: 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。

     

    对于程序来说通常只有一部分代码被经常执行,应用的性能也取决于这些代码执行的速度,这些关键代码被称作为“热点代码”,当探测命中阈值后,这些热点代码才会被编译成机器码,以此来提升运行效率,HotSpot JVM的名字即来自于这里。

      

    即时编译器类型

     

    JVM提供两种编译器类型,分别为客户端编译器和服务端编译器。这两种编译器通常被称为client和server,两种编译器的主要区别在于编译代码的时机不同。client编译器开启编译比server编译器要早,意味着在代码执行的开始阶段(服务启动阶段)client编译器比server编译器要快,但server编译器在编译代码时可以更好的进行优化,server编译器生成的代码要比client代码快。

     

    客户端编译器(Client Compiler)

     

    客户端编译是一个相对简单快速的编译器,主要的关注点在于局部的优化,而放弃了许多耗时的全局优化手段。

     

    服务端编译器(Server Compiler)

     

    服务端编译器通常也称为C2 编译器,server 编译器,以server编译器模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。原因是:当虚拟机运行在-client模式时,使用的是一个代号为C1的轻量级编译器,而-server模式启动的虚拟机采用相对重量级代号为C2的编译器。C2比C1编译器编译的相对彻底,服务起来之后,性能更高。

     

    分层编译器(Tiered Compiler)

     

    在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,

    或者对启动性能有要求的程序,我们采用编译效率较快的 C1,对应参数 -client。

    对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的

    C2,对应参数 -server。

     

    为了减少开发人员和部署运维人员的心智负担,jvm迭代升级在Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。在启动时使用C1编译器,随着热点探测将热点代码使用server编译进行优化,这种技术就叫做分层编译。

     

    Java 8 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的

    参数 -client 和 -server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用

    C2。

    分层编译将 Java 虚拟机的执行状态分为了五个层次。“C1 代码”指代由 C1 生成的机器码,“C2 代码”指代由 C2 生成的机器码。五个层级分别是:

    • 解释执行;
    • 执行不带 profiling 的 C1 代码;
    • 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
    • 执行带所有 profiling 的 C1 代码;
    • 执行 C2 代码。

    通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三

    种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。

    其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为

    profiling 越多,其额外的性能开销越大。

     

    编译路径:

  • common:通常情况下热点方法会被3层的C1编译,然后再被4层的C2编译
  • trivial method:如果方法的字节码数目比较少,例如get set方法,而且3层的profiling没有可收集的数据,那么虚拟机会认为该方法使用C1和C2编译的效果相同,在这种情况下Java虚拟机会在3层编译后,选择1层的C1编译
  • C1 Busy:在C1编译器处于忙碌状态时(C1 Compiler Thread),直接由4层的C2进行编译
  • C2 Busy:在C2编译器处于忙碌状态时 (C2 Compiler Thread),则由2层的C1编译器编译,然后再被3层的C1编译,减少方法在3层的执行时间。
  • 即时编译的触发

    热点代码

    上面介绍了即时编译只会对热点代码进行编译,热点代码主要分为两类

  • 被多次调用的方法
  • 被多次执行的循环体
  • 前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代 码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存 在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码” 

     

    触发阈值

    在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该

    值为 10000),便会触发即时编译。

     

    当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值

    (该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。

     

    在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。

    系数的计算方法为:
    s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
    其中 X 是执行层次,可取 3 或者 4;
    queue_size_X 是执行层次为 X 的待编译方法的数目;
    TierXLoadFeedback 是预设好的参数,其中 Tier3LoadFeedback 为 5,Tier4LoadFeedback 为 3;
    compiler_count_X 是层次 X 的编译线程数目。
    

     

    编译器优化技术

    上面介绍了即时编译的类型,过程以及触发编译的时机,本章节将介绍即时编译是怎样将字节码优化成高质量的机器码。

    在OpenJDK的官方Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表。

     

    优化的技术点很多,我们重点关注下面几项优化技术:

  • 最重要的优化技术之一:方法内联。
  • 最前沿的优化技术之一:逃逸分析。
  • 语言无关的经典优化技术之一:公共子表达式消除。
  • 方法内联

    方法内联的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等); 二是为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,可以获取更好的优化效果。因此各种编译器一般都会把内联优化放在优化序列最靠前的位置

    优化前的原始代码:

     static class B { 
       int value;
      final int get() { 
        return value;
      } 
    }
    public void foo() { 
      y = b.get();
    // ...do stuff... 
      z = b.get();
      sum = y + z;
    } 
    

    内联后的代码:

    public void foo() { 
      y = b.value;
    // ...do stuff... 
      z = b.value;
      sum = y + z;
    } 
    

    逃逸分析

    逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。

    逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

    逃逸状态:

  • 全局逃逸:一个对象的作用范围逃出了当前方法或当前线程
  • 对象是一个静态变量
  • 对象已经发生逃逸
  • 对象作为当前方法的返回值
  • 参数逃逸:一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的
  • 没有逃逸:方法中的对象没有发生逃逸
  • 优化手段:

  • 栈上分配当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能。
  • 标量替换首先要了解标量和聚合量的区别,标量是指:虚拟机中的原始数据类型(int ,long等),聚合量:例如java中的对象。标量替换的过程是:把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问.假如逃逸分析出对象可以在栈上分配,并且这个对象可以被拆散,那么程序真正执行时可能不会去创建这个对象
  • 同步消除(锁消除)线程同步本身是一个相对耗时的动作,如果逃逸分析能够确定变量不会逃逸出当前线程,那么这个变量的读写就不会有竞争,对这个变量的同步措施就可以消除掉
  • 使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,用户可以使用参数 - XX:+EliminateAllocations 来开启标量替换,使用 +XX:+EliminateLocks来开启同步消除 , 使用参数 -XX:+PrintEliminateAllocations 查看标量的替换情况  

    公共子表达式消除

    如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称 为全局公共子表达式消除(Global Common Subexpression Elimination) 

    假设存在如下代码:

    int d = (c * b) * 12 + a + (a + b * c); 
    

    如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码将完全遵照Java源码的写法直译而成的。 

    当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到cb与bc是一样的表达式,而且在计算期间b与c的值是不变的。 

    优化后的代码:

    int d = E * 12 + a + (a + E); 
    

    即时编译实战

    此章节将介绍如何查看以及分析编译结果

    测试代码:

    package com.example.didilog;
    public class Compiler {
    public static final int NUM = 15000;
    public static int doubleValue(int i) {
    // 这个空循环用于后面演示JIT代码优化过程
    for (int j = 0; j < 100000; j++) ;
    return i * 2;
    }
    public static long calcSum() {
    long sum = 0;
    for (int i = 1; i

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论