写点什么

Java 即时编译(JIT)原理与调优

作者:柠檬汁Code
  • 2022 年 8 月 30 日
    北京
  • 本文字数:6955 字

    阅读完需:约 23 分钟

Java即时编译(JIT)原理与调优

导读

 

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

 

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

 

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

 

编译器类别

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

  1. 前端编译器:JDK 的 Javac,即把*.java 文件转变成*.class 文件的过程

  2. 即时编译器:HotSpot 虚拟机的 C1,C2 编译器,Graal 编译器,JVM 运行期把字节码转变成本地机器码的过程

  3. 提前编译器:JDK 的 Jaotc,GNU Compiler for the Java(GCJ)等

编译器执行过程

  1. 前端编译 java 的编译过程首先由 javac 将.java 文件编译为字节码,这部分通常叫做前端编译编译过程大概分为 1 个准备过程和 3 个处理过程,最终会将代码编译为字节码

  2. 即时编译

  3. 在 jvm 运行时期,方法被执行前会先检测当前方法是否已经被编译为机器码,如果编译为机器码将直接从 CodeCache 中获取机器码执行

  4. 如果没有编译为机器码,则会进行代码的热点探测,没有达到热点代码的阈值时则由解释器执行。达到阈值时则判断是否为同步编译同步编译则需要等待 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 生成的机器码。五个层级分别是:

  • 0. 解释执行;

  • 1. 执行不带 profiling 的 C1 代码;

  • 2. 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;

  • 3. 执行带所有 profiling 的 C1 代码;

  • 4. 执行 C2 代码。

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

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

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

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

 

编译路径:



  1. common:通常情况下热点方法会被 3 层的 C1 编译,然后再被 4 层的 C2 编译

  2. trivial method:如果方法的字节码数目比较少,例如 get set 方法,而且 3 层的 profiling 没有可收集的数据,那么虚拟机会认为该方法使用 C1 和 C2 编译的效果相同,在这种情况下 Java 虚拟机会在 3 层编译后,选择 1 层的 C1 编译

  3. C1 Busy:在 C1 编译器处于忙碌状态时(C1 Compiler Thread),直接由 4 层的 C2 进行编译

  4. C2 Busy:在 C2 编译器处于忙碌状态时 (C2 Compiler Thread),则由 2 层的 C1 编译器编译,然后再被 3 层的 C1 编译,减少方法在 3 层的执行时间。


即时编译的触发

热点代码

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

  1. 被多次调用的方法

  2. 被多次执行的循环体

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

 

触发阈值

在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -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 虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表



 

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

  1. 最重要的优化技术之一:方法内联。

  2. 最前沿的优化技术之一:逃逸分析。

  3. 语言无关的经典优化技术之一:公共子表达式消除。


方法内联

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


优化前的原始代码:

 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 堆上分配内存的一项技术。

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


逃逸状态:

  1. 全局逃逸:一个对象的作用范围逃出了当前方法或当前线程

  2. 对象是一个静态变量

  3. 对象已经发生逃逸

  4. 对象作为当前方法的返回值

  5. 参数逃逸:一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的

  6. 没有逃逸:方法中的对象没有发生逃逸


优化手段:

  1. 栈上分配当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能。

  2. 标量替换首先要了解标量和聚合量的区别,标量是指:虚拟机中的原始数据类型(int ,long 等),聚合量:例如 java 中的对象。标量替换的过程是:把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问.假如逃逸分析出对象可以在栈上分配,并且这个对象可以被拆散,那么程序真正执行时可能不会去创建这个对象

  3. 同步消除(锁消除)线程同步本身是一个相对耗时的动作,如果逃逸分析能够确定变量不会逃逸出当前线程,那么这个变量的读写就不会有竞争,对这个变量的同步措施就可以消除掉


使用参数-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 源码的写法直译而成的。 

当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到 c*b 与 b*c 是一样的表达式,而且在计算期间 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 <= 100; i++) {            sum += doubleValue(i);        }        return sum;    }    public static void main(String[] args) {        for (int i = 0; i < NUM; i++) {            calcSum();        }    }}
复制代码

通过-XX:+PrintCompilation 参数打印在即时编译时被编译成本地机器码的方法



输出的结果:



参数说明:


  • timestamp:编译完成时间戳

  • compilation_id:jvm 内部任务 ID

  • attributes:编译状态

  • %:编译为 OSR(回边计数器触发的栈上替换编译)

  • s:方法是同步的!:方法有异常处理器

  • b:阻塞模式时发生的编译

  • n:为封装本地方法所发生的编译

  • tiered_level:分层编译使用的层级

  • method_name:编译的方法明

  • size:编译后的代码大小,java 字节码的大小


通过-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 参数观察编译器内联优化的信息

可以看到 doubleValue 方法已经被内联优化了


jstat

使用 jstat 运行时观察即时编译信息

jstat -compiler 74293 #74293java进程Compiled Failed Invalid   Time   FailedType FailedMethod    3107      0       0     1.15          0
复制代码

使用-printcompilation 参数获取最近被编译的方法,可以定时输出,如下每秒输出一次:

