900字范文,内容丰富有趣,生活中的好帮手!
900字范文 > 关于Java的JIT(即时编译器)知识整理

关于Java的JIT(即时编译器)知识整理

时间:2022-06-25 20:07:27

相关推荐

关于Java的JIT(即时编译器)知识整理

文章目录

前言一、JIT(即时编译器)1.1 解释执行和编译执行的区别1.2 Java代码编译过程1.3 JIT是什么二、HotSpot是什么2.1 说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快2.2 对于为何JIT要只对“热点代码”进行即使编译2.3 热点探测技术(Hot Spot Detection)2.3.1 基于采样的热点探测2.3.2 基于计数器的热点探测2.3.3 Hotspot选择的热点探测技术2.3.3.1 方法计数器2.3.3.2 回边计数器2.3.4 栈上替换(On Stack Replacement,OSR)2.4 热点衰减三、静态编译、动态编译和即时编译器到底是什么关系四、JIT编译器分类4.1 C1 编译器4.2 C2 编译器4.3 混合模式4.4 分层编译五、 JIT编译优化技术5.1 逃逸分析5.2 方法内联5.2.1 方法内联是什么5.2.2 方法内联优化的触发条件5.2.3 提高方法内联的方法5.2.4 总结5.3 退优化六、参考资料

前言

之前在整理终于搞懂了!字符串拼接的各种姿势以及底层的小知识这篇文章的时候提到了锁消除,然后了解到逃逸分析,之后又去学习了相关知识,了解到Java逃逸分析只出现在JIT(即时编译器)进行。这时候随着深入了解,出现了以下几个问题。

一、JIT(即时编译器)

了解Java逃逸分析只出现在JIT(Just In Time)进行,就不禁想JIT是什么。

1.1 解释执行和编译执行的区别

解释执行:解释执行是采用匹配执行解释器(解释器是个黑盒,通常也有编译器的组成部分)内部已经编译好的机器码,不是生成新的机器码(也有说法是逐条翻译成机器码?)。 - 由于逐条翻译,程序启动快,但是执行效率不高。

编译执行:运行期间,通过将字节码编译成对应的新的机器码(会将其缓存起来,通过参数-XX:ReservedCodeCacheSize),然后执行。 - 需要先编译出新的机器指令,所以程序启动较慢,但是执行效率高(因为执行的是机器指令)。

1.2 Java代码编译过程

在讲JIT之前,简单过一遍刚开始时,Java执行的过程

Java将源代码翻译为字节码 -> JVM逐条解释为机器码 -> 执行 -> 执行结果

后来因为解释执行必然比执行编译好的机器指令的执行效率低,所以引入JIT(即时编译器)。在执行时,JIT会把翻译过的机器码保存起来,已备下次使用,因此从理论上来说,采用JIT技术能够在执行效率上,接近曾经纯编译技术。

1.3 JIT是什么

JIT编译器(just in time 即时编译器),当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为(Hot Spot Code 热点代码,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化。

那么,虚拟机是怎么知道哪些是热点代码或者运行频繁呢,那就要说说HotSpot了。

二、HotSpot是什么

现在主流的商用虚拟机(HotSpot(Oracle)、J9 VM(IBM))中几乎都同时包含``解释器和编译器。

二者在其中各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;当程序运行后,随着时间的推移,编译器逐渐会返回作用,把越来越多的代码编译成本地代码后,可以获取更高的执行效率。解释执行可以节约内存,而编译执行可以提升效率。

2.1 说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快

并不是说“编译”这个动作比“解释”这个动作快。JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。

怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”

只被调用一次,例如类的构造器(class initializer,())没有循环

2.2 对于为何JIT要只对“热点代码”进行即使编译

大概分为两个情况考虑

时间

对只执行一次的代码做JIT编译再执行,可以说是得不偿失。对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。只有对频繁执行的代码,JIT编译才能保证有正面的收益。空间

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

2.3 热点探测技术(Hot Spot Detection)

程序中的代码只有是热点代码时,才会编译为本地代码,那么什么是热点代码呢?运行过程中会被即时编译器编译的“热点代码”有两类:

被多次调用的方法。被多次执行的循环体

热点探测技术主要有以下两种:

2.3.1 基于采样的热点探测

采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测

2.3.2 基于计数器的热点探测

采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。

在这个方法下,JVM会为每个方法或者代码块保留一个调用计数,以预定义的编译阈值(-XX:CompileThreshold,client模式下默认是1500,server模式默认是10000)开始,

这里听到两种情况,一种是计数器用减的,到0即时编译,一种是开始0,执行一次加一,超过阈值即时编译。(结合之后的热点衰减,我这里就先认为是加的了)

两种情况,编译器都是以整个方法作为编译对象。这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,OSR),即方法栈帧还在栈上,方法就被替换了

2.3.3 Hotspot选择的热点探测技术

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

编译阈值(-XX:CompileThreshold,client模式下默认是1500,server模式默认是10000)

2.3.3.1 方法计数器

当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

2.3.3.2 回边计数器

它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。

作用是在遇到回边(可以狭义的理解为循环,但并非所有循环都是回边)时,虚拟机会检测改代码是否存在已编译版本 - 是就执行,否加调用计数器+1 ,并检测是否超过阈值。

2.3.4 栈上替换(On Stack Replacement,OSR)

方法触发标准编译,循环体触发OSR栈上替换(在解释执行过程中直接切换到本地代码执行)。

在方法调用计数器种,如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

但在实际情况中并不总是有“下一次调用”的机会。假如有一个包含了千万次的循环方法,方法只执行一次,此时如果等待方法执行完成再进行编译,由于方法只调用一次,编译器将没有机会使用编译后的代码。

