JIT编译器

Java代码的执行分类

我们都知道开发语言整体分为两类,一类是编译型语言,一类是解释型语言。那么你知道二者有何区别吗?编译器和解释器又有什么区别?
这是为了兼顾启动效率和运行效率两个方面。Java 程序最初是通过解释器进行解释运行的,当虚拟机返现某个方法或代码块的运行特别频繁时,就会把这段代码标记为热点代码,为了提供热点代码的运行效率,在运行时,虚拟机就会把这些代码编译成与本地平台相关的机器码。并进行各种层次的优化。

  • 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行。
  • 第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行。

HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。

编译器和解释器

  • Java 编译器(javac)的作用是将 java 源程序编译成中间代码字节码文件,是最基本的开发工具。
  • Java 解释器(java)(英语:Interpreter),又译为直译器,是一种电脑程序,能够把高级编程语言一行一行直接转译运行。解释器不会一次把整个程序转译出来,只像一位“中间人”,每次运行程序时都要先转成另一种语言再作运行,因此解释器的程序运行速度比较缓慢。 它每转译一行程序叙述就立刻运行,然后再转译下一行,再运行,如此不停地进行下去。
image.png
  1. 当程序需要首次启动和执行的时候,解释器可以首先发挥作用,一行一行直接转译运行,但效率低下。
  2. 当多次调用方法或循环体时 JIT 编译器可以发挥作用,把越来越多的代码编译成本地机器码,之后可以获得更高的效率(占内存),此时就有了智能化的编译器(JIT 编译器)

解释器与编译器的交互:

image.png

HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Complier 和 Server Complier,它会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用"-client"或"-server"参数去强制指定虚拟机运行在 Client 模式或 Server 模式

问题

有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置 JIT 编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

JRockit 虚拟机是砍掉了解释器,也就是只采及时编译器。那是因为 JRockit 只部署在服务器上,一般已经有时间让他进行指令编译的过程了,对于响应来说要求不高,等及时编译器的编译完成后,就会提供更好的性能。

首先明确:

当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。

编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。

所以:
尽管 JRockit VM 中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。
在此模式下,当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”

什么是 JIT 编译器

  • 即时(Just-In-Time)编译器是 Java 运行时环境的一个组件,它可提高运行时 Java 应用程序的性能。JVM 中没有什么比编译器更能影响性能,而选择编译器是运行 Java 应用程序时做出的首要决定之一。
  • 当编译器做的激进优化不成立,如载入了新类后类型继承结构出现变化。出现了罕见陷阱时能够进行逆优化退回到解释状态继续运行。
image.png

解释器与编译器搭配使用的方式:

HotSpot JVM 内置了两个编译器,各自是 Client Complier 和 Server Complier,虚拟机默认是 Client 模式。我们也能够通过。

  • -client:强制虚拟机运行 Client 模式
  • -server:强制虚拟机运行 Server 模式
  • 默认(java -version 混合模式)

而不管是 Client 模式还是 Server 模式,虚拟机都会运行在解释器和编译器配合使用的混合模式下。能够通过。

  • 解释模式(java -Xint -version)强制虚拟机运行于解释模式,仅使用解释器方式执行。
  • 编译模式(java -Xcomp -version)优先采用编译方式执行程序,但解释器要在编译无法进行的情况下介入执行过程。

java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

java -Xint -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, interpreted mode)

java -Xcomp -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, compiled mode)

