8. 让java性能提升的JIT深度解剖

JVM性能调优

  • 1. C1、C2与Graal编译器
    • 1.1 C1编译器
    • 1.2 C2编译器
    • 1.3 分层编译
  • 2. 热点代码
  • 3. 热点探测
  • 4. 方法调用计数器
  • 5. 回边计数器
  • 6. 编译优化技术
    • 6.1 方法内联
  • 7. 锁消除
  • 8. 栈上分配
  • 9. 逃逸分析技术
  • 10. 标量替换

本文是按照自己的理解进行笔记总结,如有不正确的地方,还望大佬多多指点纠正,勿喷。

课程内容:

1、Java的解释执行与JIT(即时编译器)

2、JIT历史发展与分层编译

3、JIT优化技术之方法内联

4、JIT优化技术之锁消除

5、JIT优化技术之逃逸分析

6、JIT优化技术之标量替换与栈上分配

跨语言(语言无关性):JVM只识别字节码(所以跨语言强大),所以JVM其实跟语言是解耦的,也就是没有直接关联,JVM运行不是翻译Java文件,而是识别class文件,这个一般称之为字节码。还有像Groovy 、Kotlin、Scala等等语言,它们其实也是编译成字节码,所以它们也可以在JVM上面跑,这个就是JVM的跨语言特征。Java的跨语言性一定程度上奠定了非常强大的java语言生态圈。

8. 让java性能提升的JIT深度解剖_第1张图片
Java程序在运行的时候,主要就是执行字节码指令,一般这些指令会按照顺序解释执行,这种就是解释执行。

8. 让java性能提升的JIT深度解剖_第2张图片

但是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码,如果按照解释执行,效率是非常低的。(这个就是Java以前被C、C++开发者吐槽慢的原因)

以上的这些代码称为热点代码。所以,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。

完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。

1. C1、C2与Graal编译器

这个编译不是把java变成class,而是将class变成0101这种二进制代码。

8. 让java性能提升的JIT深度解剖_第3张图片
在JDK1.8中 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器。

1.1 C1编译器

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

C1编译器几乎不会对代码进行优化

1.2 C2编译器

C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译也被称为Server Compiler。

但是C2代码已超级复杂,无人能维护!所以才会开发Java编写的Graal编译器取代C2(JDK10开始)

1.3 分层编译

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

Java7及以后引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,当然我们也可以通过参数强制指定虚拟机的即时编译模式。比如我启动的时候就可以用C1,C1比较快嘛。

在 Java8 中,默认开启分层编译。

通过 java -version 命令行可以直接查看到当前系统使用的编译模式(默认分层编译)
8. 让java性能提升的JIT深度解剖_第4张图片
使用“-Xint”参数强制虚拟机运行于只有解释器的编译模式

8. 让java性能提升的JIT深度解剖_第5张图片

使用“-Xcomp”强制虚拟机运行于只有 JIT 的编译模式下

8. 让java性能提升的JIT深度解剖_第6张图片

2. 热点代码

热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。

JVM提供了一个参数“-XX:ReservedCodeCacheSize”,用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。

如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。

通过 java -XX:+PrintFlagsFinal –version查询:

在这里插入图片描述

3. 热点探测

在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”

虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。

热点探测技术(以下条件满足一个即可)

  • 方法调用计数器
    服务端模式默认10000
  • 回边计数器(就是的goto line 22,意思就是执行到这个地方回到上面的第22行去。)
    服务端模式默认107008. 让java性能提升的JIT深度解剖_第7张图片

4. 方法调用计数器

用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次(我们用的都是服务端,java –version查询),可通过 -XX: CompileThreshold 来设定

8. 让java性能提升的JIT深度解剖_第8张图片

通过 java -XX:+PrintFlagsFinal –version查询

8. 让java性能提升的JIT深度解剖_第9张图片

5. 回边计数器

用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,在服务端模式下是10700。

怎么算的呢!参考以下公式(有兴趣可了解):

回边计数器阈值 =方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100

通过 java -XX:+PrintFlagsFinal –version查询先关参数:
在这里插入图片描述
8. 让java性能提升的JIT深度解剖_第10张图片
其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700.

回边计数器阈值 =10000×(140-33)=10700

6. 编译优化技术

JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码.

6.1 方法内联

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。

  • 热点探测技术触发
  • 方法体大小受限
  • 使用方法内联提升性能

例如以下方法:

private int add1(int x1, int x2, int x3, int x4){
    return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2){
    return x1 + x2;
}

最终会被优化为:

//内联后的调用类似于以下方法
private int add(int x1, int x2, int x3, int x4){
    return x1 + x2 + x3 + x4;
}

JVM 会自动识别热点方法,并对它们使用方法内联进行优化。

我们可以通过 -XX:CompileThreshold 来设置热点方法的阈值。

但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。

