【JVM】JVM中JIT的详细探索

前言

解释器:只在执行程序时,才一条一条的解释成机器语言给计算机来执行,所以运行速度是不如编译后的程序运行的快的。

特点:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。

编译器:把源程序的每一条语句都编译成机器语言,并保存成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序,速度很快。

特点:在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

两者的协作:在程序运行环境中内存资源限制较大时,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。当通过编译器优化时,发现并没有起到优化作用,可以通过逆优化退回到解释状态继续执行。

因此,HotSpot虚拟机使用解释器与编译器并存的架构。

1、了解JVM的JIT

当虚拟机发现某个方法代码块的运行特别频繁时,就会把这些代码认定为热点代码。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler),简称 JIT 编译器。反复解释执行肯定很慢,JVM在运行程序的过程中不断优化,用JIT编译器编译那些热点代码,让他们不用每次都逐句解释执行。
在这里插入图片描述

JIT 编译器在运行程序时有两种编译模式可以选择,并且其会在运行时决定使用哪一种以达到最优性能。这两种模式一个叫client模式,另一个叫server模式。client模式通过-client参数指定,server模式通过-server参数来指定。

client模式:程序启动较快,编译效率快,但生成的机器代码执行效率比server模式低,大概低30%。
server模式:程序启动相对较慢,编译时间长,但生成机器代码质量高,适用于执行时长,对峰值有性能要求的系统。

1.1 分层编译

在Java7中把这两种方式综合了起来,出现了分层编译,参数-XX:+TieredCompilation,综合了client的启动性能优势和server的峰值性能优势。JDK1.8中默认开启分层编译。之所以引入分层编译是因为机器码越快,需要编译的时间就越长。分层编译可以在短时间内满足部分不那么热的代码编译完成,在早期就可以提升部分系统性能。等到长时间的运行收集到足够多的数据后,能让真正热的代码得到最好的优化。

分层编译将Java虚拟机的执行状态分为了五个层次,下面用C1代码表示client编译器生成的机器代码,C2代码表示server编译器生成的机器代码。五个层级分别是:

0、解释执行;
1、执行不带profiling的C1代码;
2、执行仅带调用次数以及循环回边执行次数profiling的C1代码;
3、执行带所有profiling的C1代码;
4、执行C2代码;

在这里插入图片描述

profiling在这里是指在程序执行过程中,收集能够反映程序状态的数据,这会额外损失一些性能。因此同样是执行C1代码,执行性能是1 > 2 > 3;4执行C2代码性能是最高的。其中2、3可以根据profiling得到的数据再次编译生成更好的C2代码。

Code Cache:JVM将那些编译优化后的代码被存储在一个特殊的堆中,这堆被称为代码缓存。代码被存在那里,直到相关的类被卸载或者代码没有被去优化。代码缓存有限定的大小,因此,如果他耗尽空间,以后的代码将不被优化,这将导致性能问题。

分层模式下如果是64位的虚拟机编译线程的总数目是根据处理器数量来调整的,当然也可以通常参数-XX:+CICompilerCount=N来强制指定。

2、了解什么情况会触发JIT,如何判断JVM发生了JIT,是否可观测?

2.1 什么情况会触发JIT?

在上面我们了解了什么是热点代码,也知道热点代码大概分为两类:频繁调用的方法和被多次执行的循环体;那么怎么发现这些热点代码呢?主要分为两种方法:基于采样的热点探测基于计数器的热点探测,在HotSpot虚拟机中采用的是基于计数器的探测方法。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法/代码块的执行次数,如果次数超过一定的阈值就认为它是“热点代码”

那么,热点代码什么时候会触发JIT呢?

在JVM中,JIT的触发是基本两个计数器的:一个方法被调用的次数,另一个循环回边的次数。如果两个数加起来超过阈值,JVM就判断这个方法有没有达到触发JIT的条件。如果有,把这个方法排队等待编译,这种是一般的编译。还有另外一种编译就是:如果方法里有一个很长的循环或者这个循环永远不会退出,JVM判断循环回边的次数超过阈值了就直接把循环给编译了,而不是整个方法,这种编译又叫On-Stack Replacemen(栈上替换,OSR),编译完成之后,循环就直接执行编译好的机器代码了。

