美文网首页
90% 的 Java 程序员都说不上来的为何 Java 代码越执

90% 的 Java 程序员都说不上来的为何 Java 代码越执

作者: 干货满满张哈希 | 来源:发表于2021-01-28 07:59 被阅读0次

    麻烦大家帮我投一票哈,谢谢

    经常听到 Java 性能不如 C/C++ 的言论,也经常听说 Java 程序需要预热,那么其中主要原因是啥呢

    面试的时候谈到 JVM,也有很多面试官喜欢问,为啥 Java 程序越执行越快呢

    一般人都能回答上来,类加载,缓存预热等等,但是深入下去,最重要的却没有答上来,今天本系列文章就来帮助大家理解这个问题的关键。首先是 JIT 优化

    首先,我们从一个简单的例子看起,来感受下程序是否越来越快:

    package com.test;
    
    import java.util.concurrent.TimeUnit;
    
    public class CompileTest {
        public static void main(String[] args) throws InterruptedException {
            while (true) {
                test1();
                TimeUnit.SECONDS.sleep(1);
            }
        }
    
        public static void test1() {
            long time1 = System.nanoTime();
            long count1 = 0;
            for (int i = 0; i < 10000; i++) {
                count1++;
            }
            //为了和编译日志区分,这里输出到error输出
            System.err.println(System.nanoTime() - time1 + "-----" + count1);
        }
    }
    

    运行时,加上参数-XX:+PrintCompilation,打印一下编译日志(其实这个参数以后也许就过期了,建议使用 JVM 标准日志参数:-Xlog:jit+compilation=info),可以看到:

    432900-----10000
    250800-----10000
    194600-----10000
    197200-----10000
    131600-----10000
    184000-----10000
       6369  374 %     3       com.test.CompileTest::test1 @ 9 (61 bytes)
    162300-----10000
       7369  375       3       com.test.CompileTest::test1 (61 bytes)
    68300-----10000
    60300-----10000
    47200-----10000
    48100-----10000
      11371  378 %     4       com.test.CompileTest::test1 @ 9 (61 bytes)
    55600-----10000
      11388  374 %     3       com.test.CompileTest::test1 @ 9 (61 bytes)   made not entrant
      12372  379       4       com.test.CompileTest::test1 (61 bytes)
    157600-----10000
      12389  375       3       com.test.CompileTest::test1 (61 bytes)   made not entrant
    600-----10000
    700-----10000
    600-----10000
    1200-----10000
    900-----10000
    900-----10000
    

    从输出中可以看出,貌似JVM对test1这段代码做了一些事情,使方法运行越来越快了。这就是JIT做的优化,随着代码的执行,热点代码会被优化,让执行更加迅速。这也是为什么,通过一般方法(javac命令)编译出来java class文件在执行的时候,要预热之后,才能发挥最大性能。接下来,我们来详细介绍下JIT。

    OpenJDK Hotspot JVM,是最广泛运用的Java JVM。主要包含两部分,执行引擎(execution engine)和运行时(runtime)。执行引擎包括两部分,一个是垃圾收集器,另一个就是我们今天的主题, JIT(just-in-time)编译器。

    什么是JIT

    JVM是Java一次编译,跨平台执行的基础。当java被编译为字节码形式的class文件之后,他可以在任意的JVM运行。这里说的编译,主要是指前端编译器。

    Java中主要有两种编译器:

    1. 前端编译器,将.java文件编译为JVM可执行的.class字节码文件,即javac,主要职责包括:词法、语法分析,填充符号表,语义分析,字节码生成。输出为字节码文件,也可以理解为是中间表达形式(称为IR:Intermediate Representation)。对应上面的例子就是将CompileTest.java编译成符合Java规范的字节码文件CompileTest.class

    2. 后端编译器,在程序运行期间将字节码转变成机器码,通过解释器和运行时编译器混合模式(现在的 Java 程序在运行时基本都是解释执行加编译执行),如 HotSpot 虚拟机自带的解释器还有 JIT(Just In Time Compiler)编译器(分 Client 端和 Server 端),其中JIT还会将中间表达形式进行一些优化。对应上面的例子就是test1方法执行越来越快。

    Java 9中还引入了实验编译器AOT(Ahead-Of-Time)编译器,直接生成机器码。主要用于减少JAVA启动预热时间,比较适用于单次执行时间有限需要高效执行的程序,或者是小集成芯片环境,对效率要求比较高。AOT与Graal我们会在系列的最后着重介绍。对应上面的例子就是,test1方法不用预热就会执行的和上面最会一样那么快。但是相应的,机器码占用的大小比字节码大的多得多,而且不能跨平台。

    为什么要这么区分呢?首先,不同机器的机器码是不一样的,编译生成统一的字节码保证了跨平台应用的可能性。然后,将字节码优化(中间表达形式优化)放到运行时优化,这样低版本的java编译出来的字节码,在高版本的JVM运行,仍能享受高版本的JVM新的优化机制带来的性能提升,这是一种很好的向后兼容机制。所以有的时候,我们可以先把JVM升级到新版本来享受更高效的优化算法

    刚刚提到了JVM使用混合模式来从字节码转换成机器可以运行的机器码,混合模式包括解释器和JIT:

    解释器工作机制:

    image
    在编译时,主要是将java源代码文件编译为java统一的字节码,但是编译成的字节码并不能直接运行,而是通过JVM读取运行。JVM中的解释器就是将.class文件一行一行翻译之后再运行,翻译就是转换成当前机器可以运行的机器码,它不会一次性把整个文件都翻译过来,而是翻译一句,执行一句,再翻译,再执行,所以解释器的程序运行起来会比较慢,每次都要解释之后再执行。所以,有些时候,我们想是否可以把解释之后的内容缓存起来,这样不就可以直接运行了?但是,如果每段代码都要缓存起来,例如仅仅执行一次的代码也缓存起来,这样太浪费内存了。所以,引入一个新的运行时编译器,JIT来解决这些问题,加速热点代码的执行。

    JIT运行时编译器工作机制:

    image

    JIT针对热点代码,进行编译与深度优化,优化后的机器码会被缓存起来,存入CodeCache中。对于非热点代码,例如只运行一次的代码(类构造器等等),直接解释执行,更加快速。JIT不仅花更多时间去编译优化,而且还多耗费了很多内存,并且 CodeCache 发生变化会发生部分或者所有线程进入 Safepoint 导致 Stop the world。字节码转换为可执行的机器码,大小会大很多很多倍。这也是为啥,解释器每次都要翻译并且执行,JIT只针对热点代码进行编译优化的原因。JIT编译器执行的一些常见优化操作包括数据分析,从堆栈操作到寄存器操作的转换,通过寄存器分配减少内存访问,消除常见子表达式等。JIT编译器进行的优化程度越高,在执行阶段花费的时间越多。因此,JIT编译器无法承担所有静态编译器所做的优化,这不仅是因为增加了执行时间的开销,而且还因为它只对程序进行了限制。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

    对于上面的例子,刚开始的时候,test1方法是解释器执行的,由于多了一步转换,所以比较慢。后面随着代码的运行和JIT优化,test1方法的机器码被优化并且存入代码缓存,下次执行直接从代码缓存读取执行。

    JIT的基本工作原理

    首先,需要判断一个方法是否是热点方法:在HotSpot虚拟机中使用的基于计数器的热点探测方法,他为每个方法都准备了两个计数器:方法调用计数器和loop-back-edge计数器。

    • 方法调用计数器:顾名思义,这个计数器用于统计方法被调用的次数。在一个方法被调用时,根据前面所述,会先看看是否存在与codecache中,也就是jit编译的版本,如果不存在,则将计数加一,判断是否大于阈值,如果大于,则那么将会向即时编译器提交一个该方法的代码编译请求。如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
    • loop-back-edge计数器:专用来统计loop次数的,就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为loop-back-edge。这个计数器机制与上面的方法调用计数器一致。

    有了这些计数器,JIT可以根据这些计数器里面的统计信息,进行优化。当然,不止有这些计数器,还有一些其他更复杂的采集点。JIT编译器在JDK 8之前,例如JDK 7是区分client模式(C1编译器)还是server模式(C2编译器)的,从JDK 8开始,不做这个区分了,都是C1+C2编译器合作,分层优化。C1是一个简单快速的编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段。C2则是专门面向服务器端的,并为服务端的性能配置特别调整过的编译器,是一个充分优化过的高级编译器。从Java 8开始,JIT编译优化是分层优化,分为5层,每层都会有C1或者C2参与。

    • 第0层(Tier-0),只有解释器参与,解释执行
    • 第1层(Tier-1),执行不带任何采集的的C1优化代码
    • 第2层(Tier-2),执行仅带方法调用计数器和loop-back-edge计数器profiling的C1优化代码
    • 第3层(Tier-3),执行带所有采集的的C1优化代码
    • 第4层(Tier-4),执行C2优化代码

    相关文章

      网友评论

          本文标题:90% 的 Java 程序员都说不上来的为何 Java 代码越执

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