找回密码
 会员注册
查看: 16|回复: 0

Java即时编译器原理解析及实践

[复制链接]

1

主题

0

回帖

4

积分

新手上路

积分
4
发表于 2024-10-6 11:40:00 | 显示全部楼层 |阅读模式
总第417篇2020年 第41篇跟其他常见的编程语言不同,Java将编译过程分成了两个部分,这就对性能带来了一定的影响。而即时(Just In Time, JIT)编译器能够提高Java程序的运行速度。本文会先解析一下即时编译器的原理,然后再分享一些在美团实践的经验,希望能对大家有所帮助或者启发。一、导读常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。而Java为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由javac编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java通常不如C++这类编译型语言。为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。即时编译器极大地提高了Java程序的运行速度,而且跟静态编译相比,即时编译器可以选择性地编译热点代码,省去了很多编译时间,也节省很多的空间。目前,即时编译器已经非常成熟了,在性能层面甚至可以和编译型语言相比。不过在这个领域,大家依然在不断探索如何结合不同的编译方式,使用更加智能的手段来提升程序的运行速度。二、Java的执行过程Java的执行过程整体可以分为两个部分,第一步由javac将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析,编译原理中这部分的编译称为前端编译。接下来无需编译直接逐条将字节码解释执行,在解释执行的过程中,虚拟机同时对程序运行的信息进行收集,在这些信息的基础上,编译器会逐渐发挥作用,它会进行后端编译——把字节码编译成机器码,但不是所有的代码都会被编译,只有被JVM认定为的热点代码,才可能被编译。怎么样才会被认为是热点代码呢?JVM中会设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被编译,存入codeCache中。当下次执行时,再遇到这段代码,就会从codeCache中读取机器码,直接执行,以此来提升程序运行的性能。整体的执行过程大致如下图所示:1. JVM中的编译器JVM中集成了两种编译器,Client Compiler和Server Compiler,它们的作用也不同。Client Compiler注重启动速度和局部的优化,Server Compiler则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。两种编译器有着不同的应用场景,在虚拟机中同时发挥作用。Client CompilerHotSpot VM带有一个Client Compiler ?C1编译器。这种编译器启动速度快,但是性能比较Server Compiler来说会差一些。C1会做三件事:局部简单可靠的优化,比如字节码上进行的一些基础优化,方法内联、常量传播等,放弃许多耗时较长的全局优化。将字节码构造成高级中间表示(High-level Intermediate Representation,以下称为HIR),HIR与平台无关,通常采用图结构,更适合JVM对程序进行优化。最后将HIR转换成低级中间表示(Low-level Intermediate Representation,以下称为LIR),在LIR的基础上会进行寄存器分配、窥孔优化(局部的优化方式,编译器在一个基本块或者多个基本块中,针对已经生成的代码,结合CPU自己指令的特点,通过一些认为可能带来性能提升的转换规则或者通过整体的分析,进行指令转换,来提升代码性能)等操作,最终生成机器码。Server CompilerServer Compiler主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化。这种编译器的启动时间长,适用于长时间运行的后台程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虚拟机中使用的Server Compiler有两种:C2和Graal。C2 Compiler在Hotspot VM中,默认的Server Compiler是C2编译器。C2编译器在进行编译优化时,会使用一种控制流与数据流结合的图数据结构,称为Ideal Graph。Ideal Graph表示当前程序的数据流向和指令间的依赖关系,依靠这种图结构,某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂。Ideal Graph的构建是在解析字节码的时候,根据字节码中的指令向一个空的Graph中添加节点,Graph中的节点通常对应一个指令块,每个指令块包含多条相关联的指令,JVM会利用一些优化技术对这些指令进行优化,比如Global Value Numbering、常量折叠等,解析结束后,还会进行一些死代码剔除的操作。生成Ideal Graph后,会在这个基础上结合收集的程序运行信息来进行一些全局的优化,这个阶段如果JVM判断此时没有全局优化的必要,就会跳过这部分优化。无论是否进行全局优化,Ideal Graph都会被转化为一种更接近机器层面的MachNode Graph,最后编译的机器码就是从MachNode Graph中得的,生成机器码前还会有一些包括寄存器分配、窥孔优化等操作。关于Ideal Graph和各种全局的优化手段会在后面的章节详细介绍。Server Compiler编译优化的过程如下图所示:Graal Compiler从JDK 9开始,Hotspot VM中集成了一种新的Server Compiler,Graal编译器。相比C2编译器,Graal有这样几种关键特性:前文有提到,JVM会在解释执行的时候收集程序运行的各种信息,然后编译器会根据这些信息进行一些基于预测的激进优化,比如分支预测,根据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal比C2更加青睐这种优化,所以Graal的峰值性能通常要比C2更好。使用Java编写,对于Java语言,尤其是新特性,比如Lambda、Stream等更加友好。更深层次的优化,比如虚函数的内联、部分逃逸分析等。Graal编译器可以通过Java虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时,它将替换掉HotSpot中的C2编译器,并响应原本由C2负责的编译请求。2. 分层编译在Java 7以前,需要研发人员根据服务的性质去选择编译器。对于需要快速启动的,或者一些不会长期运行的服务,可以采用编译效率较高的C1,对应参数-client。长期运行的服务,或者对峰值性能有要求的后台服务,可以采用峰值性能更好的C2,对应参数-server。Java 7开始引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。分层编译将JVM的执行状态分为了五个层次。五个层级分别是:解释执行。执行不带profiling的C1代码。执行仅带方法调用次数以及循环回边执行次数profiling的C1代码。执行带所有profiling的C1代码。执行C2代码。profiling就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。通常情况下,C2代码的执行效率要比C1代码的高出30%以上。C1层执行的代码,按执行效率排序从高至低则是1层>2层>3层。这5个层次中,1层和4层都是终止状态,当一个方法到达终止状态后,只要编译后的代码并没有失效,那么JVM就不会再次发出该方法的编译请求的。服务实际运行时,JVM会根据服务运行情况,从解释执行开始,选择不同的编译路径,直到到达终止状态。下图中就列举了几种常见的编译路径:图中第①条路径,代表编译的一般情况,热点方法从解释执行到被3层的C1编译,最后被4层的C2编译。如果方法比较小(比如Java服务中常见的getter/setter方法),3层的profiling没有收集到有价值的数据,JVM就会断定该方法对于C1代码和C2代码的执行效率相同,就会执行图中第②条路径。在这种情况下,JVM会在3层编译之后,放弃进入C2编译,直接选择用1层的C1编译运行。在C1忙碌的情况下,执行图中第③条路径,在解释执行过程中对程序进行profiling ,根据信息直接由第4层的C2编译。前文提到C1中的执行效率是1层>2层>3层,第3层一般要比第2层慢35%以上,所以在C2忙碌的情况下,执行图中第④条路径。这时方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层的执行时间。如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第⑤条执行路径代表的就是反优化。总的来说,C1的编译速度更快,C2的编译质量更高,分层编译的不同编译路径,也就是JVM根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。从JDK 8开始,JVM默认开启分层编译。3. 即时编译的触发Java虚拟机根据方法的调用次数以及循环回边的执行次数来触发即时编译。循环回边是一个控制流图中的概念,程序中可以简单理解为往回跳转的指令,比如下面这段代码:循环回边public?void?nlp(Object?obj)?{??int?sum?=?0;??for?(int?i?=?0;?i??TierXInvocationThreshold?*?s?|(i?>?TierXMinInvocationThreshold?*?s??&?i?+?b?>?TierXCompileThreshold?*?s)?i为调用次数,b是循环回边次数上述满足其中一个条件就会触发即时编译,并且JVM会根据当前的编译方法数以及编译线程数动态调整系数s。三、编译优化即时编译器会对正在运行的服务进行一系列的优化,包括字节码解析过程中的分析,根据编译过程中代码的一些中间形式来做局部优化,还会根据程序依赖图进行全局优化,最后才会生成机器码。1. 中间表达形式(Intermediate Representation)在编译原理中,通常把编译器分为前端和后端,前端编译经过词法分析、语法分析、语义分析生成中间表达形式(Intermediate Representation,以下称为IR),后端会对IR进行优化,生成目标代码。Java字节码就是一种IR,但是字节码的结构复杂,字节码这样代码形式的IR也不适合做全局的分析优化。现代编译器一般采用图结构的IR,静态单赋值(Static Single Assignment,SSA)IR是目前比较常用的一种。这种IR的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。举个例子:SSA IRPlain?Text{??a?=?1;??a?=?2;??b?=?a;}上述代码中我们可以轻易地发现a = 1的赋值是冗余的,但是编译器不能。传统的编译器需要借助数据流分析,从后至前依次确认哪些变量的值被覆盖掉。不过,如果借助了SSA IR,编译器则可以很容易识别冗余赋值。上面代码的SSA IR形式的伪代码可以表示为:SSA IRPlain?Text{??a_1?=?1;??a_2?=?2;??b_1?=?a_2;}由于SSA IR中每个变量只能赋值一次,所以代码中的a在SSA IR中会分成a_1、a_2两个变量来赋值,这样编译器就可以很容易通过扫描这些变量来发现a_1的赋值后并没有使用,赋值是冗余的。除此之外,SSA IR对其他优化方式也有很大的帮助,例如下面这个死代码删除(Dead Code Elimination)的例子:DeadCodeEliminationpublic?void?DeadCodeElimination{??int?a?=?2;??int?b?=?0??if(2?>?1){????a?=?1;??}?else{????b?=?2;??}??add(a,b)}可以得到SSA IR伪代码:DeadCodeEliminationa_1?=?2;b_1?=?0if?true:??a_2?=?1;else??b_2?=?2;add(a,b)编译器通过执行字节码可以发现 b_2 赋值后不会被使用,else分支不会被执行。经过死代码删除后就可以得到代码:DeadCodeEliminationpublic?void?DeadCodeElimination{??int?a?=?1;??int?b?=?0;??add(a,b)}我们可以将编译器的每一种优化看成一个图优化算法,它接收一个IR图,并输出经过转换后的IR图。编译器优化的过程就是一个个图节点的优化串联起来的。C1中的中间表达形式前文提及C1编译器内部使用高级中间表达形式HIR,低级中间表达形式LIR来进行各种优化,这两种IR都是SSA形式的。HIR是由很多基本块(Basic Block)组成的控制流图结构,每个块包含很多SSA形式的指令。基本块的结构如下图所示:其中,predecessors表示前驱基本块(由于前驱可能是多个,所以是BlockList结构,是多个BlockBegin组成的可扩容数组)。同样,successors表示多个后继基本块BlockEnd。除了这两部分就是主体块,里面包含程序执行的指令和一个next指针,指向下一个执行的主体块。从字节码到HIR的构造最终调用的是GraphBuilder,GraphBuilder会遍历字节码构造所有代码基本块储存为一个链表结构,但是这个时候的基本块只有BlockBegin,不包括具体的指令。第二步GraphBuilder会用一个ValueStack作为操作数栈和局部变量表,模拟执行字节码,构造出对应的HIR,填充之前空的基本块,这里给出简单字节码块构造HIR的过程示例,如下所示:字节码构造HIR????????字节码?????????????????????Local?Value?????????????operand?stack??????????????HIR??????5:?iload_1??????????????????[i1,i2]?????????????????[i1]??????6:?iload_2??????????????????[i1,i2]?????????????????[i1,i2]?????????????????????????????????????................................................???i3:?i1?*?i2??????7:?imul?????????????????????????????????????????8:?istore_3?????????????????[i1,i2,i3]??????????????[i3]可以看出,当执行iload_1时,操作数栈压入变量i1,执行iload_2时,操作数栈压入变量i2,执行相乘指令imul时弹出栈顶两个值,构造出HIR i3 : i1 * i2,生成的i3入栈。C1编译器优化大部分都是在HIR之上完成的。当优化完成之后它会将HIR转化为LIR,LIR和HIR类似,也是一种编译器内部用到的IR,HIR通过优化消除一些中间节点就可以生成LIR,形式上更加简化。Sea-of-Nodes IRC2编译器中的Ideal Graph采用的是一种名为Sea-of-Nodes中间表达形式,同样也是SSA形式的。它最大特点是去除了变量的概念,直接采用值来进行运算。为了方便理解,可以利用IR可视化工具Ideal Graph Visualizer(IGV),来展示具体的IR图。比如下面这段代码:examplepublic?static?int?foo(int?count)?{??int?sum?=?0;??for?(int?i?=?0;?i? 50ms ,下降10ms,下降幅度达16.7%。运行过程中的峰值性能会更高。可以看出对于该服务,Graal编译器带来了一定的性能提升。Graal编译器的问题Graal编译器的优化方式更加激进,因此在启动时会进行更多的编译,Graal编译器本身也需要被即时编译,所以服务刚启动时性能会比较差。考虑的解决办法:JDK 9开始提供工具jaotc,同时GraalVM的Native Image都是可以通过静态编译,极大地提升服务的启动速度的方式,但是GraalVM会使用自己的垃圾回收,这是一种很原始的基于复制算法的垃圾回收,相比G1、ZGC(新一代垃圾回收器ZGC的探索与实践)这些优秀的新型垃圾回收器,它的性能并不好。同时GraalVM对Java的一些特性支持也不够,比如基于配置的支持,比如反射就需要把所有需要反射的类配置一个JSON文件,在大量使用反射的服务,这样的配置会是很大的工作量。我们也在做这方面的调研。五、总结本文主要介绍了JIT即时编译的原理以及在美团一些实践的经验,还有最前沿的即时编译器的使用效果。作为一项解释型语言中提升性能的技术,JIT已经比较成熟了,在很多语言中都有使用。对于Java服务,JVM本身已经做了足够多,但是我们还应该不断深入了解JIT的优化原理和最新的编译技术,从而弥补JIT的劣势,提升Java服务的性能,不断追求卓越。六、参考文献[1]《深入理解Java虚拟机》[2]《Proceedings of the Java? Virtual Machine Research and Technology Symposium》Monterey, California, USA April 23–24, 2001[3]《Visualization of Program Dependence Graphs》 Thomas Würthinger[4]《深入拆解Java虚拟机》 郑宇迪[5] JIT的Profile神器JITWatch七、作者简介珩智,昊天,薛超,均来自美团AI平台/搜索与NLP部。----------? END? ----------招聘信息美团搜索与NLP部,长期招聘搜索、对话、NLP算法工程师,坐标北京/上海,感兴趣的同学可投递简历至:tech@meituan.com(邮件请注明:搜索与NLP部)。也许你还想看Java字节码增强探秘Java 动态调试技术原理及实践Java线程池实现原理及其在美团业务中的实践
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-11 12:52 , Processed in 0.437636 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表