JVM -- 运行期优化;JIT

Posted MinggeQingchun

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM -- 运行期优化;JIT相关的知识,希望对你有一定的参考价值。

一、即时编译(JIT)

JIT:Just In Time Compiler,即时编译器

这是针对解释型语言而言的,而且并非虚拟机必须,是一种优化手段。Hotspot就有这种技术,Java虚拟机标准对JIT的存在没有作出任何规范,这是虚拟机实现的自定义优化技术。

HotSpot虚拟机的执行引擎在执行Java代码是可以采用 解释执行 和 编译执行 两种方式的

如果采用的是编译执行方式,那么就会使用到JIT,而解释执行就不会使用到JIT。

HotSpot中的编译器是javac,它的工作就是将java代码编译为可执行的class文件,这部分工作是完全独立的,完全不需要运行时参与,所以java程序的编译是半独立实现的。有了字节码,就由解释器来进行解释执行,这是早期java虚拟机的工作流程;后来,java虚拟机会将执行频率高的方法或者语句块通过JIT编译成本地机器码,提高了代码执行的效率

(一)JIT工作原理

当JIT编译启用时(默认是启用的),JVM读入.class文件解释后,将其发给JIT编译器。JIT编译器将字节码编译成本机机器代码。

通常javac将程序源代码编译,转换成java字节码,JVM通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢。为了提高执行速度,引入了JIT技术。

在运行时JIT会把翻译过的机器码保存起来,以备下次使用,因此从理论上来说,采用该JIT技术可以,可以接近以前纯编译技术

(二)分层编译(Tiered Compilation)

Tiered Compilation是Java7中出现的,目的是整合C1的快速编译和C2的快速执行。因为C2使用了“激进”的优化手段,编译较慢。Java7以前,一般要求快速启动的GUI程序会选择C1,偏好性能的服务器程序使用C2

热点探测

HotSpot虚拟机中有两个编译器,一个是给客户端用的叫client Compiler,另一个是服务器用的叫Server Compiler。

一般的,把Client Compiler也叫C1编译器,Server Compiler叫C2编译器或Opto编译器

虚拟机会根据自身版本与宿主机的硬件性能自动选择运行模式,也可以使用 “-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式

热点代码有两类

  • 被多次执行的方法
  • 多次执行的循环体

虚拟机为每个代码块和方法设置了计数器,执行一次就加1。超过限定次数就认为是热点代码,开始JIT处理。给JIT去处理只是一个请求,并不会立即同步等待结果。因为JIT编译比较耗时,在编译完成前会继续解释执行。编译器处理都是以方法为单位,所以第一类热点代码是标准的JIT编译方式;对于第二种热点代码,JIT编译器会处理包含该循环的方法

HotSpot虚拟机有两种计数器(方法会同时记录这两个计数),它们的阈值并不同

1、调用次数计数器,可以通过-XX:CompileThreadhold参数指定阈值,不指定默认C1是1500次,C2是1万次

2、字节码中向之前跳转的指令叫“回边”,回边次数是回边计数器。明显这个针对的是第二类热点代码。它的阈值是算出来的,公式如下

OSR 阈值 = CompileThreshold * 
((OnStackReplacePercentage - InterpreterProfilePercentage)/100)

第一个参数CompileThreshold就是调用计数器,后面两个也都可以通过-XX指定。默认InterpreterProfilePercentage是33,而OnStackReplacePercentage的默认值在客户端和服务器模式不一样,分别是933和140,所以阈值分别是13500和10700

// -XX:+PrintCompilation -XX:-DoEscapeAnalysis
    public static void main(String[] args) 
        for (int i = 0; i < 200; i++) 
            long start = System.nanoTime();
            for (int j = 0; j < 1000; j++) 
                new Object();
            
            long end = System.nanoTime();
            System.out.printf("%d\\t%d\\n",i,(end - start));
        
    

上述代码在后面循环次数中时间花费比较少

图片来自百度

因为JVM 将执行状态分成了 5 个层次

0 层,解释执行(Interpreter)

1 层,使用 C1 即时编译器编译执行(不带 profiling)

2 层,使用 C1 即时编译器编译执行(带基本的 profiling)

3 层,使用 C1 即时编译器编译执行(带完全的 profiling)

4 层,使用 C2 即时编译器编译执行

注:

profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的 回边次数】等 

(三)即时编译器(JIT)与解释器的区别

1、解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释

2、JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需 再编译

3、解释器是将字节码解释为针对所有平台都通用的机器码

4、JIT 会根据平台类型,生成平台特定的机器码

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运 行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速 度。 执行效率上简单比较一下 Interpreter < C1 < C2

https://docs.oracle.com/en/java/javase/12/vm/java-hotspot-virtual-machine-performance-enhancements.html#GUID-F33D8BD0-5C4A-4CE8-8259-FD9D73C7C7C6

(四)内联

方法内联就是把被调用方函数代码"复制"到调用方函数中,来减少因函数调用开销的技术

一个简单的两数相加程序,被内联前的代码

