JIT 编译器概述
即时编译器 (JIT Compiler) 是由 JVM 内部使用的一种编译器,用于将字节码中的热点代码翻译成机器可理解的代码。JIT 编译器的主要目的是对性能进行重度优化。
Java 编译的目标是 JVM。Java 编译器 javac
将 Java 代码编译成字节码。接着,JVM 解释这些字节码并在底层硬件上执行。对于一些需要反复执行的代码,JVM 会识别这些代码为热点,并使用 JIT 编译器进一步将其编译到本地机器代码级别,并在需要时重用已编译的代码。
首先,让我们了解编译语言与解释语言的区别以及 Java 如何结合两者的优势。
编译语言 vs 解释语言
像 C、C++ 和 FORTRAN 这样的语言是编译语言。它们的代码以二进制代码的形式交付,针对底层机器。这意味着高级代码由静态编译器一次性编译成特定架构的二进制代码。生成的二进制文件在其他架构上无法运行。
另一方面,像 Python 和 Perl 这样的解释语言可以在任何有合适解释器的机器上运行。它们逐行遍历高级代码,并将其转换为二进制代码。
通常情况下,解释代码的速度比编译代码慢。例如,在循环的情况下,解释器会对每次迭代的相应代码进行转换,而编译代码只会转换一次。此外,由于解释器一次只能看到一行代码,因此它无法执行任何显著的代码优化,如更改语句的执行顺序。
示例
考虑加法运算:由于访问内存可能消耗多个 CPU 周期,一个好的编译器会在数据可用时发出指令来从内存中获取数据并执行加法,而不是等待;与此同时,执行其他指令。而在解释过程中,由于解释器在任何时候都不知道整个代码,因此不会发生这样的优化。
然而,解释语言可以在任何具有有效解释器的机器上运行。
Java 是编译还是解释?
Java 试图找到一个中间点。由于 JVM 处于 javac
编译器和底层硬件之间,javac
或其他编译器将 Java 代码编译为 JVM 能够理解的字节码。随后,JVM 在代码执行时使用即时编译 (JIT Compilation) 将字节码编译为二进制代码。
热点代码 (HotSpots)
在一个典型的程序中,只有很小一部分代码会被频繁执行,而这部分代码往往对整个应用的性能影响显著。这样的代码段被称为热点代码 (HotSpots)。
如果某段代码只执行一次,则编译它是浪费资源,直接解释字节码会更快。但如果这段代码是一个热点且被多次执行,JVM 则会选择编译它。例如,如果一个方法被多次调用,那么编译代码所需的额外周期将会被生成的更快的二进制代码所抵消。
随着 JVM 更多地运行特定的方法或循环,它收集的信息越多,就越能做出各种优化,从而生成更快的二进制代码。
JIT 编译器的工作原理
JIT 编译器通过将某些热点代码编译为机器或本地代码来提高 Java 程序的执行时间。
JVM 扫描整个代码,识别出需要由 JIT 优化的热点代码,然后在运行时调用 JIT 编译器,从而提高程序效率并使其运行得更快。
由于 JIT 编译是一个处理器和内存密集型活动,因此 JIT 编译需要进行相应的规划。
编译级别
JVM 支持五种编译级别:
如果您想禁用所有 JIT 编译器并仅使用解释器,请使用 -Xint
。
客户端 JIT vs 服务器 JIT
使用 -client
和 -server
分别激活相应的模式。客户端编译器 (C1) 比服务器编译器 (C2) 更早开始编译代码。因此,在 C2 开始编译之前,C1 已经编译了一些代码段。 但在等待期间,C2 对代码进行了分析,以获得比 C1 更多的信息。因此,它等待的时间可以通过生成更快的二进制代码来补偿。
从用户的角度来看,权衡在于程序的启动时间和程序运行所需的时间。如果启动时间至关重要,则应使用 C1。如果预计应用程序将长时间运行(如部署在服务器上的应用程序),则最好使用 C2,因为它生成的代码速度要快得多,大大抵消了任何额外的启动时间。
对于 IDE(如 NetBeans、Eclipse)和其他 GUI 应用程序,启动时间至关重要。NetBeans 启动可能需要一分钟或更长时间。当启动像 NetBeans 这样的程序时,会编译数百个类。在这种情况下,C1 编译器是最好的选择。
注意,C1 有两个版本——32b 和 64b。C2 只有 64b 版本。
JIT 编译器优化示例
以下示例展示了 JIT 编译器的优化:
对象情况下的 JIT 优化
考虑以下代码:
for(int i = 0 ; i <= 100; i++) {
System.out.println(obj1.equals(obj2));
}
如果这段代码被解释,解释器会在每次迭代时推断 obj1
的类。这是因为 Java 中的每个类都有一个继承自 Object 类的 .equals()
方法,并且可以被覆盖。即使 obj1
在每次迭代时都是一个字符串,这种推断仍然会发生。
另一方面,实际上 JVM 会注意到每次迭代时 obj1
都是 String 类型,因此会直接生成对应 String 类的 .equals()
方法的代码。因此,无需查找,编译后的代码将运行得更快。
这种行为只有在 JVM 知道代码的行为时才可能发生。因此,它在编译某些代码段前会稍作等待。
原始值情况下的 JIT 优化
以下是另一个例子:
int sum = 7;
for(int i = 0 ; i <= 100; i++) {
sum += i;
}
对于每个循环,解释器都会从内存中获取 sum
的值,加上 i
,然后将其存回内存。内存访问是一个昂贵的操作,通常需要多个 CPU 周期。由于这段代码多次执行,它是一个热点。JIT 将编译这段代码并进行如下优化:
sum
的本地副本将存储在一个特定于特定线程的寄存器中。所有的操作都将对寄存器中的值进行,当循环完成时,将值写回到内存中。
如果有其他线程也在访问该变量怎么办?由于其他线程正在更新变量的本地副本,它们可能会看到旧值。此时需要线程同步。一个基本的同步原语是将 sum
声明为 volatile
。现在,在访问变量之前,线程会刷新其本地寄存器并从内存中获取值;访问后,立即将值写入内存。
JIT 编译器所做的优化
以下是一些 JIT 编译器通常进行的一般优化: