Java 虚拟机 - JIT 编译器


在本章中,我们将了解 JIT 编译器,以及编译型语言和解释型语言之间的区别。

编译语言与解释语言

C、C++ 和 FORTRAN 等语言都是编译语言。他们的代码以针对底层机器的二进制代码形式交付。这意味着高级代码会被专门为底层架构编写的静态编译器立即编译为二进制代码。生成的二进制文件不会在任何其他体系结构上运行。

另一方面,像 Python 和 Perl 这样的解释语言可以在任何机器上运行,只要它们有一个有效的解释器。它逐行遍历高级代码,将其转换为二进制代码。

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

我们将研究下面这种优化的一个例子 -

将内存中存储的两个数字相加。由于访问内存可能会消耗多个 CPU 周期,因此优秀的编译器会发出指令从内存中获取数据,并仅在数据可用时才执行加法。它不会等待,同时执行其他指令。另一方面,在解释过程中不可能进行此类优化,因为解释器在任何给定时间都不知道整个代码。

但是,解释型语言可以在任何具有该语言的有效解释器的机器上运行。

Java是编译型还是解释型?

Java 试图找到一个中间立场。由于 JVM 位于 javac 编译器和底层硬件之间,因此 javac(或任何其他编译器)编译器以字节码形式编译 Java 代码,特定于平台的 JVM 可以理解字节码。然后,当代码执行时,JVM 使用 JIT(即时)编译将字节码编译为二进制。

热点

在典型的程序中,只有一小部分代码被频繁执行,而通常正是这些代码显着影响了整个应用程序的性能。这些代码段称为热点

如果某段代码只执行一次,那么编译它就会浪费精力,而解释字节码会更快。但如果该节是热门节并且被执行多次,JVM 就会转而编译它。例如,如果多次调用某个方法,则编译代码所需的额外周期将被生成的更快的二进制文件所抵消。

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

让我们考虑以下代码 -

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

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

另一方面,实际发生的情况是,JVM 会注意到,对于每次迭代,obj1 属于 String 类,因此,它将直接生成与 String 类的 .equals() 方法相对应的代码。因此,不需要查找,并且编译后的代码执行速度会更快。

只有当 JVM 知道代码的Behave方式时,这种Behave才有可能发生。因此,它会在编译代码的某些部分之前等待。

下面是另一个例子 -

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

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

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

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

以下是 JIT 编译器完成的一些常规优化 -

  • 方法内联
  • 消除死代码
  • 优化调用站点的启发式方法
  • 不断折叠