Java 简明教程

Java - Just-In-Time (JIT) Compiler

Just-in-time (JIT) compiler 是一个由 JVM 在内部用来将字节码中的热点翻译成机器可以理解的代码的编译器。JIT 编译器的主要目的是在性能内进行大量优化。

Java 编译的代码针对 JVM。Java 编译器 javac 将 Java 代码编译为字节码。现在,JVM 解释此字节码并在底层硬件上执行它。如果某些代码要反复执行,JVM 会将代码识别为热点,并使用 JIT compiler 将代码进一步编译到本机机器代码级别,并在需要时重复使用编译的代码。

我们先来了解一下编译语言和解释语言之间的差异,以及 Java 如何利用这两种方法。

Compiled Vs. Interpreted Languages

诸如 CC++FORTRAN 之类的语言是 compiled languages。它们的代码作为针对底层机器的二进制代码传递。这意味着高级代码立即由专门为底层架构编写的静态编译器编译为二进制代码。生成的可执行文件无法在任何其他架构上运行。

另一方面,诸如 PythonPerl 之类的 interpreted languages 可以运行在任何机器上,只要它们具有有效的解释器。它按行遍历高级代码,将其转换成二进制代码。

解释型代码通常比已编译的代码慢。例如,考虑一个循环。解释器会为循环的每次迭代转换对应的代码。另一方面,编译的代码只会转换一个。此外,由于解释器一次只能看到一行,因此它们无法执行任何重要的代码,例如更改编译器的语句执行顺序。

Example

下面我们来看一个这样的优化示例

Adding two numbers stored in memory: 由于访问内存会消耗多个 CPU 周期,因此好的编译器会发出指令从内存中获取数据,仅在数据可用时才执行加法操作。它不会等待,与此同时,执行其他指令。另一方面,由于解释器在任何给定时间都不知道整个代码,因此在解释过程中无法进行此类优化。

但随后,解释语言可以在任何装有该语言的有效解释器的机器上运行。

Is Java Compiled or Interpreted?

Java 尝试找到一个中间立场。由于 JVM 介于 javac 编译器和底层硬件之间,javac(或任何其他编译器)编译器将 Java 代码编译到字节码中,该代码由特定于平台的 JVM 理解。然后,JVM 在执行代码时使用 JIT (Just-in-time) compilation 将字节码编译为二进制文件。

HotSpots

在典型的程序中,仅有很小一部分代码经常执行,而且通常是这部分代码极大地影响整个应用程序的性能。这样的代码段称为 HotSpots

如果某个代码段仅执行一次,那么将其编译将是浪费时间,而直接解释字节代码会更快。但是,如果该代码段是一个热点部分并且执行多次,则 JVM 会对其进行编译。例如,如果一个方法多次调用,编译代码所需的额外周期将通过生成更快的二进制文件来抵消。

此外,JVM 运行特定方法或循环的次数越多,它收集的信息就越多,从而可以进行多种优化,以便生成更快的二进制文件。

Working of JIT Compiler

JIT compiler 有助于通过将某些热点代码编译成机器代码或本机代码来缩短 Java 程序的执行时间。

JVM 扫描完整代码并识别 JIT 要优化的热点或代码,然后在运行时调用 JIT Compiler,进而提高程序的效率并更快地运行程序。

由于 JIT compilation 是一个处理器且会占用大量内存,因此需要计划好即时编译 (JIT)。

Compilation Levels

JVM 支持五个编译级别−

  1. Interpreter

  2. C1 完全优化 (无概要分析)

  3. C1 使用调用和回边计数器 (轻量概要分析)

  4. C1 with full profiling

  5. C2 (使用前几步的概要分析数据)

如果你想禁用所有 JIT compilers 并只使用解释器,请使用 -Xint

Client Vs. Server JIT (Just-In-Time) Compiler

使用 -client-server 激活相应的模式。客户端编译器 (C1) 开始编译代码的时间比服务器编译器 (C2) 早。因此,到 C2 开始编译时,C1 就已经编译了一部分代码。但 C2 在等待时会分析代码,以便比 C1 更多地了解它。因此,它等待的时间如果被优化所抵消的话,就可以用来生成一个更快的二进制文件。

从用户的角度来看,这是程序启动时间和程序运行时间之间的权衡。如果启动时间是首要考虑因素,则应使用 C1。如果应用程序预计将长时间运行(此类应用程序通常部署在服务器上),则最好使用 C2,因为它生成的代码更快,可以在很大程度上抵消额外的启动时间。

对于诸如 IDE(NetBeans、Eclipse)和其他 GUI 程序之类的程序,启动时间至关重要。NetBeans 的启动可能需要一分钟或更长时间。当启动诸如 NetBeans 之类的程序时,会编译数百个类。在这种情况下,C1 编译器是最佳选择。

请注意,C1 有两个版本 - 32b 和 64b。C2 只有 64b 版本。

Examples of JIT Compiler Optimizations

以下示例展示了 JIT 编译器的优化:

Example of JIT optimization in case of objects

我们考虑以下代码:

for(int i = 0 ; i <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

如果解释此代码,解释器将推断 for each 迭代 obj1 的类。这是因为 Java 中的每个类都具有一个 .equals() 方法,该方法从 Object 类扩展而来,并且可以被覆盖。因此,即使 obj1 在每次迭代时都是字符串,仍会进行推断。

另一方面,实际上发生的是,JVM 会注意到对于每次迭代,obj1 属于 String 类,因此,它会直接生成对应于 .equals() method of the String class 的代码。因此,无需查找,编译代码将执行得更快。

仅当 JVM 知道代码如何执行时,才有可能发生此类行为。因此,在编译代码的某些部分之前,它会等待。

Example of JIT optimization in case of primitive values

下面是另一个示例:

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

对于每个循环,解释器都会从内存中获取“sum”的值,将其加到“i”,然后将其存储回内存。内存访问是昂贵的操作,通常需要多个 CPU周期。由于此代码运行多次,因此它是热点。JIT 将编译此代码并进行以下优化。

“sum”的本地副本将存储在一个特定于特定线程的寄存器中。对寄存器中的值执行所有操作,当循环完成时,该值将被写回内存。

如果其他线程也在访问变量怎么办?由于某些其他线程正在更新局部变量副本,因此他们将看到旧值。在这种情况下需要线程同步。一个非常基本的同步原语是将“sum”声明为 volatile。现在,在访问变量之前,线程将刷新其本地寄存器并从内存中获取值。访问它后,该值会立即被写入内存。

Optimizations Done by Just-In-Time (JIT) Compiler

以下是 JIT 编译器所做的一些通用优化:

  1. Method inlining

  2. Dead code elimination

  3. 优化调用站点的启发式方法

  4. Constant folding