Java 功能“一次编译,到处运行”的关键是 bytecode。字节码转换为应用程序的机器指令的方式对应用程序的速度有很大的影响。这些字节码可以被解释,编译为本地代码,或者直接在指令集架构中符合字节码规范的处理器上执行。

  • 解释字节码的是 Java 虚拟机(JVM)的标准实现,这会使程序的执行速度变慢。为了提高性能,JIT 编译器在运行时与 JVM 交互,并将适当的字节码序列编译为本地机器代码。
  • 使用 JIT 编译器时,硬件可以执行本机代码,而不是让 JVM 重复解释相同的字节码序列,并导致翻译过程相对冗长。这样可以提高执行速度,除非方法执行频率较低。
  • JIT 编译器编译字节码所花费的时间被添加到总体执行时间中,并且如果不频繁调用 JIT 编译的方法,则可能导致执行时间比用于执行字节码的解释器更长。
  • 当将字节码编译为本地代码时,JIT 编译器会执行某些优化。
  • 由于 JIT 编译器将一系列字节码转换为本机指令,因此它可以执行一些简单的优化。
  • JIT 编译器执行的一些常见优化操作包括数据分析,从堆栈操作到寄存器操作的转换,通过寄存器分配减少内存访问,消除常见子表达式等。
  • JIT 编译器进行的优化程度越高,在执行阶段花费的时间越多。

因此,JIT 编译器无法承担所有静态编译器所做的优化,这不仅是因为增加了执行时间的开销,而且还因为它只对程序进行了限制。

image.png
  • JIT 编译器默认情况下处于启用状态,并在调用 Java 方法时被激活。
  • JIT 编译器将该方法的字节码编译为本地机器代码,“即时”编译以运行。
  • 编译方法后,JVM 会直接调用该方法的已编译代码,而不是对其进行解释。

从理论上讲,如果编译不需要处理器时间和内存使用量,则编译每种方法都可以使 Java 程序的速度接近本机应用程序的速度。

JIT 编译确实需要处理器时间和内存使用率。JVM 首次启动时,将调用数千种方法。即使程序最终达到了非常好的峰值性能,编译所有这些方法也会严重影响启动时间。

不同应用程序的不同编译器

JIT 编译器有两种形式,并且选择使用哪个编译器通常是运行应用程序时唯一需要进行的编译器调整。实际上,即使在安装 Java 之前,也要考虑知道要选择哪个编译器,因为不同的 Java 二进制文件包含不同的编译器。

客户端编译器

著名的优化编译器是 C1,它是通过-clientJVM 启动选项启用的编译器。顾名思义,C1 是客户端编译器。它是为客户端应用程序设计的,这些客户端应用程序具有较少的可用资源,并且在许多情况下对应用程序启动时间敏感。C1 使用性能计数器进行代码性能分析,以实现简单,相对无干扰的优化

服务器端编译器

对于长时间运行的应用程序(例如服务器端企业 Java 应用程序),客户端编译器可能不够。可以使用类似 C2 的服务器端编译器。通常通过将 JVM 启动选项添加-server 到启动命令行来启用 C2 。由于大多数服务器端程序预计将运行很长时间,因此启用 C2 意味着您将能够比使用运行时间短的轻量级客户端应用程序收集更多的性能分析数据。因此,您将能够应用更高级的优化技术和算法。

分层编译

为什么要进行分层编译

这是由于编译器编译本机代码须要占用程序运行时间,要编译出优化程度更高的代码锁花费的时间可能更长,并且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息。这对解释运行的速度也有影响。为了在程序启动响应速度和运行效率之间寻找平衡点。因此採用分层编译的策略。

  • 分层编译结合了客户端和服务器端编译。分层编译利用了 JVM 中客户端和服务器编译器的优势
  • 客户端编译器在应用程序启动期间最活跃,并处理由较低的性能计数器阈值触发的优化
  • 客户端编译器还会插入性能计数器,并为更高级的优化准备指令集,服务器端编译器将在稍后阶段解决这些问题。

分层编译是一种非常节省资源的性能分析方法,因为编译器能够在影响较小的编译器活动期间收集数据,以后可以将其用于更高级的优化。与仅使用解释的代码配置文件计数器所获得的信息相比,这种方法还可以产生更多的信息。

分层策略例如以下所看到的:

Oracle JDK6u25之后引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。如果需要关闭分层编译,需要加上启动参数-XX:-TieredCompilation

