前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >90% 的 Java 程序员都说不上来的为何 Java 代码越执行越快(1)- JIT编译优化

90% 的 Java 程序员都说不上来的为何 Java 代码越执行越快(1)- JIT编译优化

作者头像
干货满满张哈希
发布2021-04-12 13:48:17
4350
发布2021-04-12 13:48:17
举报
文章被收录于专栏:干货满满张哈希

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

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

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

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

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

代码语言:javascript
复制
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),可以看到:

代码语言:javascript
复制
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:

解释器工作机制:

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

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

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优化代码
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021/01/28 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是JIT
  • JIT的基本工作原理
相关产品与服务
云服务器
云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档