而方法体的大小阈值,我们也可以通过参数设置来优化:

经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过 -XX:FreqInlineSize=N 来设置大小值;

在这里插入图片描述

不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。
在这里插入图片描述

所以我们熟悉的阿里归约之类的对方法体 参数名不要超过几个之类的 因为这里涉及底层的编译有

代码演示

package ding;

/**
 * 方法内联
 * -XX:+PrintCompilation   再控制台打印编译过程信息
 * -XX:+UnlockDiagnosticVMOptions  解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
 * -XX:+PrintInlining   将内联方法打印出来
 */
public class CompDemo {
    private int add1(int x1, int x2, int x3, int x4){
        return add2(x1, x2) + add2(x3, x4);
    }
    private int add2(int x1, int x2){
        return x1 + x2;
    }
    //内联后的调用类似于以下方法
    private int add(int x1, int x2, int x3, int x4){
        return x1 + x2 + x3 + x4;
    }

    public static void main(String[] args) {
        CompDemo compDemo = new CompDemo();
        //方法调用计数器的默认阈值10000次,我们循环遍历超过需要阈值
        for (int i = 0; i < 10000; i++){
            compDemo.add1(1,2,3,4);
        }
    }
}

设置 VM 参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions

-XX:+PrintInlining

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

-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

8. 让java性能提升的JIT深度解剖_第11张图片

从打印结果看发生了内联

8. 让java性能提升的JIT深度解剖_第12张图片

什么情况下不会发生呢,我们把for循环里面的数值改的小一点。改成100吧。

在这里插入图片描述
可以看到没有发生内联。
8. 让java性能提升的JIT深度解剖_第13张图片

热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  • 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  • 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

7. 锁消除

在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer。由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。

8. 让java性能提升的JIT深度解剖_第14张图片
但实际上,在以下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。

下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。

package ding;

public class UnLock {
    //两种国之间性能有没有很大的差别
    public static void main(String[] args) {
        long timeStart1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++){
            BufferString("king","666");//这里是调用方法
        }
        long timeEnd1 = System.currentTimeMillis();
        System.out.println("StringBuffer花费的时间" + (timeEnd1 - timeStart1));


        long timeStart2 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++){
            BuildString("king","999");//这里是调用方法
        }
        long timeEnd2 = System.currentTimeMillis();
        System.out.println("StringBuild花费的时间" + (timeEnd2 - timeStart2));
    }

    private static String BuildString(String s1, String s2) {
        StringBuilder sd = new StringBuilder();
        sd.append(s1);
        sd.append(s2);
        return sd.toString();
    }

    private static String BufferString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
}

8. 让java性能提升的JIT深度解剖_第15张图片
这个时候发现二者的时间差不多,其实是因为有JIT的锁消除技术。当我们运行的时候,运行一个很大的循环一定会触发JIT,就会热点追踪

我们把锁消除关闭—测试发现性能差别有点大

-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)

-XX:-EliminateLocks 关闭锁消除

8. 让java性能提升的JIT深度解剖_第16张图片
锁消除,默认是true

8. 让java性能提升的JIT深度解剖_第17张图片

8. 栈上分配

几乎所有的对象都会被分配到堆中,但是有例外,这个例外就是因为有JIT优化技术,就是逃逸分析技术。
栈是其他的方法访问是访问不到的,栈里面是线程隔离的。

9. 逃逸分析技术

8. 让java性能提升的JIT深度解剖_第18张图片
逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。

比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。

从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。

当然逃逸分析技术属于JIT的优化技术,所以必须要符合热点代码,JIT才会优化,另外对象如果要分配到栈上,需要将对象拆分,这种编译优化就叫做标量替换技术。

说白了,有了热点探测,才有逃逸分析,有了逃逸分析,才有了标量替换,有了标量替换才有了栈上分配。

package ding;

public class EscapeAnalysisTest {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 50000000; i++) {
            allocate();

        }
        System.out.println((System.currentTimeMillis() - start) + " ms");
        Thread.sleep(600000);
    }
    static void allocate(){    //逃逸分析(不会逃逸出方法)
        //这个myObject引用没有出去,也没有其他发给发使用
        MyObject myObject = new MyObject(2020,2020.6);

    }
    static class MyObject{
        int a;
        double b;
        MyObject(int a,double b){
            this.a = a;
            this.b = b;
        }
    }
}

运行结果:

8. 让java性能提升的JIT深度解剖_第19张图片

这个时候走的就是栈上分配,他很快的。

我们可以使用-XX:+PrintGC按理来说应该会垃圾回收,毕竟创建了50000000个对象,我们把这个参数加上再运行一下:

8. 让java性能提升的JIT深度解剖_第20张图片

然而事实证明并没有垃圾回收的日志。

为什么没有垃圾回收,因为没有在堆上分配啊,所以没有垃圾回收。