private int add4(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 add4(int x1, int x2, int x3, int x4)   
    return x1 + x2 + x3 + x4;  
 

JVM会自动的识别热点方法,并对它们使用方法内联优化 ;一段代码需要执行多少次才会触发JIT优化呢?通常这个值由-XX:CompileThreshold参数进行设置

  • 使用client编译器时,默认为1500;
  • 使用server编译器时,默认为10000

一个方法就算被JVM标注成为热点方法,JVM仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况。

  • 如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联(可以通过** -XX:MaxFreqInlineSize=N**来设置这个大小)
  • 如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联(可以通过** -XX:MaxInlineSize=N **来设置这个大小)

可以通过增加这个大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。

如果想要知道方法被内联的情况,可以使用下面的JVM参数来配置

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

想要对热点的方法使用内联的优化方法,最好尽量使用final、private、static这些修饰符修饰方法,避免方法因为继承,导致需要额外的类型检查,而出现效果不好情况 

(五)字段优化

JMH 基准 OpenJDK: jmh

创建 maven 工程,添加依赖如下

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <jmh.version>1.0</jmh.version>
    <uberjar.name>benchmarks</uberjar.name>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>$jmh.version</version>
    </dependency>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-generator-annprocess</artifactId>
      <version>$jmh.version</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
public class Benchmark1 

    int[] elements = randomInts(1_000);

    private static int[] randomInts(int size) 
        Random random = ThreadLocalRandom.current();
        int[] values = new int[size];
        for (int i = 0; i < size; i++) 
            values[i] = random.nextInt();
        
        return values;
    

    @Benchmark
    public void test1() 
        for (int i = 0; i < elements.length; i++) 
            doSum(elements[i]);
        
    

    @Benchmark
    public void test2() 
        int[] local = this.elements;
        for (int i = 0; i < local.length; i++) 
            doSum(local[i]);
        
    

    @Benchmark
    public void test3() 
        for (int element : elements) 
            doSum(element);
        
    

    static int sum = 0;

    @CompilerControl(CompilerControl.Mode.INLINE)
    static void doSum(int x) 
        sum += x;
    


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

        new Runner(opt).run();
    

启用 doSum 的方法内联,测试结果如下(每秒吞吐量,分数越高的更好):

# Run complete. Total time: 00:00:28

Benchmark                Mode  Samples        Score  Score error  Units
c.m.Benchmark1.test1    thrpt        5  3543660.510   220838.607  ops/s
c.m.Benchmark1.test2    thrpt        5  3349451.189   913399.544  ops/s
c.m.Benchmark1.test3    thrpt        5  3472374.929   602064.401  ops/s

禁用 doSum 方法内联 

@CompilerControl(CompilerControl.Mode.DONT_INLINE)
static void doSum(int x) 
    sum += x;
# Run complete. Total time: 00:00:28

Benchmark                Mode  Samples       Score  Score error  Units
c.m.Benchmark1.test1    thrpt        5  421998.239   185020.935  ops/s
c.m.Benchmark1.test2    thrpt        5  505350.741    14294.063  ops/s
c.m.Benchmark1.test3    thrpt        5  463303.567   180720.536  ops/s

 如果 doSum 方法内联了,刚才的 test1 方法会被优化成下面的样子

@Benchmark
public void test1() 
    // elements.length 首次读取会缓存起来 -> int[] local
    for (int i = 0; i < elements.length; i++)  // 后续 999 次 求长度 <- local
        sum += elements[i]; // 1000 次取下标 i 的元素 <- local
    

二、反射优化

Java的反射技术,使静态语言的java具备了动态语言的某些特质。反射,让java动态,编程的时候更加灵活,能够动态获取信息以及动态调用对象方法。其实,Java基础技术中的代理,注解也都是依托反射才 能得以实现并应用广泛,另外我们常用的Spring、myBatis等技术框架也都是依托反射才能得以实现

public class Reflect1 

    public static void test() 
        System.out.println("test...");
    

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, IOException 
        Method test = Reflect1.class.getMethod("test");
        for (int i = 0; i <= 16; i++) 
            System.out.printf("%d\\t", i);
            test.invoke(null);
        
        System.in.read();
    

test.invoke 前面 0 ~ 15 次调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现 

package sun.reflect;
import java.lang.reflect.InvocationTargetException;

import java.lang.reflect.Method;
import sun.reflect.misc.ReflectUtil;

class NativeMethodAccessorImpl extends MethodAccessorImpl 

    private final Method method;
    private DelegatingMethodAccessorImpl parent;
    private int numInvocations;
    
    NativeMethodAccessorImpl(Method method) 
        this.method = method;
    

    public Object invoke(Object target, Object[] args)
        throws IllegalArgumentException, InvocationTargetException 
        // inflationThreshold 膨胀阈值,默认 15
        if (++this.numInvocations > ReflectionFactory.inflationThreshold()
            && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass()))
        
            // 使用 ASM 动态生成的新实现代替本地实现,速度较本地实现快 20 倍左右
            MethodAccessorImpl generatedMethodAccessor =
                (MethodAccessorImpl)
                    (new MethodAccessorGenerator())
                    .generateMethod(
                        this.method.getDeclaringClass(),
                        this.method.getName(),
                        this.method.getParameterTypes(),
                        this.method.getReturnType(),
                        this.method.getExceptionTypes(),
                        this.method.getModifiers()
                );
            this.parent.setDelegate(generatedMethodAccessor);
        
        // 调用本地实现
        return invoke0(this.method, target, args);
    

    void setParent(DelegatingMethodAccessorImpl parent) 
        this.parent = parent;
    

    private static native Object invoke0(Method method, Object target, Object[]args);

当调用到第 16 次(从0开始算)时,会采用运行时生成的类代替掉最初的实现,可以通过 debug 得到 类名为 sun.reflect.GeneratedMethodAccessor1 

使用阿里的 arthas 工具

java -jar arthas-boot.jar

再输入【jad + 类名】来进行反编译

$ jad sun.reflect.GeneratedMethodAccessor1

以上是关于JVM -- 运行期优化;JIT的主要内容,如果未能解决你的问题,请参考以下文章

JVM总结:晚期(运行期)优化

JVM晚期运行期优化

深入了解JVM——运行期优化

深入了解JVM——运行期优化

JVM理论:(四/2)编译过程——晚期(运行期)

运行期优化