JVM中JIT即时编译器的学习

目录

前言

C1和C2的交互模式

热点检测

基于采样的热点探测

基于计数器的热点探测

方法调用计数器        

回边计数器

HotSpot VM设置程序执行方式

测试JIT即时编译器优化效果

JIT编译器的优化手段

方法内联

逃逸分析

锁消除

标量替换

JIT优化可能带来的问题

解决JIT优化带来的问题

总结

参考资料



前言

        在Java中,JIT即时编译器是一项用来提升应用程序代码执行效率的技术。字节码指令被 Java 虚拟机解释执行,如果有一些指令执行频率高,称之为热点代码,这些字节码指令则被JIT即时编译器编译成机器码同时进行一些优化,最后保存在内存中,将来执行时直接读取就可以运行在计算机硬件上了。

在HotSpot中,有三款即时编译器,C1、C2和Graal,其中Graal在jdk10才出现,长期的目标为代替C2 , 这里只说C1和C2

C1编译效率比C2快,但是优化效果不如C2。所以C1适合优化一些执行时间较短的代码,C2适合优化服务端程序中长期执行的代码。

JDK7之后,采用了分层编译的方式,在JVM中C1和C2会一同发挥作用,分层编译将整个优化级别分成了5个等级。

等级使用的组件描述保存的内容性能打分(1 - 5)
0解释器解释执行1
1C1即时编译器C1完整优化优化后的机器码4
2C1即时编译器C1完整优化记录方法调用次数及循环次数优化后的机器码部分额外信息:方法调用次数及循环次数3
3C1即时编译器C1完整优化记录所有额外信息优化后的机器码所有额外信息:分支跳转次数、类型转换等等2
4C2即时编译器C2完整优化优化后的机器码5

        C1即时编译器和C2即时编译器都有独立的线程去进行处理,内部会保存一个队列,队列中存放需要编译的任务。一般即时编译器是针对方法级别来进行优化的,当然也有对循环进行优化的设计。

C1和C2的交互模式

详细来看看C1和C2是如何进行协作的:

1、先由C1执行过程中收集所有运行中的信息,方法执行次数、循环执行次数、分支执行次数等等,然后等待执行次数触发阈值(分层即时编译由JVM动态计算)之后,进入C2即时编译器进行深层次的优化。

2、方法字节码执行数目过少,先收集信息,JVM判断C1和C2优化性能差不多,那之后转为不收集信息,由C1直接进行优化。

3、C1线程都在忙碌的情况下,直接由C2进行优化。

4、C2线程忙碌时,先由2层C1编译收集一些基础信息,多运行一会儿,然后再交由3层C1处理,由于3层C1处理效率不高,所以尽量减少这一层停留时间(C2忙碌着,一直收集也没有意义),最后C2线程不忙碌了再交由C2进行处理。

        这里说明一下 , 其实还有一种情况是如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行

热点检测

        要知道某段代码是不是热点代码 , 是不是需要需要触发即时编译 , 这个行为被称为 : "热点探测"

        其实"热点探测" 不一定非要知道方法具体被调用了多少次 , 目前主流的热点探测判定方式有两种 , 分别是 :

基于采样的热点探测

        周期性的检测各个线程的栈顶 , 发现某个方法经常出现在栈顶,那这个方法就是 "热点方法" . 这个方法的好处是实现简单高效 , 缺点就是不能精确的统计一个方法的热度 , 容易受到线程阻塞或别的外界因素的影响而扰乱热点检测

基于计数器的热点探测

         采用这种方法的虚拟机会为每个方法 , 甚至是每个代码块都建立一个计数器 , 统计方法的执行次数 , 某个方法执行次数超过了阈值就认为它是 "热点方法" ,好处是热点统计的结果比较准确 , 缺点就是比较麻烦,需要为每一个方法都建立并维护一个计数器

        在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法 , 为了实现这个热点计数器 , 虚拟机为每个方法准备了两个计数器

