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

一、JIT

即时编译(Just-in-time Compilation,JIT)是一种通过在运行时将字节码翻译为机器码,从而改善字节码编译语言性能的技术。

二、热点代码

在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译,第一段是把.java文件转换成.class文件。第二段编译是把.class转换成机器指令的过程。

当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。JIT会把部分“热点代码”class翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

参数-XX:ReservedCodeCacheSize = N(其中N是为特定编译器提供的默认值)主要设置热点代码缓存codecache的大小。如果缓存不够,则JIT无法继续编译,并且会去优化,比如编译执行改为解释执行,由此,性能会降低。

查看codecache使用情况:java -XX:+PrintCodeCache


相关参数:
-XX:InitialCodeCacheSize
用于设置初始CodeCache大小
-XX:ReservedCodeCacheSize
用于设置Reserved code cache的最大大小,通常默认是240M
-XX:CodeCacheExpansionSize
用于设置code cache的expansion size,通常默认是64K
-XX:+UseCodeCacheFlushing
是否在code cache满的时候先尝试清理一下

不同jdk版本热点代码缓存大小不同:


注:热点代码两个“热”:方法热和代码块热;

三、HotSpot编译器

在 HotSpot 虚拟机中,内置了两种 JIT,分别为C1 编译器和C2 编译器,这两个编译器的编译过程是不一样的。

1、C1 编译器

C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler。

2、C2 编译器

C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler,例如,服务器上长期运行的 Java 应用对稳定运行就有一定的要求。

JDK 6开始定义服务器级别的机器是至少有两个CPU和2GB的物理内存,才开启C2;

3、分层编译

在 Java8 中,默认开启分层编译,在1.8之前,分层编译默认是关闭的。在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。

分层编译将 JVM 的执行状态分为了 5 个层次:

  • 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;

  • 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;

  • 第 2 层:也称为 C1 编译,开启 Profiling,执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;

  • 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;

  • 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

mixed mode代表是默认的混合编译模式,除了这种模式外,我们还可以使用-Xint参数强制虚拟机运行于只有解释器的编译模式下,这时 JIT 完全不介入工作;也可以使用参数-Xcomp强制虚拟机运行于只有 JIT 的编译模式下。如下:

如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1

C1、C2和C1+C2,分别对应client、server和分层编译。C1编译速度快,优化方式比较保守;C2编译速度慢,优化方式比较激进。
C1+C2在开始阶段采用C1编译,当代码运行到一定热度之后采用G2重新编译。

四、如何判断热点代码(热点探测)

1、基于采样的热点探测:

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

  • 优点是实现简单;
  • 缺点是很难精确一个方法的热度,容易受到线程阻塞或外界因素的影响。
  • 典型应用-J9
2、基于计数器的热点探测(典型应用-Hotspot):

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

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

  • 方法调用计数器: 方法调用计数器用于统计方法被调用的次数,默认阈值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可通过-XX: CompileThreshold来设定;而在分层编译的情况下-XX: CompileThreshold指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。
  • 回边计数器:回边计数器用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700,可通过-XX: OnStackReplacePercentage=N来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。

回边计数器阈值计算规则

C1模式下:CompileThreshold*OnStackReplacePercentage/100;
即:方法调用计数器阈值*OSR比率/100;
C2模式下:(CompileThreshold)*(OnStackReplacePercentage-InterpreterProfilePercentage)/100;
即:方法调用计数器阈值*(OSR比率 - 解释器监控比率)/100;

eg:以C2模式为例,验证一下各个参数
回边计数器阈值=10000*(140-33)/100 = 10700;

OnStackReplacePercentage=140, CompileThreshold=10000,InterpreterProfilePercentage=33,计算阈值为10700;




建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。

五、JIT优化

JIT 编译运用了一些经典的编译优化技术来实现代码的优化:主要有两种:方法内联逃逸分析

1、方法内联

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。

//eg:
private int add1(int s1, int s2, int s3, int s4) {
        return add2(s1+s2)+add2(s3+s4);
}
private int add2(int s1, int s2) {
        return s1+s2;
}   
private int add(int s1, int s2, int s3, int s4) {
        return s1+s2+s3+s4;
}

提高方法内联:

  • 调整参数;
  • 写小方法;
  • 使用static、final关键字,不用出现方法继承,没有额外的类型检查,就可能发生内联;
2、逃逸分析

逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。根据逃逸分析的结果,进行优化的手段主要有三种:栈上分配、锁消除、标量替换。

1)栈上分配

默认创建一个对象是在堆中分配内存,当堆内存中的对象不再使用的时候,JVM垃圾回收器会回收对象,这个过程的消耗相对分配在栈中的对象的创建和销毁都更消耗时间和性能。逃逸分析发现对象只在方法中使用,就会将对象分配在栈上。

2)锁消除

如果是在单线程环境下, JIT 编译会对这个对象的方法锁进行锁消除,jdk1.8 默认开启。例如:

//-XX:-EliminateLocks 先关闭锁消除, 再打开, 执行此段代码100万次查看差别很大
public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
        }   
3)标量替换

逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,可以直接创建它的成员变量来代替,前提要开启逃逸分析(jdk1.8 默认开启逃逸分析-XX:+DoEscapeAnalysis )。

//eg:
public void foo() {
        Person info = new Person ();
        info.name = "queen";
        info.age= 18;   
    }
//逃逸分析后,代码会被优化(标量替换)为:
    public void foo() {
        String name= "queen";
        int age= 18;
    }
JVM参数中有关逃逸分析的参数配置:
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
-XX:-DoEscapeAnalysis 关闭逃逸分析
-XX:+EliminateLocks开启锁消除(jdk1.8默认开启)
-XX:-EliminateLocks 关闭锁消除
-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
-XX:-EliminateAllocations 关闭就可以了

六、查看及分析JIT编译结果

相关参数如下:
-XX:+PrintCompilation":在控制台打印编译信息;
-XX:+PrintInlining":要求JVM输出方法内联信息(Product版本需要"-XX:+UnlockDiagnosticVMOptions"选项,打开JVM诊断模式);
-XX:+PrintAssembly":JVM安装反汇编适配器后,该参数使得JVM输出编译方法的汇编代码(Product版本需要"-XX:+UnlockDiagnosticVMOptions"选项,打开JVM诊断模式);
-XX:+PrintLIR":输出比较接近最终结果的中间代码表示,包含一些注释信息(用于C1,Debug版本);
-XX:+PrintOptoAssembly":输出比较接近最终结果的中间代码表示,包含一些注释信息(用于C2,非Product版本);
-XX:+PrintCFGToFile":将JVM编译过程中各个阶段的数据输出到文件中,而后用工具C1 Visualizer分析(用于C1,Debug版本);
-XX:+PrintIdealGraphFile":将JVM编译过程中各个阶段的数据输出到文件中,而后用工具IdealGraphVisualizer分析(用于C2,非Product版本);
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容