jstat -printcompilation 74293 1000Compiled  Size  Type Method    3131    195    1 java/util/concurrent/ScheduledThreadPoolExecutor reExecutePeriodic    3131    195    1 java/util/concurrent/ScheduledThreadPoolExecutor reExecutePeriodic    3131    195    1 java/util/concurrent/ScheduledThreadPoolExecutor reExecutePe
复制代码

在查看编译日志时,如果遇到类似下面的这行信息的错误:

timestamp compile_id COMPILE SKIPPED: reason
复制代码

出现这个错误时有可能是两种原因:

  • 代码缓存(code cache)满了:需要使用 ReservedCodeCache 标志增加代码缓存的大小

  • 编译的同时加载类:编译类的时候会发生修改,JVM 之后会再次编译


即时编译调优

如何选择编译器

简单来说 client 编译器启动快,server 编译器性能更好,分层编译降低了开发和运维的心智,结合了 client 和 server 编译器的优点。java8 默认开启了分层编译

以 java8 HotSpot 举例 如果要手动指定编译器类型,需要使用 TieredCompilation 参数关闭分层编译

指定 client 编译器

-XX:-TieredCompilation -client
复制代码

指定 server 编译器

-XX:-TieredCompilation -server
复制代码


调优代码缓存

JVM 生成的 native code 存放的内存空间称之为 Code Cache;JIT 编译、JNI 等都会编译代码到 native code,其中 JIT 生成的 native code 占用了 Code Cache 的绝大部分空间

如下图:code cache 属于堆外内存

通过-XX:ReservedCodeCacheSize 指定最大值,通常默认 240M

通过-XX:InitialCodeCacheSize 指定初始值

通过-XX:+PrintCodeCache 可以查看 CodeCache 的使用情况(在启动时增加,在 jvm 关闭时会打印使用情况)


当 CodeCache 被填满时,会打印如下错误:



当遇到这个错误时只需要将-XX:ReservedCodeCacheSize 调大一点,一般建议调大 1-3 倍即可

各平台上 CodeCache 默认大小:

CodeCache 的回收时通过在启动参数上增加:-XX:+UseCodeCacheFlushing 来启用;

打开这个选项,在 JIT 被关闭之前,也就是 CodeCache 装满之前,会在 JIT 关闭前做一次清理,删除一些 CodeCache 的代码;如果清理后还是没有空间,那么 JIT 依然会关闭。这个选项默认是关闭的;


编译阈值


未启用分层编译

使用-XX:CompilerThreshold 参数设置编译阈值

client 编译默认值时 1500。server 编译时,默认值时 10000,可以更改参数使其更早或更晚进行编译

OSR(回边计数器编译)阈值计算公式:

(CompileThreshold*((OnStackReplacePercentage-InterpreterProfilePercentage)/100))

所有编译器中:-XX:InterpreterProfilePercentage(解释器监控比率)默认值是 33

在 client 编译器中-XX:OnStackReplacePercentage(OSR 比率)默认值是 933,所以在 client 编译器中回边计数需要达到 13500.

在 server 编译器中-XX:OnStackReplacePercentage(OSR 比率)默认值是 140,所以在 server 编译器中回边计数需要达到 10700.

 

启用分层编译

如文章中触发阈值章节中所讲,需要根据编译线程计算

分层编译中编译器 C1 和 C2 的默认线程数

编译器线程数量通过-XX:CICompilerCount 参数进行设置,默认值参考上面表格。

对于 CPU 核心数充足的情况下,可以适当调大编译线程数,以此来加快即时编译的速度。

对于 CPU 核心数不足的情况下,可以适当减少编译咸亨数,以此来降低即时编译开启的线程,减少 CPU 的使用。

需要注意的是:如果在程序运行时,如果因为动态加载(classloader)导致触发及时编译,从而让 CPU 大幅度抖动,可以检查下-XX:CICompilerCount 参数的设置。

查看方法:

jinfo -flag CICompilerCount 1503996 #1503996是目标java进程
复制代码


异步编译

通过-XX:+BackgroundCompilation 参数可以设置编译机器码的动作同步还是异步。默认是 true(异步)。

当设置为 false 时,执行该方法的代码将一直等到它确实被编译为机器码以后才会执行。用-Xbatch 可以禁止后台编译

 

总结

Just-In-Time (JIT) 编译器是 JVM 运行时环境的一个重要组件,本文主要介绍了即时编译中的 client,server 以及分层编译器的原理以及优化手段。通过这些内容有助于对即时编译器加深理解,遇到相关问题时可以有效的分析和排查。

在即时编译器中除了经典的 client 以及 server 编译器还有新一代的编译器:Graal 编译器,它集成了即时编译器和提前编译器的功能感兴趣的可以自行查阅。


参考

发布于: 刚刚阅读数: 3
用户头像

柠檬汁Code

关注

开源社区爱好者,记录生活,沉淀自己 2018.07.10 加入

还未添加个人简介

评论

发布
暂无评论
Java即时编译(JIT)原理与调优_JVM_柠檬汁Code_InfoQ写作社区