4.1 JIT编译器:概览
先作一些介绍,如果你基本理解即时编译的话,可以放心大胆地跳过开头这段。
计算机——更具体说是 CPU——只能执行相对少而特定的指令,这被称为汇编码或者二进制码。因此,CPU 所执行的所有程序都必须翻译成这种指令。
像 C++ 和 Fortran 这样的语言被称为编译型语言,因为它们的程序都以二进制(编译后的)形式交付:先写程序,然后用编译器静态生成二进制文件。这个二进制文件中的汇编码是针对特定 CPU 的。只要是兼容的 CPU,都可以执行相同的二进制代码:比如,AMD 和 Intel CPU 共享一个基本的、常用的汇编语言指令集,新版本的 CPU 几乎总是能执行与老版本 CPU 相同的指令集。但反过来并不总是成立,新版本的 CPU 时常会引入一些指令,这些指令无法在老版本 CPU 上运行。
另外还有一些像 PHP 和 Perl 这样的语言,则是解释型的。只要机器上有合适的解释器(即称为 php 或 perl 的程序),相同的程序代码可以在任何 CPU 上运行。执行程序时,解释器会将相应代码转换成二进制代码。
每种语言类型都各有长处和不足。解释性型语言的程序可移植:相同的代码你丢到任何有适当解释器的机器上,它都能运行。但是,它运行起来可能就慢了。举个简单的例子,不妨考虑一下执行循环时会发生什么:当解释器执行循环体时,会重新翻译每一行代码。编译过后的代码就不必再重复做这样的转换了。
好的编译器在生成二进制代码时需要考虑许多因素。一个简单例子是二进制代码中的语句顺序:生成的汇编语言指令与执行时的顺序并不完全相同。执行两个寄存器值相加的语句可能只需要一个时钟周期,但(从主存储器)获取加法所需要的数据可能需要好几个周期。
因此,好的编译器生成的二进制代码需要包括装载数据、执行其他指令,然后——当数据准备好时——执行加法。而一次只能看一行的解释器就没有足够的信息生成这样的代码了。它会请求内存数据,然后一直等到数据准备好之后再执行加法。稍差点的编译器也这么干,而且顺便说一句,即便是最好的编译器偶尔也需要等待指令完成。
由于这些(或其他的)原因,解释型代码几乎总是明显比编译型代码要慢:编译器有足够的程序信息,这些信息可用来大量优化二进制代码,这些是简单解释器无法做到的。
解释型代码的优势在于可移植。很显然,SPARC CPU 的二进制编译器无法在 Intel CPU 上运行。而用 Intel Sandy Bridge 处理器最新 AVX 指令的二进制代码也无法在老的 Intel 处理器上运行。因此,商业软件通常会在较老的处理器上编译,从而无法利用最新的指令。这里面有很多技巧,例如,发布二进制代码时附带多个共享库,而这些共享库执行的代码都是对性能较敏感的,还要有多种版本与各种类型的 CPU 相匹配。
Java 试图走一条中间路线。Java 应用会被编译——但不是编译成特定 CPU 所专用的二进制代码,而是被编译成一种理想化的汇编语言。然后该汇编语言(称为 Java 字节码)可以用 java 运行(与 php 解释运行 PHP 脚本是相同的道理)。这使得 Java 成为一门平台独立的解释型语言。因为 java 程序运行的是理想化的二进制代码,所以它能在代码执行时将其编译成平台特定的二进制代码。由于这个编译是在程序执行时进行的,因此被称为“即时编译”(即 JIT)。
Java 虚拟机在执行时编译代码的这种方式是本章关注的重点。
热点编译
如第 1 章讨论的那样,本书中的 Java 实现是 Oracle 的 HotSpot JVM。HotSpot 的名字来自于它看待代码编译的方式。对于程序来说,通常只有一部分代码被经常执行,而应用的性能就取决于这些代码执行得有多快。这些关键代码段被称为应用的热点,代码执行得越多就被认为是越热。
因此 JVM 执行代码时,并不会立即编译代码。有两个基本理由。第一,如果代码只执行一次,那编译完全就是浪费精力。对于只执行一次的代码,解释执行 Java 字节码比先编译然后执行的速度快。
但如果代码是经常被调用的方法,或者是运行很多次迭代的循环,编译就值得了:编译的代码更快,多次执行累积节约的时间超过了编译所花费的时间。这种权衡是编译器先解释执行代码的原因之一——编译器可以找出哪个方法被调用得足够频繁,可以进行编译。
第二个理由是为了优化:JVM 执行特定方法或者循环的次数越多,它就会越了解这段代码。这使得 JVM 可以在编译代码时进行大量优化。
本章后面将讨论这些大量的优化(以及影响它们的方法),先考虑一个简单的例子,即 equals() 方法。这个方法存在于每个 Java 对象中(既然所有类都继承自 Object 类),并且经常被子类重写。当解释器遇到 b = obj1.equals(obj2) 语句时,为了知道该执行哪个 equals(),必须先查找 obj1 的类型(类)。这个动态查找的过程有点消耗时间。
寄存器和主内存
编译器最重要的优化包括何时使用主内存中的值,以及何时在寄存器中存贮值。考虑以下代码:
public class RegisterTest {private int sum;public void calculateSum(int n) {for (int i = 0; i < n; i++) {sum += i;}}}在某个时刻,实例变量
sum必须驻留在主内存中,但从主内存获取数据是昂贵的操作,需要花费多个时钟周期才能完成。如果每次循环迭代都从主内存获取(或保存)sum的值,性能就比较糟糕了。编译器不会这么做,它会将sum的初始值装入寄存器,用寄存器中的值执行循环,然后(在某个不确定的时刻)将最终的结果从寄存器写回到主内存。这种优化非常高效,但这意味着线程同步的语义(参见第 9 章)对应用行为非常重要。一个线程无法看到另一个线程所用寄存器中保存变量的值,同步机制使得从寄存器写回主内存时其他线程可以准确地读到这个值。
使用寄存器是编译器普遍采用的优化方法,当开启逃逸分析(escape analysis)时(参见本章末尾),寄存器的使用更为频繁。
比如说,随着时间的流逝,JVM 发现每次执行这条语句时,obj1 的类型都是 java.lang.String。于是 JVM 就可以生成直接调用 String.equals() 的编译代码。现在代码更快了,不仅是因为被编译,也是因为跳过了查找该调用哪个方法的步骤。
不过没那么简单。下次执行代码时,obj1 完全有可能是别的类型而不是 String,所以 JVM 必须生成编译代码处理这种可能。尽管如此,由于跳过了方法查找的步骤,这里的编译代码整体性能仍然要快(至少和 obj1 一直是 String 时同样快)。这种优化只有在代码运行过一段时间观察它如何做之后才能使用:这是为何 JIT 编译器等待代码编译的第二个原因。
快速小结
1. Java 的设计结合了脚本语言的平台独立性和编译型语言的本地性能。
2. Java 文件被编译成中间语言(Java 字节码),然后在运行时被 JVM 进一步编译成汇编语言。
3. 字节码编译成汇编语言的过程中有大量的优化,极大地改善了性能。
快速小结