解释执行?编译执行?即时编译?轻松让你分清前期编译与后期编译

对编译的理解

“诶,那个谁谁谁,昨天我把那个bug改好了。你编译一下看看还有那个问题没。”说罢,软件部主任不慌不忙品了一口手中的茶。”主任你太牛逼了!......”。伴随着下属崇拜眼神,主任不禁想起了曾经的自己最开始写代码也是一个连”编译”也不清楚的小白而已。

提起编译。我们最最开始的理解便是:程序先把写的代码读一遍,判断一下有没有基本的语法错误。当编译通过之后,就进入了运行模式。那么随着学习的深入与加深理解,对编译的理解已经不能单单局限于此了。

image.png

在Java技术下谈”编译器”,在没有上下文语境的情况下,其实是一个很含糊的表述。他可能指的是前端编译器(通过javac将.java文件转为.class文件的过程)。我们平常开发指的”把软件编译一下”。其实指的是前端编译器。而后端编译器往往被我们忽略,因为这个事情完全交给JVM内部了,我们一般开发关心的很少。

所谓编译的意义,其实就是程序界的翻译。前端编译,意味着我们要把我们认识的.java文件翻译成JVM认识的.class文件。但是计算机可不认你的.class文件。因此,JVM就需要把它认识的.class文件再翻译成为相关的二进制机器码,这就是所谓的后端编译。

这下总算是对编译有一个更具体的概念了。

后端编译与优化

如果把字节码看作是程序的中间表达形式,那么编译器无论何时,何种状态下把class文件转换为计算机是别的二进制机器码,都可以被视为后端编译过程。

后端编译主要包括即时编译(Just In Time)和提前编译(Ahead Of Time),下面我们分别来介绍这两种形式。

解释执行

解释执行得放在编译执行之前将。因为不出意外,大部分情况下都是解释执行处理程序。

通过解释器,在代码执行时逐条翻译成机器码,不做保存。解释器的方式是非常低效的,需要先把字节码翻译成机器码,才能往下执行。(一边解释,一边执行)。然而除了程序中有提前编译的程序外,代码一开始都是从解释执行开始的。

编译执行

JVM在某些条件下,自己都会觉得解释执行墨迹的没完没了。那么此时JVM工作者就想,反正这个代码经常都要翻译成机器码,那我干脆就把这些代码提前编译好成机器码,然后运行到这个代码时直接去读机器码不就好了?

于是产生了编译执行。虚拟机在某些场景或通过某些配置,达到让部分代码编译为机器码。在编译成机器码后每次运行至此都直接使用机器码执行以提高运行效率。

即时编译(JIT)

假设程序现在没有任何提前编译,那么程序一开始就都只会进行解释执行。

但我们幻想一下,假设我们是一个英语老师。我们教一个学生英文单词的念法。一遍,两遍.....讲到第100,1000遍的时候这学生还是不会念,你是不是有点烦了。没错,虚拟机也烦了(准确说是虚拟机的开发者们发现了这个问题)。因此,提出了热点代码的概念。

假如一个程序总是重复执行一个高频率的程序块,那么此时就会被判定为热点代码。那么重复执行率这么高的代码,干脆不每次都翻译了,就直接一次翻译好每次去读已经翻译好的内容不就好了。

将热点代码编译成与本地平台相关的机器码,并保存到内存。编译执行只需要在线下编译一次,而解释执行则每次都需要编译,所以运行效率上编译执行更高。

HopSpot虚拟机采用混合模式综合了解释执行与即时编译的优点。它会先执行解释执行字节码,而后将其中反复执行的热点代码,以此方法为代为单位进行即时编译。

即时编译建立在程序符合二八定律的假设上,也就是百分之20的代码占据了百分之80的计算机资源。

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

理论上将,即时编译后的Java程序的执行效率,是可能超过C++程序的。这是因为与静态编译(一开始全部翻译成机器码)相比,即时编译拥有程序运行时的信息(程序在运行时能根据运行过程中再次进行优化编译),并且能够根据这个信息作出相应的优化,峰值性能更好。
Oracle的HotSpot VM便附带两个用C++实现的JIT compiler:C1及C2。

C1及C2编译器简介

经过前面对即时编译,提前编译的讲解。现在应该已经有一个大概的认知。编译器的目标虽然是由程序代码翻译为本地机器码而工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。

C1(客户端编译器)

客户端编译器把.class文件翻译的比较保守,但是速度很快。

而为什么会伴随这种特性呢?是因为C1编译器没有应用激进的优化技术,因为这些优化往往伴随着耗时较长的代码分析。

C2(服务器编译器)

服务端代码编译器采用高复杂度的优化算法。往往采取应用激进的优化技术,因此执行速度较慢,但优化出来的代码执行效率更高。

C2可以想象成为我们的人工智能。根据程序在运行时期的行为习惯,做成预测性质的优化。这种优化可以编译出更加高效的代码。