分层编译将 Java 虚拟机的执行状态分为了五个层次。为了方便阐述,我用“C1 代码”来指代由 C1 生成的机器码,“C2 代码”来指代由 C2 生成的机器码。五个层级分别是:

  • 第0层(解释层)启动:这一层主要是提供了一些比较关键性方法的性能,快速进入C1层。

  • 第1层(C1编译器):通过上一层提供的一些关键方法的性能信息来优化这些代码。本层不包含性能优化的信息。

  • 第2层:基于C1编译器优化的结果来处理的,此时会有少数方法通过C1编译器的编译,在本层会为这些少数方法的调用次数和循环分支执行情况,收集它们的性能分析信息。

  • 第3层:得到C1编译器编译的所有方法以及对所有的性能优化信息

  • 第4层:只对C2编译器有效。

关于分层编译有关文章:https://book.douban.com/annotation/31392220/

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

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

在 5 个层次的执行状态中,1 层和 4 层为终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么 Java 虚拟机是不会再次发出该方法的编译请求的。

image.png

这里我列举了 4 个不同的编译路径(Igor 的演讲列举了更多的编译路径)。通常情况下,热点方法会被 3 层的 C1 编译,然后再被 4 层的 C2 编译。

如果方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据。

那么,Java 虚拟机断定该方法对于 C1 代码和 C2 代码的执行效率相同。在这种情况下,Java 虚拟机会在 3 层编译之后,直接选择用 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。

在 C1 忙碌的情况下,Java 虚拟机在解释执行过程中对程序进行 profiling,而后直接由 4 层的 C2 编译。在 C2 忙碌的情况下,方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。

Java 8 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的参数 -client 和 -server 都是无效的。当关闭分层编译的情况下(指定-XX:-TieredCompilation),Java 虚拟机将直接采用 C2。

如果你希望只是用 C1,那么你可以在打开分层编译的情况下使用参数 -XX:TieredStopAtLevel=1。在这种情况下,Java 虚拟机会在解释执行之后直接由 1 层的 C1 进行编译。

代码优化

  • 当选择一种方法进行编译时,JVM 会将其字节码提供给即时编译器(JIT)。JIT 必须先了解字节码的语义和语法,然后才能正确编译该方法。
  • 为了帮助 JIT 编译器分析该方法,首先将其字节码重新格式化为称为 trees,它比字节码更类似于机器代码。
  • 然后对方法的树进行分析和优化
  • 最后,将树转换为本地代码。
  • JIT 编译器可以使用多个编译线程来执行 JIT 编译任务,使用多个线程可以潜在地帮助 Java 应用程序更快地启动。

编译线程的默认数量由 JVM 标识,并且取决于系统配置。如果生成的线程数不是最佳的,则可以使用该 XcompilationThreads 选项覆盖 JVM 决策。

编译包括以下阶段:

内联

内联是将较小方法的树合并或“内联”到其调用者的树中的过程。这样可以加速频繁执行的方法调用。

局部优化

局部优化可以一次分析和改进一小部分代码。许多本地优化实现了经典静态编译器中使用的久经考验的技术。

控制流优化

控制流优化分析方法(或方法的特定部分)内部的控制流,并重新排列代码路径以提高其效率。

全局优化

全局优化可一次对整个方法起作用。它们更加“昂贵”,需要大量的编译时间,但可以大大提高性能。

本机代码生成

本机代码生成过程因平台架构而异。通常,在编译的此阶段,将方法的树转换为机器代码指令;根据架构特征执行一些小的优化。

编译对象

编译对象即为会被编译优化的热点代码。有下面两类

  • 被多次调用的方法
  • 被多次运行的循环体

触发条件

这就牵扯到触发条件这个概念,推断一段代码是否是热点代码。是否须要触发即时编译,这样的行为成为热点探测(Spot Dectection)。

热点探测有两种手段:

基于采样的热点探测(Sample Based Hot Spot Dectection)

虚拟机会周期性的检查各个线程的栈顶,假设发现某些方法常常性的出如今栈顶,那么这种方法就是热点方法。

基于计数器的热点探测(Counter Based Hot Spot Dectection)