基于计数器的探测: Client模式下默认1500次,Server下默认10000次,根据参数-XX:CompileThreshold设定。 调用一个方法,先检查是否存在JIT编译版本本地代码,存在优先使用本地代码,不存在将计数器加1。然后判断调用计数器和回边计数器之和是否大于阈值,如果超过,用JIT编译器提交编译请求。JIT编译完成后方法调用入口就被系统换成新的。下次调用已编译版本。 计数器热度衰减(Counter Decay超过一定的时间限度,方法的调用次数仍未达到阈值,方法计数器减少一半。在垃圾收集期间执行,用-UseCounterDecay来关闭,以统计绝对次数。用-XX:CounterHalfLifeTime设置半半衰周期。

回边计数器:统计方法中方法体代码执行的次数,在字节码中遇到控制流向后跳动的指令成为回边(Back Edge)。 回边计数器阈值可以用-XX:OnStackReplacePercentage来间接调整。 回边计数器没有热度衰减过程。

两个计数器的协作:当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器加 1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果已经超过阈值,那么将会向JIT提交一个该方法的代码编译请求。

2.2 JIT生成代码时采用的代码优化技术

1、公共子表达式消除:如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就可以了。
例子:int d = (c*b) * 12 + a + (a+ b * c) -> int d = E * 12 + a + (a+ E)

2、数组范围检查消除:在 Java 语言中访问数组元素的时候系统将会自动进行上下界的范围检查,超出边界会抛出异常。对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。Java 在编译期根据数据流分析可以判定范围进而消除上下界检查,节省多次的条件判断操作。

3、方法内联(重要):简单来说就是把目标方法复制到调用的方法中,消除一些无用的代码。

我们都知道调用某个方法实际上将程序执行顺序转移到该方法所存放在内存中某个地址,将方法的程序内容执行完后,再返回到转去执行该方法前的地方。这种转移操作要求在转去前要保护现场并记忆执行的地址,转回后先要恢复现场,并按原来保存地址继续执行。也就是通常说的压栈和出栈。因此,方法调用要有一定的时间和空间方面的开销。那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。

那怎么解决这个性能消耗问题呢,这个时候需要引入内联函数了。内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换。显然,这样就不会产生转去转回的问题,但是由于在编译时将函数体中的代码被替代到程序中,因此会增加目标程序代码量,进而增加空间开销,而在时间代销上不象函数调用时那么大,可见它是以目标代码的增加为代价来换取时间的节省。

举个栗子:

public int add(int x, int y) {
  return x + y;
}
int result = add(a, b);

对于这段代码,当发生内联之后,就会变成:

int result = a + b;

上面的变量a和b替换了方法的参数,并且add方法的方法体已经复制到了调用者的区域。使用内联可以为程序带来很多好处,比如:

  • 1、不会引起额外的性能损失。
  • 2、减少指针的间接引用。
  • 3、不需要对内联方法进行虚方法查找。

此外,通过将方法的实现复制到调用者中,JIT编译器处理的代码增多,使得后续的优化和更多的内联成为可能。可以通过设置-XX:MaxInlineSize=#选项来修改最大的临界值,通过设置‑XX:FreqInlineSize=#选项来修改频繁调用的方法的临界值。但是在没有正确的分析的情况下,我们不应该修改这些配置。

但是一个方法就算被JVM标注成为热点方法,JVM仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况。

  • 如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联(可以通过-XX:MaxFreqInlineSize=N来设置这个大小)

  • 如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联(可以通过-XX:MaxInlineSize=N来设置这个大小)

我们可以通过增加这个参数的大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。
使用参数:

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

虽然JIT号称可以针对代码全局的运行情况而优化,但是JIT对一个方法内联之后,还是可能因为方法被继承,导致需要类型检查而没有达到性能的效果。想要对热点的方法使用上内联的优化方法,最好尽量使用final、private、static这些修饰符修饰方法,避免方法因为继承,导致需要额外的类型检查,而出现效果不好情况。

4、逃逸分析:逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可以为这个变量进行一些高效的优化。

2.3 判断是否发生JIT

在线上环境可以使用参数-XX:+PrintCompilation的输出结果会提供运行时正在编译的方法的信息。

如下信息:

    108   38       3       tencent.lifangding.MyInterfaceImpl::<init> (5 bytes)
    108   43       4       tencent.lifangding.MyInterfaceImpl::addARandomNumber (10 bytes)
    108   44       4       tencent.lifangding.MyInterfaceImpl::<init> (5 bytes)
    108   38       3       tencent.lifangding.MyInterfaceImpl::<init> (5 bytes)   made not entrant
    109   37       3       java.util.Random::next (47 bytes)   made not entrant
    109   39       3       tencent.lifangding.MyInterfaceImpl::addARandomNumber (10 bytes)   made not entrant
2.4 JIT日志跟踪(详细查看JIT优化的信息)

上面我们知道通过参数PrintCompilation可以查看运行时正在编译的信息,但是该参数也存在着两个小问题:
1、输出的结果中未包含方法的签名,如果存在重载方法,区分起来则比较困难。
2、Hotspot虚拟机目前不能将结果输出到单独的文件中,目前只能是以标准输出的形式展示。

因此,我们可以通过配置更加详细的参数查看JIT日志信息:-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=jit_compiler.log(默认和项目同一文件中),该参数打印的日志信息人为阅读极为困难,好在AdoptOpenJDK维护了一个名为JITWatch的开源项目,该项目将帮助我们跟踪JIT编译器。 使用非常简单:

git clone git@github.com:AdoptOpenJDK/jitwatch.git  //克隆到本地
cd jitwatch //cd进jitwatch
mvn clean compile test exec:java //执行该命令即可出现JITWatch界面

关于JITWatch使用可参考:如何在windows平台下使用hsdis与jitwatch查看JIT后的汇编码

2.5 JIT日志参数解析

下面我们可以看看JIT日志参数列表:
在这里插入图片描述

timestamp:进程启动到JIT编译发生的时间,单位毫秒。

compile_id:编译id,表明该方法正在被编译(HotSpot中一个方法可以多次去优化和再优化)

Attributes:附加标志信息,详细描述如下图:
在这里插入图片描述

阻塞模式永远不会看到,因为它只在后台编译被禁用时发生。它在默认情况下是启用的,没有很好的理由去禁用它。OSR例子参见文档:The Java HotSpot Performance Engine: On-Stack Replacement Example
Tier level:分层编译中编译成机器码使用的级别。
在这里插入图片描述

method:正在编译的方法全限定类名

size:方法的字节大小。

deopt:出现了反优化。

反优化:反优化意味着编译器需要"撤消"先前的编译。 结果是将降低应用程序的性能(至少在编译器可以重新编译代码之前)。

存在两种情况,JVM进行反优化,made not entrantmade zombie,分别解释为代码不准进入僵尸代码

made not entrant 有两种原因:第一个是当我们使用多态和接口时,第二个可以简单地发生在分层编译期间(从1到4) 。
为了解释这个问题,以下面代码为例进行介绍:
提供一个公共接口: MyInterface以及其两个实现类:MyInterfaceImplMyInterfaceLoggerImpl,其中MyInterfaceImpl是一个简单的实现,但是MyInterfaceLoggerImpl在实现的方法上添加了一些打印语句。


interface MyInterface {
    void addARandomNumber(double value);
}
 
 
class MyInterfaceImpl implements MyInterface {
    @Override
    public void addARandomNumber(double value) {
        double random = Math.random();
        double finalResult = random + value;
    }
}
 
 
class MyInterfaceLoggerImpl implements MyInterface {
    @Override
    public void addARandomNumber(double value) {
        System.out.println("The value is: " + Math.random() + value);
    }
}

现在,我们看一下下面调用,MyInterfaceImpl将先执行45000,然后MyInterfaceLoggerImpl将执行剩下的5000次。

public class DeoptimizationExample {
    public static void main(String[] args) {
        for (int i = 0; i < 50000; i++) {
            MyInterface myInterface;
            if (i < 45000) {
                // The first 45.000 executions will enter here
                myInterface = new MyInterfaceImpl();
            } else {
                myInterface = new MyInterfaceLoggerImpl();
            }
            myInterface.addARandomNumber(50);
        }
    }
}

使用JVM参数-XX:+ PrintCompilation执行上述调用代码,将会看到如下信息:
在这里插入图片描述

出现了made not entrant因为编译器会看到myInterface对象的当前类型是MyInterfaceImpl。 然后它将内联代码并根据此知识执行其他优化。在使用MyInterfaceImpl进行一堆执行(基于我们的示例为45000次)之后,我们进入另一个场景,其中的实现将是MyInterfaceLoggerImpl。 现在,假设编译器对myInterface对象的类型做出了错误的假设,则先前的优化无效 。 它将生成去优化 ,并且先前的优化将被丢弃。 如果使用MyInterfaceLoggerImpl进行了许多其他调用,则JVM将快速编译该代码并进行新的优化。

导致made not entrant的第二个原因是由于分层编译的工作原理。 当C2(服务器)编译器将代码编译到第4层时,JVM必须替换C1(客户端)编译器已经编译的代码。 它通过将旧代码标记为不可进入并使用相同的去优化机制来将标记的代码替换为新编译(且效率更高)的代码来实现。 因此,当程序运行分层编译时,编译日志将显示一些不可重入的方法。 但是在这种情况下,这种“非优化”实际上使代码变得更快。在例子中,使用MyInterfaceLoggerImpl实现运行测试之后,将MyInterfaceImpl类的代码设为entrant。 但是MyInterfaceImpl类的对象仍在内存中。 最终,所有这些对象将由垃圾收集器(GC)收集。 发生这种情况时,编译器注意到该类的方法将被标记为僵尸代码 。

在内存上来说是好的,因为该编译后的代码将保存在固定大小的代码缓存中。 识别出僵尸方法后,可以从代码缓存中删除此代码,从而为其他代码的编译和添加留出空间。
JIT直接控制参数

分层编译(1.8中默认开启):-XX:+TieredCompilation

JIT代码缓存初始大小:-XX:InitialCodeCacheSize=Nflag(N为大小,flag单位标识)

代码缓存从初始大小开始逐渐扩大,配合参数JIT缓存大小:–XX:ReservedCodeCacheSize=Nflag(N为大小,flag单位标识)

3、 经验建议

(1) 尽量减少方法体大小:一个方法体中的代码很多,但是难免会出现许多判断条件,对于不怎么容易成立的条件代码又比较多的,将其独立为一个单独的方法,减少方法体大小,尽量复用临时变量,重复逻辑抽取独立为方法。
(2) 编译模式: 如果JDK版本支持分层编译,最佳实践是切换到分层编译。
(3) 编译线程:绝大部分情况下不需要调整,除非CPU资源很紧张。
(4) 如果内存资源充足,则保证代码缓存的大小设置的足够大,这样JIT将会提供最高的编译性能。
(5)让尽可能多的方法达到内联条件尤为重要,通过工具Jarscan可检测程序中有多少方法是对内联友好的。(Jarscan工具是分析JIT编译的JITWatch开源工具套件中的一部分。和在运行时分析JIT日志的主工具不同,Jarscan是一款静态分析jar文件的工具。该工具的输出结果格式为CSV,结果中包含了超过频繁调用方法临界值的方法等信息。)

4、Java10中关于JIT的新特性

Java 10 中开启了基于 Java 的 JIT 编译器 Graal,并将其用作 Linux/x64 平台上的实验性 JIT 编译器开始进行测试和调试工作,另外 Graal 将使用 Java 9 中引入的 JVM 编译器接口(JVMCI)。

Graal 是一个以 Java 为主要编程语言、面向 Java bytecode 的编译器。与用 C++实现的 C1 及 C2 相比,它的模块化更加明显,也更加容易维护。Graal 既可以作为动态编译器,在运行时编译热点方法;亦可以作为静态编译器,实现 AOT 编译。在 Java 10 中,Graal 作为试验性 JIT 编译器一同发布(JEP 317)。将 Graal 编译器研究项目引入到 Java 中,或许能够为 JVM 性能与当前 C++ 所写版本匹敌(或有幸超越)提供基础。

Java 10 中默认情况下 HotSpot 仍使用的是 C2 编译器,要启用 Graal 作为 JIT 编译器,请在 Java 命令行上使用以下参数:

-XX:+ UnlockExperimentalVMOptions -XX:+ UseJVMCICompiler

分析JIT日志:
1、https://julio-falbo.medium.com/understand-jvm-and-jit-compiler-part-2-cc6f26fff721

JIT文章:

1、https://theboreddev.com/analysing-jit-compilation-in-jvm/
2、https://droidyue.com/blog/2015/09/12/is-your-java-code-jit-friendly/
3、了解jvm和jit编译器的第3部分
4、Hollis:深入分析Java的编译原理

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值