【深入理解Java虚拟机】HotSpot虚拟机JIT编译器

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_22871607/article/details/89161849

JIT编译器(Just In Time Complier),通常在java程序通过解释器进行解释执行时,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认为是热点代码(Hot Spot code),虚拟机为了提高热点代码的执行效率,在运行时,虚拟机就把这些代码编译成为与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器成为即时编译器。

一:解释器和编译器

HotSpot是采用解释器和编译器混合的方式,能更有效的提高效率。
HotSpot虚拟机内置了两个即时编译器,分别称为Client Complier和Server Complier,或者简称为C1编译器和C2编译器。

查看虚拟机版本:
在这里插入图片描述
Mixde mode 混合模式,表示虚拟机采用解释器和编译器搭配使用的方式。

也可以强制使用参数 “-Xint” 强制指定虚拟机为 解释模式(Interpreted Mode)
在这里插入图片描述
或者通过参数“-Xcomp”强制虚拟机运行为 编译模式(Compiled Mode)
在这里插入图片描述
这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

由于JIT编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码所花费的时间更长,且解释器可能还要替编译器收集性能监控信息,这对解释器执行速度有影响。因此,HotSpot采用分层编译(Tiered Compilation)的策略:

1)第0层,程序解释执行,解释器不开启性能监控(Profiling),可触发第1层编译。
2)第1层,即C1编译,将字节码编译为本地代码,进行简单可靠的优化,如有必要将加入性能监控的逻辑。
3)第2层(或2层以上),即C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实现分层编译后,C1和C2编译器将会同时工作,许多代码可能会被多次编译,用C1编译器获取更高的编译速度,用C2编译器来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

二:编译条件和触发对象

热点代码分为两类:

1)被多次调用的方法,对整个方法进行编译
2)被多次执行的循环体,同样对整个方法进行编译,这种编译发生在执行过程中,因此称为
栈上替换(On Stack Replacement),简称OSR编译,即方法栈帧还在栈上,方法就被替换了。

通过**热点探测(Hot Spot Detection)**方法来判断一段代码是否是热点代码。
热点探测的方式有两种:

1)基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机周期性地检查各个线程的栈顶,
如果发现某个方法经常出现在栈顶,那么就认为是热点代码。
2)基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,
如果执行次数超过一定的阈值就认为是热点代码。

在HotSpot虚拟机中采用基于计数器的热点探测方式,它会给每个方法准备了两类计数器:方法调用计算器(Invocation Counter)和回边计数器(Back Edge Counter)。
通过虚拟机参数 -XX:ComplieThreshold 来设定阈值。
方法调用计数器触发即时编译的流程图:
在这里插入图片描述
方法调用计算器(Invocation Counter):是一个相对的执行效率,即一段时间内方法调用次数。当超过一定时间限度,如果方法调用次数仍然不足以让它交给JIT编译器,那这个方法的调用计数器就会减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间称为此方法统计的半衰周期(Counter Half Life Time). 进行热度衰减的动作是在虚拟机进行垃圾回收时顺便进行,可以通过虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,绝大部分方法都会 被便以为本地代码。 也可以通过 -XX:-UseHalfLifeTime 参数设置半衰周期的时间,单位是秒。
回边计数器(Back Edge Counter):方法中循环体的执行次数,在字节码中遇到控制流向后跳转的指令称为回边,通过参数 -XX:OnStackReplacePercentage来间接调整回边计数器的阈值。
C1模式下:
方法调用计算器阈值(ComplieThreshold) * OSR比率(OnStackReplacePercentage)/ 100 ,其中OnStackReplacePercentage默认值933

C2模式下:
方法调用计算器阈值(ComplieThreshold) * (OSR比率(OnStackReplacePercentage)- 解释器监控比率(InterpreterProfilePrecentege))/ 100

回边计数器触发JIT编译流程图:
在这里插入图片描述

三:编译器代码优化

优化方式:
1)语言无关的:公共子表达式消除
2)语言相关的:数组范围检查消除
3)最重要的技术:方法内联
4)最前沿技术:逃逸分析

1.公共子表达式消除

如果一个子表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。
如果这种优化仅限于程序基本块内,便成为局部公共子表达式消除。
如果优化范围涵盖了多个基本快,则称为全局公共子表达式消除。

2.数组范围检查消除

当编译器在编译期间根据数据流分析来确定出数组的长度,那么执行获取数组内数据就无需要判断边界了,那么在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。

3.方法内联

内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换。显然,这样就不会产生转去转回的问题,但是由于在编译时将函数体中的代码被替代到程序中,因此会增加目标程序代码量,进而增加空间开销,而在时间代销上不象函数调用时那么大,可见它是以目标代码的增加为代价来换取时间的节省。
C++是否为内联函数由自己决定,Java由编译器决定。Java不支持直接声明为内联函数的,如果想让他内联,你只能够向编译器提出请求: 关键字final修饰 用来指明那个函数是希望被JVM内联的,例:

public final void doSomething() {  
        // to do something  
} 

如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。

public static void foo(Object obj){
        if(obj == null)
            System.out.println("do something");
    }
    
    public static void testInline(){
        Object obj = null;
        foo(obj);
    }

会被替换成:

public static void testInline(){
        Object obj = null;
        if(obj == null)
            System.out.println("do something");
    }

4.逃逸分析

逃逸分析的基本行为就是分析对象的作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到地方方法中,称为方法逃逸, 甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果一个对象不会逃逸到其他线程或方法之外,可以做一下优化:
1)栈上分配(Stack Allocation): 如果一个对象确定不会逃逸出方法之外,那么让这个对象在栈上分配内存会是个不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,垃圾收集系统的压力也会减少很多。
2)同步消除(Synchronization Elimination): 线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉。
3)标量替换(Scalar Replacement): 标量是指一个数据已经无法再分解成更小的数据来表示了,java虚拟机中的基本数据类型都不能再分解,称为标量。如果一个对象经过逃逸分析无法逃逸到外部,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为创建它的若干个被这个方法使用到的成员变量来替换。

虽然目前逃逸分析技术仍然不够成熟,但是它给JIT编译器提供了一个重要的发展方向。

展开阅读全文

没有更多推荐了,返回首页