虚拟机会为每一个方法或代码块建立计数器,统计方法的运行次数。假设运行次数超过一定的阈值就觉得他是热点方法。

HotSpot JVM 使用另外一种方法基于计数器的热点探測方法。它为每一个方法准备了两类计数器:

方法调用计数器

这个阈值在 Client 模式下是 1500 次。在 Server 模式下是 10000 此,这个阈值能够通过參数-XX:CompileThreshold来人为设定。

  • 方法调用次数统计的并非方法被调用的绝对次数,而是相对的运行频率,即一段时间内方法被调用的次数,当超过一定时间限度,假设方法的调用次数仍然不足以让它提交给即时编译器编译,那这种方法的调用计数器会被降低一半,这个过程被称为方法调用计数器的热度衰减(Counter Decay)。
  • 而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。相同也能够使用參数-XX:-UseCounterDecay来关闭热度衰减。

方法调用计数器触发即时编译的整个流程例如以下图所看到的:


image.png

回边计数器

什么是回边?

在字节码遇到控制流向后跳转的指令称为回边(Back Edge)。

  • 回边计数器是用来统计一个方法中循环体代码运行的次数,回边计数器的阈值能够通过參数-XX:OnStackReplacePercentage来调整。

虚虚拟机运行在 Client 模式下,回边计数器阂值计算公式为:

方法调用计数器闭值(CompileThreshold) xOSR比率(OnStackReplacePercentage) / 100

当中 OnSlackReplacePercentage 默认值为 933,假设都取默认值,那 Client 模式虚拟机的回边计数器的阂值为 13995。

虚拟机运行在 Server 模式下,回边计数器阂值的 itm 公式为:

方法调用计数器阂值(CompileThreshold) x (OSR比率(OnStackReplacePercentage) - 解释器监控比率(InterpreterProffePercentage) / 100

  • 当中 OnSlackReplacePercentage 默认值为 140。 InterpreterProffePercentage 默认值为 33.
  • 假设都取默认值。BF Server 模式虚拟机回边计数器的阈值为 10700。

回边计数器触发即时编译的流程例如以下图所看到的:

image.png

回边计数器与方法调用计数器不同的是,回边计数器没有热度衰减,因此这个计数器统计的就是循环运行的绝对次数。

编译流程

在默认设置下,不管是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完毕之前,都仍然依照解释方式继续进行,而编译动作则在后台的编译线程中继续进行。也能够使用-XX:-BackgroundCompilation 来禁止后台编译,则此时一旦遇到 JIT 编译,运行线程向虚拟机提交请求后会一直等待,直到编译完毕后再開始运行编译器输出的本地代码。

那么在后台编译过程中,编译器做了什么事呢?

Client Compiler 编译流程

  • 第一阶段:一个平台独立的前端将字节码构造成一种高级中间码表示(High Level Infermediate Representaion),HIR 使用静态单分配的形式来表示代码值,这能够使得一些的构造过程之中和之后进行的优化动作更 easy 实现,在此之前编译器会在字节码上完毕一部分基础优化,如方法内联、常量传播等。
  • 第二阶段:一个平台相关的后端从 HIR 中产生低级中间代码表示(Low Level Intermediate Representation),而在此之前会在 HIR 上完毕还有一些优化。如空值检查消除、范围检查消除等。以便让 HIR 达到更高效的代码表示形式。
  • 第三阶段:在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔优化(Peephole)优化,然后产生机器码。
image.png

三 HotSpot JVM 执行方式
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。

案例

注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态(稳定运行状态)可以承受的负载要大于冷机状态(刚刚启动状态)。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的 JVM 均是解释执行,还没有进行热点代码统计和 JIT 动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了 JIT 的存在作用和意义。

实战

1 代码

/**
* 测试JIT 编译器
*/
public class JITTest {
    public static void main(String[] args) {
        ArrayList list = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            list.add("this is a test");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

2 用 jconsole 查看 JIT 编译器

image.png

3 用 jvisualvm 查看 JIT 编译器

image.png

你可能感兴趣的:(JIT编译器)