方法调用计数器        

        方法被调用次数的计数器 , 它的默认阈值C1中是 1500次,在C2中是 10000次, 这个阈值可以通过 -XX:CompileThreshold来人为设定

        

        当一个方法被调用时 , 虚拟机会先检查该方法是否存在已被编译过的版本 , 如果存在,则优先使用编译后的本地代码来执行 , 如果不存在被编译过的代码 , 则将该方法调用计数器加1,然后判断是否超过阈值,超过阈值则向即时编译器提交一个该方法的代码编译请求

        如果没有做过任何设置 , 执行引擎默认不会同步等待编译请求完成 , 而是继续进入解释器按照解释的方式来执行字节码 , 直到提交的请求被即时编译器编译完成 . 编译完成之后 , 相应方法的调用入口地址就会被系统自动改写成新值 , 下一次调用方法时就会使用已编译的版本了

        其实在默认设置下 , 方法调用计数器统计的并不是方法被调用的绝对次数 , 而是一个相对的执行频率 , 即一段时间之内方法被调用的次数 . 当超过一定的时间限度 , 如果方法的调用次数仍然不足让它提交给即时编译器编译 , 那该方法的调用计数器就会减半 , 这个过程被称为方法调用计数器热度的衰减(Counter Decay)

        而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) , 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的 , 可以使用虚拟机参数 : -XX:-UseCounterDecay来关闭热度衰减, 让方法计数器统计方法调用的绝对次数 , 这样只要系统运行时间足够长 , 程序中绝大部分方法都会被编译成本地代码 , 另外还可以使用 -XX:CounterHalfLifeTime参数设置半衰周期的时间 , 单位是秒

回边计数器

        统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)",其实来说 , 不应该是循环体代码执行的次数 , 应当为回边次数 , 因为并非所有的循环都是回边的,比如空循环就是自己跳转到自己的过程 , 就不算控制流向后跳转 , 也不会被回边计数器统计 . 很显然建立回边计数器统计的目的是为了触发栈上的替换编译

        

        关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值

-XX:CompileThreshold 的参数 , -XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。

  • 虚拟机运行在客户端模式下,回边计数器阈值计算公式为:

方法调用计数器阈值(-XX:CompileThreshold)* OSR比率(-XX:OnStackReplacePercentage)/ 100。 其中-XX:OnStackReplacePercentage 默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为13995。

  • 虚拟机运行在服务端模式下

方法调用计数器阈值(-XX:CompileThreshold)*(OSR比率(-XX:OnStackReplacePercentage)- 解释器监控比率(-XX:InterpreterProfilePercentage))/ 100。 其中-XX:OnStackReplacePercentage默认值为140,-XX:InterpreterProfilePercentage 默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700。

        当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图所示。

开启分层编译的情况下,-XX:CompileThreshold参数设置的阈值将会失效,触发编译会由以下的条件来判断:

  • 方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数。

  • 方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时。

分层编译触发条件公式

i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s  && i + b > TierXCompileThreshold * s) 

i为调用次数,b是循环回边次数

上述满足其中一个条件就会触发即时编译,并且JVM会根据当前的编译方法数以及编译线程数动态调整系数s。

HotSpot VM设置程序执行方式

缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

-Xint : 完全采用解释器模式执行程序:

-Xcomp : 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。

-Xmixed : 采用解释器+即时编译器的混合模式共同执行程序。

测试JIT即时编译器优化效果

        

