美文网首页
JVM性能分析

JVM性能分析

作者: 鱼蛮子9527 | 来源:发表于2022-05-19 09:35 被阅读0次

    JIT

    在谈到 Java 的编译机制的时候,其实应该按时期,分为两个阶段。一个是 javac 指令将 Java 源码变为 Java 字节码的静态编译过程。另一个是 Java 字节码编译为本地机器码的过程,并且因为这个过程是在程序运行时期完成的所以称之为即时编译(JIT),下面我们讨论的编译也都是指“即时编译”过程。

    解释器

    java作为一种跨平台的语言实现了一次编译到处运行的特性,这也就决定了它编译出来的不是机器码而是特定的字节码。解释器(各平台不同)就是将字节码解释为机器指令,调用操作系统来完成程序的执行。

    编译器

    解释器虽然实现了跨平台的特性,但是解释执行的效率是很低的,是以牺牲性能为代价来换取的跨平台特性。所以 JVM 发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code,不知道Sun的虚拟机命名是否跟这个有联系)。为了提高热点代码的执行效率,在运行时,虚拟机就会将这些代码翻译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的就是编译器,被称为即时编译器(Just In Time Compiler,简称为JIT)。

    HotSpot 虚拟机内置两个即时编译器,称为 Client Compiler 和 Server Compiler,分别简称为 C1,C2。

    • C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序
    • C2 编译器是为长期运行的应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。可能会对代码进行激进的优化来获取更好的性能,这些优化往往伴随着耗时较长的代码分析,同时会设定“逃生门”在激进优化不成立的时候回退到 C1 编译器或者解释器继续执行

    分层编译

    由于即时编译器编译本地代码需要占用程序运行时间,而要编译出优化程度较高的代码,所花费的时间可能更多。为了在程序启动速度与运行效率之间达到平衡,HotSpot 虚拟机启用了分层编译(Tiered Compilation)策略。

    在分层编译中,会同时使用两个编译器。当 C2 编译器在等待并分析一些代码片段来收集信息的时候,C1 编译器首先开始编译。这使得 C1 编译器能够快速的提高性能;而 C2 编译器将能够更好地提高性能,因为它拥有有热点方法更好的信息。分层编译在 JDK1.6 时期出现,在 JDK1.7 的 Server 模式中作为默认编译策略开启。

    根据编译器编译、优化的规模耗时,划分出不同的编译级别:

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

    profiling 就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。

    通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。对于 C1 代码的三种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为 profiling 越多,其额外的性能开销越大。

    这 5 个层次的执行状态中,1 层和 4 层为终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么 Java 虚拟机将不再次发出该方法的编译请求的。

    编译路径

    上图列举了一些编译的路径。

    通常情况下,热点方法会经过 3 层的 C1 编译,然后再被 4 层的 C2 编译。

    如果方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据。那么 JVM 断定该方法对于 C1 代码和 C2 代码的执行效率相同。在这种情况下,Java 虚拟机会在 3 层编译之后,直接选择用 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。

    默认启用的是混合模式(解释器与编译器配合工作)
    可以使用 -Xint 参数强制虚拟机运行于只有解释器模式下
    可以使用 -Xcomp 强制虚拟机运行于只有 JIT 的编译模式下
    Java8 中默认开启分层编译 -client,-server 参数已经无效,如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation)
    如果只想开启 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。

    热点探测

    JIT 编译器基于一个非常基本的原则:编译和优化执行频率更高的代码段。如果代码很少执行,即使优化之后提升 80% 的速度也是没有必要的。可以说热点代码是 JIT 编译的前提,而热点代码的判定就是基于热点探测技术。

    基于采样的热点探测

    主要是虚拟机会周期性的检查各个线程的栈顶,若某个或某些方法经常出现在栈顶,那这个方法就是“热点方法”。

    优点是实现简单。

    缺点是很难精确一个方法的热度,容易受到线程阻塞或外界因素的影响。

    基于计数器的热点探测

    主要就是虚拟机给每一个方法甚至代码块建立了一个计数器,统计方法的执行次数,超过一定的阀值则标记为此方法为热点方法。

    HotSpot 虚拟机使用的基于计数器的热点探测方法。然后使用了两类计数器:方法调用计数器和回边计数器。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发JIT编译器。

    • 方法调用计数器:方法调用计数器用于统计方法被调用的次数,默认阈值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可通过 -XX: CompileThreshold 来设定;而在分层编译的情况下 -XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。
    • 回边计数器:回边计数器用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该计数器用于计算是否触发 C1 编译的阈值。HotSpot 虚拟机提供 -XX:BackEdgeThreshold 供用户设置,但是当前的 HotSpot 虚拟机实际上并未使用此参数。而需要通过 -XX: OnStackReplacePercentage 来间接调整回边计数器的阈值,在 C1,C2 模式下计算公式也有不同,需要区别配置。而在分层编译的情况下,-XX: OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。

    常见编译优化

    方法内联

    在编译时,将方法调用优化为直接使用方法体中的代码进行替换,这就是方法内联,这样做减少了方法调用过程中压栈与出栈的开销,同时也为之后的一些优化手段提供条件。

        @Benchmark
        public int inline() {
            CounterObj counterObj = new CounterObj();
            counterObj.add(1);
            counterObj.add(2);
            return counterObj.getCounter();
        }
        @Benchmark
        @CompilerControl(CompilerControl.Mode.DONT_INLINE)
        public int dontInline() {
            CounterObj counterObj = new CounterObj();
            counterObj.add(1);
            counterObj.add(2);
            return counterObj.getCounter();
        }
         public static class CounterObj {
            @Getter
            private int counter;
            public void add(int num) {
                this.counter = sum(this.counter, num);
            }
            public int sum(int a, int b) {
                return a + b;
            }
        }
    ------------------------------------------------------------------------
    Benchmark                Mode  Cnt  Score   Error  Units
    MethodInline.dontInline  avgt    5  3.936 ± 0.127  ns/op
    MethodInline.inline      avgt    5  2.620 ± 0.042  ns/op
    

    逃逸分析

    如果一个变量的使用,在运行期检测它的作用范围不会超过一个方法或者一个线程的作用域。那么这个变量就不会被多个线程所共享,也就是说可以不将其分配在堆空间中,而是将其线程私有化。

    如何来检测一个变量的作用域仅在一个方法或者线程中呢? JVM 中使用全局数据流分析机制实现的一种机制,称之为逃逸分析,作为其他一些激进优化的前提条件。

    可以通过 -XX:+DoEscapeAnalysis 开启逃逸分析(jdk8中默认开启),-XX:-DoEscapeAnalysis 来关闭逃逸分析。下面是基于逃逸分析基础上做的一些优化。

    标量替换

    • 标量:即不可被进一步分解的量,Java 的基本数据类型就是标量(如:int,long 等基本数据类型以及 reference 类型等)。
    • 聚合量:标量的对立就是可以被进一步分解的量,被称之为聚合量,Java 中对象就是聚合量。

    当对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这个过程就是标量替换。对象将跟随栈的创建而创建,销毁而销毁,减轻了 GC 的负担以及工作内存跟主存的同步消耗。

    很多人会把标量替换跟栈上分配拆开来解释,但我认为标量替换跟栈上分配说的是一件事情。因为在栈上是不能创建对象的(栈上只能存放一些基本类型以及对象的引用),只有进行了标量替换,将聚合量拆分为标量之后才达成栈上分配的目的。

    可以通过 -XX:+EliminateAllocations 开启标量替换(jdk8 中默认开启),-XX:-EliminateAllocations 来关闭标量替换。

        @Benchmark
        @Fork(jvmArgsAppend = "-XX:+EliminateAllocations")
        public void escaped() {
            methodA();
        }
        @Benchmark
        @Fork(jvmArgsAppend = "-XX:-EliminateAllocations")
        public void noEscape() {
            methodA();
        }
        public void methodA() {
            new Tmp();
        }
        @Data
        public static class Tmp {
            private int data;
        }
    ------------------------------------------------------------------------
    Benchmark               Mode  Cnt  Score   Error  Units
    ScalarReplace.escaped   avgt    5  0.354 ± 0.055  ns/op
    ScalarReplace.noEscape  avgt    5  2.661 ± 0.264  ns/op
    

    同步消除

    当加锁的变量不会发生逃逸,是线程私有的时候,那么完全没有必要加锁。 在 JIT 时期就可以将同步锁去掉,以减少加锁与解锁造成的资源开销。

        @Benchmark
        @Fork(jvmArgsAppend = "-XX:+EliminateLocks")
        public void escaped() {
            methodA();
        }
        @Benchmark
        @Fork(jvmArgsAppend = "-XX:-EliminateLocks")
        public void noEscape() {
            methodA();
        }
        public void methodA() {
            synchronized (new Object()) {
                // do nothing
            }
        }
    ------------------------------------------------------------------------
    Benchmark            Mode  Cnt   Score   Error  Units
    LockRemove.escaped   avgt    5   0.357 ± 0.053  ns/op
    LockRemove.noEscape  avgt    5  21.847 ± 0.236  ns/op
    

    除了上面举例的几种经典优化方式,JVM 还为我们执行很多其他优化,如:无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块冲排序(Basic Block Reordering)等。

    代码缓存

    经过辛苦的编译优化之后的本地代码是比较珍贵的,这些代码会被缓存起来,当下一次运行的时候就可以直接使用了,也就是所谓的代码缓存(Code Cache)。在 32 位机器client模式默认 32MB,64 位机器默认 240MB。可以使用- XX:InitialCodeCacheSize,-XX:ReservedCodeCacheSize 来修改代码缓存的大小。

    代码缓存很少引起性能问题,但是一旦发生其影响可能是毁灭性的。如果代码缓存被占满,JVM 会打印出一条警告消息,并切换到 interpreted-only 模式:JIT 编译器被停用,字节码将不再会被编译成机器码。应用程序将继续运行,但运行速度会降低一个数量级,直到有人注意到这个问题。

    通过设置 -XX:+UseCodeCacheFlushing 这个参数,当代码缓存满了的时候,会让 JVM 换出一部分缓存以容纳新编译的代码,避免直接进入解释模式使性能急剧下降。在默认情况下,这个选项是关闭的。

    其他

    编译相关参数

    • -XX:+TieredCompilation:开启分层编译,jdk8 之后默认开启
    • -XX:+CICompilerCount=N:编译线程数,设置数量后,JVM 会自动分配线程数,C1:C2=1:2
    • -XX:TierXBackEdgeThreshold:OSR 编译的阈值
    • -XX:TierXMinInvocationThreshold:开启分层编译后各层调用的阈值
    • -XX:TierXCompileThreshold:开启分层编译后的编译阈值
    • -XX:ReservedCodeCacheSize:codeCache 最大大小
    • -XX:InitialCodeCacheSize:codeCache 初始大小
    • -XX:+PrintCompilation:输出编译过程
    • -XX:+PrintInlining:输出方法内联信息,需要跟 -XX:+UnlockDiagnosticVMOptions 一起使用

    由于编译情况复杂,JVM 也会动态调整相关的阈值来保证 JVM 的性能,所以不建议手动调整编译相关的参数。除非一些特定的 Case,比如 CodeCache 满了停止编译,可以适当增加 CodeCache 大小。或者一些非常常用的方法,未被内联到而拖累了性能,可以调整内敛层数或者内联方法的大小来解决。

    编译输出信息简介

    编译信息

    上图是一段编译的信息输出,从左到右依次是:

    • timestamp:从开始启动到现在的时间
    • compile_id:为每个编译过的方法赋值的一个自增 ID
    • attributes:表示正在编译的代码的状态
    • tier_level:编译的级别,可参照上文对编译级别的介绍
    • method:编译的方法名
    • size:Java 字节码的大小
    • deopt:去优化,也就是废弃优化

    attributes信息

    有五种不同类型的属性来表示编译的状态。

    % - The compilation is OSR (on-stack replacement).
    s - The method is synchronized.
    ! - The method has an exception handler.
    b - Compilation occurred in blocking mode.
    n - Compilation occurred for a wrapper to a native method.
    

    deopt 信息

    该字段通常具有以下两个值之一:“made not entrant”或“made zombie”。

    • made not entrant:有两种情况会发生这种情况。①分层编译模式下,更好的的优化代码出现时,将旧的编译代码无效,例如完成 4 层编译时候将 3 层编译无效②编译器收集了更多的信息,将优化进行回滚以便能够再次编译它,并基于新的信息重新优化代码。
    • made zombie:对于僵尸代码,这基本上是一种清理机制。在一段代码被标记为非进入者之后,它最终将被标记为 zombie,并将由 GC 收集以从代码缓存中释放该空间。

    我们可以看到为了让我们的代码跑的更快,JVM 默默为我们做了很多的事情,但是凡事都是有利有弊。比如一个 QPS 较高的应用,重启之后如果没有比较好的预热策略,可能就会因为分层编译导致接口响应变慢,CPU 飙升等问题。

    深入理解Java虚拟机--周志明

    JVM实用参数(二)JVM类型、工作模式及代码缓存

    代码缓存

    JIT与C1及C2

    JIT——即时编译的原理

    函数在实现过程内存中的压栈和出栈

    jvm之方法内联优化

    热点代码、分层编译、JIT优化(方法内联、锁消除、标量替换)

    HotSpot中执行引擎技术详解(三)——代码缓存机制

    基本功 | Java即时编译器原理解析及实践

    Java JIT compiler explained – Part 1

    相关文章

      网友评论

          本文标题:JVM性能分析

          本文链接:https://www.haomeiwen.com/subject/jxttprtx.html