为了防止编译器做这种无用功,需要一种技术在解释执行循环期间将代码替换为编译后的代码,即循环的第N次使用解释执行,第N+1次使用编译后的代码,这样就能将“下一次调用”缩小到“下一次循环”。这种技术叫作栈上替换(On Stack Replacement,OSR)。OSR机制类似协程切换,它将解释器栈的数据打包到OSR buffer,然后在编译后的代码里面提取OSR buffer的数据放入编译后的执行栈,再继续执行。

2.4 热点衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。

半衰周期是化学中的概念,比如出土的文物通过查看 C60 来获得文物的年龄。

进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。

另外,可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

三、静态编译、动态编译和即时编译器到底是什么关系

之前一直分不清楚,这几个概念之间的关系

动态编译(dynamic compilation):在运行时进行编译,如JIT编译(just-in-time compilation),自适应动态编译(adaptive dynamic compilation)。

静态编译(static compilation):运行前已经编译好了,如AOT(ahead-of-time compilation)。

JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT编译是动态编译的一种特例。JIT编译一词后来被泛化,时常与动态编译等价;但要注意广义与狭义的JIT编译所指的区别。

自适应动态编译(adaptive dynamic compilation)也是一种动态编译,但它通常执行的时机比JIT编译迟,先让程序“以某种式”先运行起来,收集一些信息之后再做动态编译。这样的编译可以更加优化。

四、JIT编译器分类

在 HotSpot 虚拟机中,内置了两种 JIT,分别为C1 编译器和C2 编译器,这两个编译器的编译过程是不一样的。

4.1 C1 编译器

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

4.2 C2 编译器

C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler,例如,服务器上长期运行的 Java 应用对稳定运行就有一定的要求。

4.3 混合模式

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

HotSpot 虚拟机会根据自身版本与计算机的硬件性能自动选择运行模式,用户也可以使用 -client 和 -server 参数强制指定虚拟机运行在 Client 模式或者 Server 模式。这种配合使用的方式称为**“混合模式”(Mixed Mode)**,用户可以使用参数 -Xint 强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作。另外,使用 -Xcomp 强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机 -version 命令可以查看当前默认的运行模式。

C:\Users\Administrator>java -versionjava version "1.8.0_144"Java(TM) SE Runtime Environment (build 1.8.0_144-b01)Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)```shellC:\Users\Administrator>java -versionjava version "1.8.0_144"Java(TM) SE Runtime Environment (build 1.8.0_144-b01)Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)

4.4 分层编译

Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 -client或者-server 强制指定虚拟机的即时编译模式。

分层编译将 JVM 的执行状态分为了 5 个层次:

第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;第 1 层:可称为 C1编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;第 2 层:也称为 C1 编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;第 3 层:也称为 C1编译,执行所有带 Profiling 的 C1 编译;第 4 层:可称为 C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

对于 C1 的三种状态,按执行效率从高至低:第 1 层、第 2层、第 3层。

通常情况下,C2 的执行效率比 C1 高出30%以上。

在 Java8 中,默认开启分层编译,-client 和 -server 的设置已经是无效的了。如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。

你可以通过 java -version命令行可以直接查看到当前系统使用的编译模式:

C:\Users\Administrator>java -Xint -versionjava version "1.8.0_45"Java(TM) SE Runtime Environment (build 1.8.0_45-b14)Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, interpreted mode)C:\Users\Administrator>java -Xcomp -versionjava version "1.8.0_45"Java(TM) SE Runtime Environment (build 1.8.0_45-b14)Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, compiled mode)

五、 JIT编译优化技术

JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。主要有两种:方法内联、逃逸分析。

5.1 逃逸分析

其中,逃逸分析我们已经在终于搞懂了!字符串拼接的各种姿势以及底层的小知识中讲过了,这个次主要说下另一种,方法内联

5.2 方法内联

5.2.1 方法内联是什么

调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置

这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销(其实可以理解为一种资源开销小于上下文切换的类似动作)。

那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大

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

5.2.2 方法内联优化的触发条件

JVM 会自动识别热点方法,并对它们使用方法内联进行优化。我们可以通过-XX:CompileThreshold来设置热点方法的阈值。但要强调一点,热点方法不一定会被 JVM 做内联优化如果这个方法体太大了,JVM 将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:

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

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

之后我们就可以通过配置 JVM 参数来查看到方法被内联的情况:

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

5.2.3 提高方法内联的方法

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

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

5.2.4 总结

一个方法中的内容越少,当该方法经常被执行时,则容易进行方法内联,从而优化性能。

5.3 退优化

在前面分层编译说到,不同的层级通过收集不同数量的信息,可以进行不同层级的优化,虚拟机执行方法或循环的次数越多,它知道的代码的额外信息越多,代码优化效率越高。

假设虚拟机执行了很多次obj.equals()发现obj的类型都是String,那么虚拟机可以乐观地认为obj就是String类型,继而直接调用String.equals,省去了查询obj虚函数表的开销。但是如果后续变量obj接收到其他类型的对象,虚拟机也必须有处理这种少数情况的能力,这种处理少数情况的行为即退优化

除了上述这个例子外,编译器优化还会做很多乐观的假设,它广泛使用fast/slow惯例,乐观地认为大部分情况程序都是走快速路径fast,而只有极少数情况走慢速路径slow。当极少数情况发生时,虚拟机将执行退优化,使用慢速路径作为后备方案。退优化可以认为是栈上替换的逆操作。

六、参考资料

关于Java的JIT知识整理Java虚拟机解释器与JIT编译器JVM之逃逸分析无聊的记录:java与即时编译器(JIT)热点代码探测jvm调优之分层编译Java分层编译,深入解析java虚拟机:编译概述,即时编译技术深入理解java虚拟机(十三) Java 即时编译器JIT机制以及编译优化

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。