那么栈上分配肯定有逃逸分析,逃逸分析是-XX:-DoEscapeAnalysis,这个参数是默认开启的。那么我们可以尝试关闭,如果关闭了就只能走堆空间分配了。

-XX:-DoEscapeAnalysis -XX:+PrintGC

8. 让java性能提升的JIT深度解剖_第21张图片

我们发现这个时候就出现了垃圾回收,这个时候就是堆上分配了。

可以看到栈上分配还是节省了很多时间。

10. 标量替换

逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。

堆里面是对象,栈里面的放的是基本数据类型,8大数据类型以及一些引用。要把一个对象放到栈里面就要拆开,所以这个过程就称为标量替换。

标量替换就是说把一个对象进行拆解,拆解到栈或者寄存器上面。栈上分配就是把一个对象放到栈上,标量替换是一个技术点。

package ding;
/*-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)

-XX:-DoEscapeAnalysis 关闭逃逸分析

-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)

-XX:-EliminateAllocations 关闭标量替换*/
public class VariableDemo {
    public void foo(){
        Teacher teacher = new Teacher();
        teacher.name = "king";
        teacher.age = 18;
    }
    public void fool(){
        String name = "king";
        int age = 18;
    }
}
class Teacher{
    String name;
    String sexType;
    int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSexType() {
        return sexType;
    }

    public void setSexType(String sexType) {
        this.sexType = sexType;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

如上图中foo方法如果使用标量替换的话,那么最后执行的话就是foo1方法的效果。

-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)

-XX:-DoEscapeAnalysis 关闭逃逸分析

-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)

-XX:-EliminateAllocations 关闭标量替换

如果我们把逃逸分析去掉,而是把标量替换加上,一样可以达到堆上分配的

-XX:-EliminateAllocations -XX:+PrintGC

8. 让java性能提升的JIT深度解剖_第22张图片

总结:

  1. 我们首先判断是不是热点

热点探测技术(以下条件满足一个即可)

  • 方法调用计数器
    服务端模式默认10000次
  • 回边计数器(就是的goto line 22,意思就是执行到这个地方回到上面的第22行去。)
    服务端模式默认10700次
  1. 如果不是则直接堆上分配,如果是热点则判断是否能逃逸
  2. 如果能逃逸则堆上分配,如果不能逃逸判断是否开启了标量替换
  3. 如果没有开启标量替换则堆上分配,如果开启了则栈上分配。

整体总结:

左边是解释器,右边是及时编译器(这个指的是图1)。解释器是逐行解释执行。及时编译器是翻译成1010这种二进制。所以在JIT这块就可以做优化,所以在JIT里面就会有编译器,C1制作编译,几乎不做优化,C2为长期运行的应用做性能调优。所以才会引出热点探测技术,有了探测技术,我才可以做一些优化,比如方法内联,减少栈上的压入。可以做锁消除,没有必要做锁。我们还可以去做逃逸分析、标量替换、栈上分配

方法内联是一种编译技术,就是将一个方法的代码直接嵌入到调用它的代码处,从而避免了方法调用的开销。这样可以提高程序运行效率,因为方法内联能够减少每次方法调用时栈帧的创建和销毁所产生的开销。

举个例子,比如我们有一个方法doSomething,在这个方法中还要调用另一个方法calc,当我们在程序中多次调用doSomething时,每次都需要创建和销毁calc方法所需的栈帧。而如果将calc方法内联到doSomething方法里面,那么就可以避免这些栈帧的创建和销毁开销,从而提高程序的运行效率。

Java中的方法内联可以通过字节码增强技术实现,也可以由JIT编译器跟踪应用程序的行为自动完成。但是方法内联也有缺点,如果开启过多的方法内联,会增加代码量、编译时间和内存使用量,所以需要合理使用。

Java中的锁消除(Lock Elimination)是指编译器在代码执行期间分析程序,如果发现某些同步块或锁机制对于实际业务逻辑并没有意义,可以将其消除掉。这样做可以减少无谓的锁开销、提升代码运行效率和性能。

锁消除有以下两种方式:

1. 基于程序分析:根据代码的流程,分析是否存在竞争情况,如果不存在或者轻微,则可以通过更改程序来消除锁。

2. 基于逃逸分析:在编译期间进行逃逸分析,判断对象是否会被其他线程访问,如果不会,那就可以消除该对象相关的锁。

锁消除的优点是可以减少锁带来的性能瓶颈以及排查使用锁所引发的安全问题。但是,在一些复杂场景下,锁消除可能会带来意想不到的后果,比如导致多线程数据竞争等情况,因此需要根据具体情况进行选择和应用。

总之,锁消除是一个重要的JVM优化技术,它可以避免过度的同步操作对程序性能造成的负面影响,提高代码的运行效率和性能。

你可能感兴趣的:(JVM性能调优,java,jvm,开发语言)