/*
 * Copyright (c) 2005, 2014, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package org.sample;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.concurrent.TimeUnit;
//执行5轮预热,每次持续1秒
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
//执行一次测试
@Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
//显示平均时间,单位纳秒
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class MyJITBenchmark {

    public int add (int a,int b){
        return a + b;
    }

    public int jitTest(){
        int sum = 0;
        for (int i = 0; i < 10000000; i++) {
            sum = add(sum,100);
        }
        return sum;
    }


    //禁用JIT
    @Benchmark
    @Fork(value = 1,jvmArgsAppend = {"-Xint"})
    public void testNoJIT(Blackhole blackhole) {
        int i = jitTest();
        blackhole.consume(i);
    }

    //只使用C1 1层
    @Benchmark
    @Fork(value = 1,jvmArgsAppend = {"-XX:TieredStopAtLevel=1"})
    public void testC1(Blackhole blackhole) {
        int i = jitTest();
        blackhole.consume(i);
    }

    //分层编译
    @Benchmark
    public void testMethod(Blackhole blackhole) {
        int i = jitTest();
        blackhole.consume(i);
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(MyJITBenchmark.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

结果 :

Benchmark                  Mode  Cnt          Score         Error  Units
MyJITBenchmark.testC1      avgt    5    2366699.562 ±   53699.031  ns/op
MyJITBenchmark.testMethod  avgt    5          1.698 ±       0.036  ns/op
MyJITBenchmark.testNOJIT   avgt    5  178392814.173 ± 8689487.626  ns/op

JIT编译器主要优化手段是方法内联逃逸分析

JIT编译器的优化手段

方法内联

        方法内联(Method Inline):方法体中的字节码指令直接复制到调用方的字节码指令中,节省了创建栈帧的开销。

        

并不是所有的方法都可以内联,内联有一定的限制:

        1、方法编译之后的字节码指令总大小 < 35字节,可以直接内联。(通过-XX:MaxInlineSize=值 控制)

        2、方法编译之后的字节码指令总大小 < 325字节,并且是一个热方法。(通过-XX:FreqInlineSize=值 控制)

        3、方法编译生成的机器码不能大于1000字节。(通过-XX:InlineSmallCode=值 控制)

        4、一个接口的实现必须小于3个,如果大于三个就不会发生内联。

逃逸分析

        逃逸分析指的是如果JIT发现在方法内创建的对象不会被外部引用,那么就可以采用锁消除、标量替换等方式进行优化。

这段代码可以使用逃逸分析进行优化,因为test对象不会被外部引用,只会在方法中使用。

for (int i = 0; i < 10000000; i++) {
	Test test = new Test();
	int t = test.a;
}

这段代码就会有一定的问题,如果在方法中对象被其他静态变量引用,那优化就无法进行。

for (int i = 0; i < 100000; i++) {
   Test test = new Test();
    int t = testToMethod(test);
}

锁消除

        逃逸分析中的锁消除指的是如果对象被判断不会逃逸出去,如果同步代码块中的锁对象通过逃逸分析发现只能被一个线程访问,,那么在对象就不存在并发访问问题,对象上的锁处理都不会执行,从而提高性能。比如如下写法

synchronized (new Test()) {
    
}

锁消除优化在真正的工作代码中并不常见,一般加锁的对象都是支持多线程去访问的。

标量替换

        逃逸分析真正对性能优化比较大的方式是标量替换,在Java虚拟机中,对象中的基本数据类型称为标量,引用的其他对象称为聚合量标量替换指的是如果方法中的对象不会逃逸,那么其中的标量就可以直接在栈上分配,省去了创建对象的步骤

如下图中,point对象不存在逃逸,那么就可以将test方法中的字节码指令直接挪到循环中,减少方法调用的开销。

JIT优化可能带来的问题

        了解了JIT编译的原理之后 , 其实可以知道 , JIT优化是在运行期进行的 , 并且也不是java进行刚一启动就能优化的 , 是需要先执行一段时间的 , 因为他需要先知道哪些是热点代码

        所以 , 在JIT优化开始之前 , 我们的所有请求 , 都是要经过解释执行的 , 这个过程就会相对满一些 , 而且 , 如果你们的应用请求量比较大的话,这种问题就会更加明显 , 在应用启动过程中 , 会有大量的请求过来 , 这就导致解释器持续的在努力工作 .

        一旦解释器对CPU资源占用比较大的话 , 就会间接导致CPU , LOAD 等彪高 , 导致应用的性能进一步下降 , 这也是为什么很多应用在发布过程中 , 会出现刚刚重启好的引用会发生大量的超时问题了 .

        而随着请求的不断增多 , JIT优化就会被触发 , 这就使得后续的热点请求的执行可能就不需要再通过解释执行了,直接运行JIT优化后缓存的机器码就行了

解决JIT优化带来的问题

主要有两种思路 :

  • 提升JIT优化的效率

  • 降低瞬时的请求量

        提升JIT优化效率的设计上 , 可以了解下阿里研发的JDK --- dragonwell

        这个相比OpenJdk提供了一些专有的特性 , 其中有一项叫做JwarmUp的技术就是解决JIT优化效率的问题

        这个技术主要是通过记录java应用上一次运行时候的编译信息到文件中, 在下一次应用启动时,读取该文件 , 从而在流量进来之前 , 提前完成类加载 , 初始化 , 方法编译 , 从而跳过解释执行阶段 , 直接执行编译好的机器码就可以了

        除了针对JDK做优化之外 , 还可以采用另外一种方法来解决问题 , 那就是做好预热 .

很多人都听说过缓存预热 , 其实思想是类似的. 就是在应用刚刚启动的时候 , 通过调节负载均衡 , 不要很快的把大流量分发给它 , 而是先给它一小部分流量 , 通过这部分流量来触发JIT优化 , 等优化好了之后 , 再把流量调大

总结

根据JIT即时编器优化代码的特性,在编写代码时注意以下几个事项,可以让代码执行时拥有更好的性能:

1、尽量编写比较小的方法,让方法内联可以生效。

2、高频使用的代码,特别是第三方依赖库甚至是JDK中的,如果内容过度复杂是无法内联的,可以自行实现一个特定的优化版本。

3、注意下接口的实现数量,尽量不要超过2个,否则会影响内联的处理。

4、高频调用的方法中创建对象临时使用,尽量不要让对象逃逸。

参考资料

        《深入理解Java虚拟机》第三版 周志明大佬
         b站黑马最新JVM视频

        程序员Hollis技术文档

  • 34
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值