深入浅出 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 生成的机器码。五个层级分别是:

    1. 解释执行;
    1. 执行不带 profiling 的 C1 代码;
    1. 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
    1. 执行带所有 profiling 的 C1 代码;
    1. 执行 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 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。

ini 复制代码
系数的计算方法为:
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. 语言无关的经典优化技术之一:公共子表达式消除。

方法内联

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

优化前的原始代码:

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

内联后的代码:

ini 复制代码
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)

假设存在如下代码:

css 复制代码
int d = (c * b) * 12 + a + (a + b * c); 

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

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

优化后的代码:

ini 复制代码
int d = E * 12 + a + (a + E); 

即时编译实战

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

测试代码:

arduino 复制代码
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运行时观察即时编译信息

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

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

yaml 复制代码
jstat -printcompilation 74293 1000
Compiled  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

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

objectivec 复制代码
timestamp compile_id COMPILE SKIPPED: reason

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

  • 代码缓存(code cache)满了:需要使用ReservedCodeCache标志增加代码缓存的大小
  • 编译的同时加载类:编译类的时候会发生修改,JVM之后会再次编译

即时编译调优

如何选择编译器

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

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

指定client编译器

ruby 复制代码
-XX:-TieredCompilation -client

指定server编译器

ruby 复制代码
-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参数的设置。

查看方法:

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

异步编译

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

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

总结

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

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

作者介绍

Github 账号:binbin0325,公众号:柠檬汁CodeSentinel-Golang Committer 、ChaosBlade Committer 、 Nacos PMC 、Apache Dubbo-Go Committer。目前主要关注于混沌工程、中间件以及云原生方向。

参考

相关推荐
Yan.love30 分钟前
开发场景中Java 集合的最佳选择
java·数据结构·链表
椰椰椰耶33 分钟前
【文档搜索引擎】搜索模块的完整实现
java·搜索引擎
大G哥33 分钟前
java提高正则处理效率
java·开发语言
小_太_阳1 小时前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
智慧老师1 小时前
Spring基础分析13-Spring Security框架
java·后端·spring
lxyzcm1 小时前
C++23新特性解析:[[assume]]属性
java·c++·spring boot·c++23
V+zmm101342 小时前
基于微信小程序的乡村政务服务系统springboot+论文源码调试讲解
java·微信小程序·小程序·毕业设计·ssm
Oneforlove_twoforjob2 小时前
【Java基础面试题025】什么是Java的Integer缓存池?
java·开发语言·缓存
xmh-sxh-13142 小时前
常用的缓存技术都有哪些
java
搬码后生仔2 小时前
asp.net core webapi项目中 在生产环境中 进不去swagger
chrome·后端·asp.net