在Java 7前,用户需根据自己的应用场景选择合适的JIT compiler。举例来说,针对偏好高启动性能的GUI用户端程序则使用C1,针对偏好高峰值性能的服务器端程序则使用C2。

解释器与编译器混合使用的意义

解释器与编译器直接各有优势:

1. 当程序需要迅速启动时,解释器可以首先发挥作用,省去编译的时间,立即运行。

(虽然二者都是翻译成机器码,但是由于编译器要对代码进行优化以及保存等操作必然更加耗时)

2. 当程序运行了一段时间后,编译器逐渐发挥作用。把越来越多的热点代码

翻译成机器码。这样可以减少许多解释器的时间消耗,提供效率。(也有点空间换时间内味儿)

3. 当程序运行环境中内存资源限制较大,可以使用解释执行节约内存。反之可以用编

译器提升效率。

4. 同时,解释器还可以作为编译器激进优化后的后备”逃生门”(如果情况运行,HotSpot

虚拟机中也会采用不进行激进优化的客户端编译器充当”逃生门”角色)。为什么会需要逃生门呢?因为在运行过程中,编译器可能识别一些场景,根据经验进行提速优化。然而这些场景并不不能保证一定正确。此时激进优化的假设就会不成立。例如如果加载了新类,类的继承结构就会变化,出现”罕见陷阱”。此时可以通过逆优化,让编译执行的代码重新进行解释执行。这也就告诉我们,为什么HotSpot虚拟机是解释器编译器并存的虚拟机。

分层编译解释器与编译器的执行细节

尽管不是所有的Java虚拟机都采用解释器与编译器并存的运行架构。但是目前主流的商用Java虚拟机。譬如HotSpot,OpenJ9等,内部都同时包含解释器与编译器。

由于即时编译器编译本代码需要占用程序运行时间。通常要编译出优化程度越高的代码,所花费的时间就会越长。而且要想编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息。解释器分出经历帮编译器,那么它的效率也受到了影响。为了在程序启动响应速度与运行效率达到一个最佳平衡,HotSpot在编译子系统时加入了分层编译的功能。其中包括:

level 0:interpreter解释执行
level 1:C1编译,无profiling(性能监控功能)
level 2:C1编译,仅方法及循环back-edge执行次数的profiling((性能监控功能))
level 3:C1编译,除level 2中的profiling外还包括branch(针对分支跳转字节码)及receiver type(针对成员方法调用或类检测,如checkcast,instnaceof,aastore字节码)的profiling
level 4:C2编译

这里解释一下,profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。

1、通常情况下,一个方法先被解释执行(level 0),然后被C1编译(level 3),再然后被得到profile数据的C2编译(level 4)
2、如果编译对象非常简单,虚拟机认为通过C1编译或通过C2编译并无区别,便会直接由C1编译且不插入profiling代码(level 1)
3、在C1忙碌的情况下,interpreter会触发profiling,而后方法会直接被C2编译;
4、在C2忙碌的情况下,方法则会先由C1编译并保持较少的profiling(level 2),以获取较高的执行效率(与3级相比高30%)。

通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。

其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为 profiling 越多,其额外的性能开销越大。

实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译著采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

热点代码判断

在运行过程中,会被即时编译器编译的目标是”热点代码”。这里的”热点代码”包括:

1. 被多次调用的方法

2. 被多次执行的循环体

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为被称为“热点探测”(Hot Spot Code Detection),目前主流的有两种方式:

(1)基于采样的热点探测(Sample Based Hot Spot Code Detection):周期性地检查各个线程的调用栈顶,如果发现某个(或者某些)方法经常出现在栈顶(栈顶的方法是正在被执行的方法),那这个方法就是“热点方法”。好处是高效,而且很容易获取方法调用的关系(将调用栈展开即可)。缺点是不够精确,容易受到线程阻塞和外部因素影响。

(2)基于计数器的热点探测(Counter Based Hot Spot Code Detection):为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阙值就认为是“热点方法”。缺点是比较麻烦,需要为每个方法维护计数器,而且不能直接获得调用关系。优点是更加严谨准确。

方法调用计数器

用于统计方法被调用的次数,当达到某个次数时,可以判定为热点代码。

当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码执行。如果不存在已被编译过版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阙值。一旦超过阙值,将会向即时编译器提出一个该方法的代码编译请求。流程如下图所示:

image.png

当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器,那该方法的调用计数器就会被减半,这个过程被称为方法调用计数器的衰减(Counter Decay),而这段时间就称为该方法统计的半衰周期(Counter Half Life Time)。可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器

用于统计循环体代码的次数,当达到某个次数时,可以判定为热点代码。(for,while等)。

当一个解释器遇到一条回边指令时,会先检查该执行代码片段是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码执行。如果不存在已被编译过版本,则将回边计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阙值。如果超过阙值,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。流程如下图所示:

image.png

与方法计数器不同,回边计数器没有热度衰半的过程

 

猜你喜欢

转载自blog.csdn.net/weixin_47184173/article/details/109706816