Java性能调优06——JIT编译器

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

即时(Just-In-Time,JIT)编译器是 Java 虚拟机的核心。对 JVM 性能影响最大的莫过于编译器,而选择编译器是运行 Java 程序时首先要做的选择之一——无论你是 Java 开发人员还是最终用户。幸运的是,在绝大多数情况下,只需要对编译器做一些基本的调优。

本章将深入介绍编译器。首先介绍编译器的工作原理,并讨论 JIT 编译器的优点和不足。然后转而介绍每个 Java 版本所包括的编译器:为特定场景选择合适的编译器,是你为了程序快速运行所采取的最重要的步骤,你需要明白这个道理。本章的最后涵盖了一些编译器调优的中高级技巧。这些调优技巧有助于使应用的性能百尺竿头更进一步。

概览

先作一些介绍,如果你基本理解即时编译的话,可以放心大胆地跳过开头这段。

计算机——更具体说是 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 虚拟机在执行时编译代码的这种方式是本章关注的重点。

热点编译

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 的初始值装入寄存器,用寄存器中的值执行循环,然后(在某个不确定的时刻)将最终的结果从寄存器写回到主内存。

这种优化非常高效,但这意味着线程同步的语义对应用行为非常重要。一个线程无法看到另一个线程所用寄存器中保存变量的值,同步机制使得从寄存器写回主内存时其他线程可以准确地读到这个值。

使用寄存器是编译器普遍采用的优化方法,当开启逃逸分析(escape analysis)时(参见本章末尾),寄存器的使用更为频繁。

比如说,随着时间的流逝,JVM 发现每次执行这条语句时,obj1 的类型都是 java.lang.String。于是 JVM 就可以生成直接调用 String.equals() 的编译代码。现在代码更快了,不仅是因为被编译,也是因为跳过了查找该调用哪个方法的步骤。

不过没那么简单。下次执行代码时,obj1 完全有可能是别的类型而不是 String,所以 JVM 必须生成编译代码处理这种可能。尽管如此,由于跳过了方法查找的步骤,这里的编译代码整体性能仍然要快(至少和 obj1 一直是 String 时同样快)。这种优化只有在代码运行过一段时间观察它如何做之后才能使用:这是为何 JIT 编译器等待代码编译的第二个原因。

概述小结

  1. Java 的设计结合了脚本语言的平台独立性和编译型语言的本地性能。

  2. Java 文件被编译成中间语言(Java 字节码),然后在运行时被 JVM 进一步编译成汇编语言。

  3. 字节码编译成汇编语言的过程中有大量的优化,极大地改善了性能。

调优入门:选择编译器类型(Client、Server或二者同用)

有两种“口味”的 JIT 编译器,选择哪种常常是应用运行时所需做的仅有的编译器调优。事实上,甚至在安装 Java 之前就必须考虑如何选择编译器,因为不同的 Java 安装包包含了不同的编译器。我们来逐步分析。首先找出何种环境下该用哪种编译器。

这两种编译器被称为 client 和 server。名字来自于命令行上用于选择编译器的参数(例如 -client 或 -server)。JVM 开发者(甚至一些工具)通常称这些编译器为 C1(编译器 1,client 编译器)和 C2(编译器 2,server 编译器)。它们的名字似乎意味着如何选择编译器受程序运行的硬件平台的影响,但这并不完全正确:特别是到现在,这些术语已经超过了整整 15 年,你的“client”笔记本也已经是 4 到 8 核的 CPU 和 8 GB 内存(处理能力比 Java 刚诞生时的中型服务器还要强)。

与众不同的编译器标志

选择编译器的 Java 标志与大多数标志不同,大都没有使用 -XX。标准的编译器标志是这几个简单的词语:-client、-server 或 -d64。

分层编译(tiered compilation)是个例外,标志采用常见的开启形式 -XX:+TieredCompilation。分层编译意味着必须使用 server 编译器。下面的命令行隐含着关闭分层编译,因为与 client 编译器的选择冲突。

% java -client -XX:+TieredCompilation other_args

两种编译器的最主要的差别在于编译代码的时机不同。client 编译器开启编译比 server 编译器要早。意味着在代码执行的开始阶段,client 编译器比 server 编译器要快,因为它的编译代码相比 server 编译器而言要多。

此处工程上考虑的权衡是,server 编译器等待编译的时候是否还能做更有价值的事:server 编译器在编译代码时可以更好地进行优化。最终,server 编译器生成的代码要比 client 编译器快。从用户角度看,权衡的取舍在于程序要运行多久,程序的启动时间有多重要。

此处最明显的问题是,为什么需要人来做这种选择?为什么 JVM 不能在启动时用 client 编译器,然后随着代码变热使用 server 编译器?这种技术被称为分层编译。代码先由 client 编译器编译,随着代码变热,由 server 编译器重新编译。

Java 7 的早期发布版中曾经提供了分层编译的实验性版本,结果却发现技术上存在许多难点(尤其是两种编译器架构的不同),所以这些实验版的分层编译效果并不好。从 Java 7u4 开始,这些难点很大程度上得到了解决,所以分层编译常常可以让应用发挥最佳性能。

Java 7 中的分层编译有些瑕疵,所以没能成为默认设置。尤其是 Java 7 的分层编译容易超出 JVM 代码缓存的大小,使得代码无法优化编译。使用分层编译需要指定 server 编译器(-server 或者确认它是特定 Java 安装包所用的默认值),并确保 Java 命令行包括标志 -XX:+TieredCompilation(默认值为 false)。Java 8 中,分层编译默认为开启。

为了解其中的权衡,我们来看一些例子。

优化启动

当快速启动时间是首要目标时,最常使用 client 编译器。不同应用使用不同编译器标志的差别见下表:

应用-client-server-XX:+TieredCompilation
HelloWorld0.080.080.08
NetBeans2.833.923.07
BigApp51.554.052.0

对于简单的 HelloWorld 应用,没有编译器占据优势,因为都没有足够的代码可以运行以便优化。并且对于执行时间只有 80 毫秒的任务,我们也很难注意到性能差异是否真的存在。

NetBeans 是相当典型、规模适中的 Java GUI 应用。它启动时,装载约 10 000 个类,初始化一些图形对象等。启动时,client 编译器有很明显的优势:显而易见,server 编译器的启动慢了 38.5%,大约相差了 1 秒。注意分层编译器并不是很快,虽然只慢了约 8%,相差并不大。

这就是为什么 NetBeans——许多 GUI 程序喜欢它,包括 Web 浏览器所用的 Java 插件——默认使用 client 编译器的原因。性能是王道:其他部分还不错的情况下,启动若快,便是晴天,用户就会倾向于认为整个程序都像启动那样快。

启动时间要紧吗?

关于 GUI 程序有个重要的观点,即整体性能比启动性能更重要,而且这种场景更适合使用 server 编译器。

如果 server 编译器优化了应用中的 GUI 代码,最终 GUI 的响应性会有所提高,不过最终用户可能不太会注意到这种差别。但是,如果程序执行了大量其他计算,这样做就有意义了。比如,NetBeans 可以进行大范围的(和昂贵的)代码重构,如果使用 server 编译器,速度就会很快。

通常程序供应商会考虑默认的编译器应该是哪种(因为启动时间是大家首先讨论的事情之一,所以这些程序通常被优化成有最佳的启动时间)。如果你的应用与此不同,不要犹豫,应该尽量使用 server 编译器或分层编译器。

最后来看 BigApp:一个很大的服务器程序,装载超过 20 000 个类,初始化范围很广。因为是服务器应用,可以肯定它需要使用 server 编译器。尽管运行着许多进程,对 client 编译器仍然有些许优势。这个例子的重点是本系列开篇中所提到的:问题并不总在 JVM。在这个示例中,需要从磁盘读取大量的 JAR 文件,从而制约了性能(除此以外,启动上的差异更有利于 client 编译器)。

启动优化小结

  1. 如果应用的启动时间是首要的性能考量,那 client 编译器就是最有用的。

  2. 分层编译的启动时间可以非常接近于 client 编译器所获得的启动时间。

优化批处理

对于批处理应用来说——处理的工作量固定——编译器的选择,归根到底取决于哪种编译器使得应用运行的时间最优。批处理应用在不同编译器下的执行时间如下表:

股票数量-client(秒)-server(秒)-XX:+TieredCompilation(秒)
10.1420.1760.165
100.2110.3480.226
1000.4540.6740.472
10002.5562.1581.910

股票数量为 1 到 100 时,client 编译器最快完成启动任务,说明如果处理的股票数量少于 100 只,client 编译器是最佳选择。之后,性能优势就偏向了 server 编译器(特别是分层编译的 server 编译器)。即便是少量股票,分层编译的性能也很接近于 client 编译器,所以分层编译是适合所有情况的很好的备选方案。

还有一点比较重要,分层编译总是比标准的 server 编译器好一些。理论上,一旦程序足够运行,编译了所有的热点,server 编译器就应该达到最佳(或至少等同于)的性能。但任何程序都有一小部分代码很少执行。最好是编译这些代码——即便编译不是最好的方法——而不是以解释模式运行。正如本章后面(“编译阈值”)所讨论的,实际上即便应用永远运行,server 编译器也不可能编译它的所有代码。

优化批处理小结

  1. 对于计算量固定的任务来说,应该选择实际执行任务最快的编译器。

  2. 分层编译是批处理任务合理的默认选择。

优化长时间运行的应用

对于长时间运行的应用来说,应该一直使用 server 编译器,最好配合分层编译。

Java和JIT编译器版本

各种测试的编译器之间是有差别的,我们来看下如何获得合适的编译器。在你下载 Java 时,需要选择版本,而最终的选择取决于你所用的平台。Java 版本的选择也会影响 JIT 编译器。到目前为止,我们讨论了 client 和 server 编译器,实际上 JIT 编译器有 3 种版本:

32 位 client 编译器(-client)
32 位 server 编译器(-server)
64 位 server 编译器(-d64)

从某种程度上说,你选择使用的编译器取决于所给的命令行选项参数(-server 等)。然而事情并没有这么简单。

32 位还是 64 位?

如果是 32 位操作系统,那你必须使用 32 位的 JVM。如果是 64 位操作系统,那你可以选择 32 位或 64 位 Java。并没有规定 64 位操作系统必须使用 64 位 Java。

如果堆小于 3 GB,32 位的 Java 会更快一些,并且内存占用也更少。这是因为 JVM 内部的指针只有 32 位,操作 32 位指针的代价要少于 64 位指针的(即便你使用的是 64 位 CPU)。而且 32 位指针所占的内存也少。

后面会有压缩的普通对象指针(ordinary object pointers,oops)的讨论,这是一种在 64 位 JVM 中使用 32 位寻址的方法。不过,即便有这种优化,64 位 JVM 的内存占用仍然大于 32 位 JVM,这是因为它所用的本地代码依然是 64 位寻址。

32 位 JVM 的不足之处是进程最多只能占用 4 GB 内存(某些版本的 Windows 为 3 GB,某些老版本的 Linux 为 3.5 GB)。而且还包括了堆、永久代(permgen)和本地代码以及 JVM 所用的本地内存。还有一个非常特殊的案例,因为 32 位 JVM 无法使用 CPU 的 64 位寄存器,所以大量使用 long 或 double 变量的程序在 32 位 JVM 上就会比较慢。

在 32 位 JVM 上运行的程序,只要与 32 位寻址空间吻合,无论机器是 32 位还是 64 位,都要比在类似配置的 64 位 JVM 上运行时快 5% 到 20%。比如,本章前面讨论的股票批处理程序,在我台式机的 32 位 JVM 上运行时,就要快 20%。

在下载特定操作系统的 Java 时,只有两个选项:32 位或 64 位 JVM。而准确地说,32 位 JVM(最多)有两种编译器,而 64 位只有一种编译器。(实际上 64 位 JVM 也有两种编译器,因为分层编译需要有 client 编译器的支持。但在只有 client 编译器时,64 位 JVM 无法运行。)

不过一旦安装之后,事情就变得有些复杂了。在大多数平台上,32 位和 64 位是分开安装的。在你的电脑里可以有两种 JVM,但必须通过不同路径来区分引用。因此,在我测试 Linux 的机器上,我安装了 /export/VMs/jdk1.7.0-32bit 和 /export/VMs/jdk1.7.0-64bit,通过设置相应的 PATH 来选择。

在 Solaris 上则有所不同:64 位的安装会覆盖 32 位。因此所有的 3 种编译器都在相同的路径上。对于最终用户来说更容易。尤其是,Java 安装在系统路径 /usr/bin 上时,用户就总能通过命令行从 3 种编译器中指定他所想要的。不过这种安装方式也有问题,由于 HotSpot 的开发人员通常使用 Solaris 作为主要的开发系统,所以事情就变得更复杂了,所用的安装方式会使讨论(有时是文档)变得令人迷惑。

关于编译器,最后要说的一点是:考虑到兼容性,指定使用哪种编译器的规则并没有被严格遵守。如果 64 位 JVM 上设定 -client,那无论怎样应用都会使用 64 位 server 编译器。如果 32 位 JVM 上设定 -d64,就会抛出所给实例不支持 64 位 JVM 的错误。

总结一下:编译器的选择取决于安装的 JVM 是 32 位还是 64 位,以及传递给 JVM 的编译器参数。

在 Java 8 中,上述所有情况的默认值是 server 编译器,同时也默认开启分层编译。

不设定编译器参数会发生什么?在代码运行时,JVM 选择默认的编译器:默认编译器是运行时选择。这个选择基于 JVM 认为机器是“client”机器还是“server”机器。这个决策综合考虑了操作系统以及机器上的 CPU 数目。

不同OS和机器上的默认编译器

确定默认的编译器

想确定所安装的 Java 的默认编译器,可以运行以下命令:

% java -version
java version "11.0.6" 2020-01-14 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.6+8-LTS)

👇
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.6+8-LTS, mixed mode)

默认值是基于一个理念的,即启动时间对 32 位 Windows 机器来说是最重要的,而基于 Unix 的系统一般来说更关注长期运行的性能。一如既往,这其中也有例外:确切来说,现代 Windows 也可以运行在高性能服务器上,即便是 32 位模式,这种情况下也应该使用 server 编译器。与此类似,许多应用服务器使用简单的 Java 管理命令侦测或变更配置,即使在 Unix 机器上,这些命令用 client 编译器也很快。

JIT编译器版本小结

  1. 不同的 Java 支持不同的编译器。

  2. 不同的操作系统和架构所支持的编译器也不相同。

  3. 程序不必指定编译器,而是仰仗平台所支持的编译器。

编译器中级调优

大多数情况下,所谓编译器调优,其实就只是为目标机器上的 Java 选择正确的 JVM 和编译器开关(-client、-server 或 -XX:+TieredCompilation)而已。分层编译通常是长期运行应用的最佳选择,而对于运行时间短的应用来说,分层编译与 client 编译器的性能差别也只在毫厘之内。

除了选择 JVM 和编译器开关,有些场景还需要进行额外的调优工作,本节就来探讨这一点。

调优代码缓存

JVM 编译代码时,会在代码缓存中保留编译之后的汇编语言指令集。代码缓存的大小固定,所以一旦填满,JVM 就不能编译更多代码了。

很显然,如果代码缓存过小,就可能会有问题。一些热点被编译了,而其他则没有,最终导致应用的大部分代码都是解释运行(非常慢)。

这个问题在使用 client 编译器或进行分层编译时很常见。使用常规的 server 编译器时,因为通常只有少量类会被编译,所以能被编译的类不太可能填满代码缓存。而用 client 编译器时,可被编译的类可能会非常多(因此也适合开启分层编译)。

代码缓存填满时,JVM(通常)会发出以下警告:

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
         Compiler has been disabled.
Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the
         code cache size using -XX:ReservedCodeCacheSize=

有时候容易忽略这个信息,而且开启分层编译时,Java 7 某些版本所输出的信息也不正确。本节后面将讨论另一种判断编译器是否停止编译代码的方法,即追踪输出的编译日志。

ava 7 开启分层编译时,默认的代码缓存通常就不够用了,常常需要扩大。使用 client 编译器的大型程序也需要增加代码缓存的大小。

确实没有什么好的机制可以算出程序所需要的代码缓存。所以,如何增加代码缓存,基本上就是摸着石头过河,通常的做法是简单地增加 1 倍或 3 倍。

-XX:ReservedCodeCacheSize=N(对特定编译器来说,N 为默认的值)标志可以设置代码缓存的最大值。代码缓存的管理和大多数 JVM 内存一样,有初始值(由 -XX:InitialCodeCacheSize=N 指定)。代码缓存从初始大小开始分配,一旦充满就会增加(直至最大值)。代码缓存的初始大小依据芯片架构和所用的 JVM 编译器而有所不同(例如 Intel 机器的 client 编译器的初始代码缓存为 160 KB,server 编译器的初始代码缓存为 2496 KB)。缓存大小的自动调整在后台进行,不会对性能造成实际影响,所以通常只需要设定 ReservedCodeCacheSize(也就是设定代码缓存的最大值)。

为了永远不超出空间而将代码缓存的最大值设得很大,这有没有什么坏处?这取决于目标机器上有多少可用资源。代码缓存设为 1 GB,JVM 就会保留 1 GB 的本地内存空间。虽然这部分内存在需要时才会分配,但它仍然是被保留的,这意味着为了满足保留内存,你的机器必须有足够的虚拟内存。

保留内存与已分配内存

理解 JVM 保留内存和分配内存方式之间的差别非常重要。这种差别在代码缓存、Java 堆以及其他 JVM 本地内存结构中都存在。

此外,如果是 32 位 JVM,则进程占用的总内存不能超过 4 GB。这包括 Java 堆、JVM 自身所有代码占用的空间(包括它的本地库和线程栈)、分配给应用的本地内存(或者 NIO 库的直接内存),当然还有代码缓存。

鉴于上述原因,代码缓存总是受限的,大型应用(甚至使用分层编译时的中型应用)有时需要就此进行调优。然而,特别是在 64 位机器上,这个值设置得太高未必有实际效果,因为应用不可能超过进程的空间内存,且一般来说,操作系统会保留更多的内存。

通过 jconsoleMemory(内存)面板的 Memory Pool Code Cache 图表,可以监控代码缓存。

中级调优小结

  1. 代码缓存是一种有最大值的资源,它会影响 JVM 可运行的编译代码总量。

  2. 分层编译很容易达到代码缓存默认配置的上限(特别是在 Java 7 中)。使用分层编译时,应该监控代码缓存,必要时应该增加它的大小。

编译阈值

本章已经粗略地定义了触发代码编译的条件。其中最主要的因素是代码执行的频度。一旦执行达到一定次数,且达到了编译阈值,编译器就可以获得足够的信息编译代码了。

本节将讨论影响这些阈值的调优标志。不过,本节实际上是为了让你对编译器如何工作有个更深入的了解(并引入一些术语)。实际上只有一种情况需要调优编译阈值,将在本节最后讨论。

编译是基于两种 JVM 计数器的:方法调用计数器和方法中的循环回边计数器。回边实际上可看作是循环完成执行的次数,所谓循环完成执行,包括达到循环自身的末尾,也包括执行了像 continue 这样的分支语句。

JVM 执行某个 Java 方法时,会检查该方法的两种计数器总数,然后判定该方法是否适合编译。如果适合,该方法就进入编译队列。这种编译没有正式的名称,通常叫标准编译。

但是,如果循环真的很长——或因包含所有程序逻辑而永远不退出,又该如何?在这种情况下,JVM 不等方法被调用就会编译循环。所以循环每完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译。

这种编译称为栈上替换(On-Stack Replacement,OSR)。由于仅仅编译循环还不够,JVM 必须在循环进行的时候还能编译循环。在循环代码编译结束后,JVM 就会替换还在栈上的代码,循环的下一次迭代就会执行快得多的编译代码。

标准编译由 -XX:CompileThreshold=N 标志触发。使用 client 编译器时,N 的默认值是 1500,使用 server 编译器时为 10 000。更改 CompileThreshold 标志的值,将使编译器提早(或延后)编译。然而请注意,尽管有一个标志,但这个标志的阈值等于回边计数器加上方法调用计数器的总和。

更改 OSR 编译

更改 OSR 编译阈值的情况非常罕见。事实上,虽然 OSR 编译在基准测试(特别是微基准测试)中经常发生,但在实际运行时并不经常出现。

具体来说,OSR 编译由 3 个标志触发:

OSR trigger = (CompileThreshold *
        ((OnStackReplacePercentage - InterpreterProfilePercentage)/100))

所有编译器中的 -XX:InterpreterProfilePercentage=N 标志的默认值为 33。client 编译器 -XX:OnStackReplacePercentage=N 的默认值为 933,所以在它开始 OSR 编译前,回边计数器需要达到 13 500。在 server 编译器中,由于 OnStackReplacePercentage 默认值为 140,所以当回边计数器达到 10 700 时才开始 OSR 编译。注意,对于分层编译来说,虽然概念相同,但上述默认值完全取决于不同的标志。详情请看 后面的“高级编译器调优”

有一段时期,性能调优中常常会建议更改 CompileThreshold 标志。事实上,Java 的基准测试经常使用该标志(例如,常常在 server 编译器执行 8000 次迭代之后使用它)。

我们已经了解,client 编译器和 server 编译器的基本性能有很大差异,这些差异很大程度上取决于编译方法时编译器所能获得的信息。降低编译阈值,特别是对于 server 编译器来说,可能会减少编译代码的优化——不过,应用测试表明,事实上几乎没有什么差别,比如 8000 次调用和 10 000 次调用差别甚微。

你可以认为,JVM 供应商提交的基准测试已经验证过上述调优,不同设置的基准测试间并没有什么性能差异。他们使用较低的设置主要基于以下两个原因:

节约一点应用热身的时间;

使得某些原本可能不会被 server 编译器编译的方法得以编译。

第一点应该很好理解,但第二点,为什么有些重要方法永远都不会被编译呢?并不是还没达到编译阈值,而是永远都达不到。这是因为虽然计数器随着方法和循环的执行而增加,但它们也会随时间而减少。

每种计数器的值都会周期性减少(特别是当 JVM 达到安全点时)。实际上,计数器只是方法或循环最新热度的度量。由此带来的一个副作用是,执行不太频繁的代码可能永远不会编译,即便是永远运行的程序(相对于热来说,有时称这些方法为温热 [lukewarm])。这就是通过减少编译阈值来进行优化的一种情况,它也是分层编译通常比单独的 server 编译器要快的原因之一。下节将展示对于特定方法如何判定是否需要编译。如果应用分析信息显示关键路径上的方法没有编译,那有时就可以通过降低编译器阈值来触发这些方法的编译。

编译阈值小结

  1. 当方法和循环执行次数达到某个阈值的时候,就会发生编译。

  2. 改变阈值会导致代码提早或推后编译。

  3. 由于计数器会随着时间而减少,以至于“温热”的方法可能永远都达不到编译的阈值(特别是对 server 编译器来说)。

检测编译过程

关于中级优化,最后要讨论的内容并不是优化本身,这些调优措施并不会改善应用性能。更准确地说,它们就是可以让人看到编译器是如何工作的 JVM 标志(和其他工具)。其中最重要的是 -XX:+PrintCompilation(默认为 false)。

如果开启 PrintCompilation,每次编译一个方法(或循环)时,JVM 就会打印一行被编译的内容信息。输出的信息在不同的 Java 发布版之间会有所不同,这里的输出是 Java 7 中已经标准化的信息。

绝大多数编译日志的行具有以下格式:

timestamp compilation_id attributes (tiered_level) method_name size deopt

此处的时间戳 timestamp 是编译完成的时间(相对于 JVM 开始的时间 0)。

compilation_id 是内部的任务 ID。通常这个数字只是简单地单调增长,不过在使用 server 编译器时(或者某个时刻编译器的线程数增加时),你有时会发现乱序的 compilation_id。这表明编译线程相对于其他线程快或者慢了,但不能就以此下结论,某个特定的编译任务因为某种原因变得特别慢了,因为这通常只是线程调度的缘故(尽管 OSR 编译比较慢,经常出现乱序)。

attributes 是一组 5 个字符长的串,表示代码编译的状态。如果给定的编译被赋予了特定属性,就会打印下面列表中所显示的字符,否则该属性就打印一个空格。因此,5 字符属性串可以同时出现 2 个或多个字符。不同的属性如下所列。

%:编译为 OSR。

s:方法是同步的。

!:方法有异常处理器。

b:阻塞模式时发生的编译。

n:为封装本地方法所发生的编译。

其中前 3 个可以自解释。阻塞标志在当前版本的 Java 中默认永远都不会打印,表明编译不会发生在后台。最后,n 属性表明 JVM 生成了一些编译代码以便于调用本地方法。

如果程序没有使用分层编译的方式运行,下一个字段 tiered_level 就是空的。否则就会是数字,以表明所完成编译的级别.

下面一个是被编译方法(或者是被 OSR 编译的包含循环的方法)的名字,打印格式为 ClassName::method。

接下来是编译后代码的大小(单位是字节)。这是 Java 字节码的大小,不是被编译代码的大小(所以很不幸,不能用来预估代码缓存的大小)。

最后,在某些情况下,编译日志行的结尾会有一条信息,表明发生了某种逆优化,通常是“made not entrant”或“made zombie”。

用 jstat 检测编译

编译日志需要在程序启动时开启 -XX:+PrintCompilation。如果程序启动时没有开启这个标志,你可以用 jstat 了解编译器内部的部分工作情况。

jstat 有两个有关编译器信息的标志。-compiler 标志提供了关于多少方法被编译的概要信息(此处 5003 是被检测进程的 ID):

% jstat -compiler 5003
Compiled Failed Invalid   Time    FailedType FailedMethod
     206      0       0     1.97           0

请注意,这里也列出了编译失败的方法个数和最近编译失败的方法名。如果你通过性能分析或其他信息推测某个方法比较慢是因为没有编译,那这行命令就是一个简单验证该假设的方法。

此外,你可以用 -printcompilation 标志获取最近被编译的方法。jstat 借助一个可选参数反复执行操作,你可以看到随时间变化有哪些方法被编译了。在本例中,jstat 每 1 秒(1000 毫秒)输出一次进程 ID 为 5003 的信息:

% jstat -printcompilation 5003 1000
Compiled  Size  Type Method
     207     64    1 java/lang/CharacterDataLatin1 toUpperCase
     208      5    1 java/math/BigDecimal$StringBuilderHelper getCharArray

编译日志还会包括类似下面这行信息:

timestamp compile_id COMPILE SKIPPED: reason

这行信息(包括文本文字 COMPILE SKIPPED)表示编译给定的方法有错误。出现这个错可能有以下两种原因。

代码缓存满了
  需用 ReservedCodeCache 标志增大代码缓存的大小。

编译的同时加载类
  编译类的时候会发生修改。JVM 之后会再次编译,你可以在之后的日志中看到方法被再次编译。

在所有这些情况(除了代码缓存被填满)中,编译都可以再次尝试。如果不能,说明代码编译出了错。虽然通常是编译器的缺陷,但常用的解决方法是将代码重构得更简单,以使编译器能够处理。

以下是股票的 servlet Web 应用开启 PrintCompilation 时的几行输出:

 28015  850              net.sdo.StockPrice::getClosingPrice (5 bytes)
 28179  905  s           net.sdo.StockPriceHistoryImpl::process (248 bytes)
 28226   25 %            net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
 28244  935              net.sdo.MockStockPriceEntityManagerFactory$\
                             MockStockPriceEntityManager::find (507 bytes)
 29929  939              net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
106805 1568   !          net.sdo.StockServlet::processRequest (197 bytes)

输出包括仅有的一些与股票相关的已编译方法。几个有趣的地方值得注意:首个与股票相关的方法在应用服务器启动 28 秒之后才被编译,在它之前有 849 个被编译的方法。在这个例子中,这些都是应用服务器的方法(从输出日志中可以过滤出来)。应用服务器启动用了 2 秒,剩下的没开始编译之前的 26 秒基本上是空闲,因为应用服务器在等待请求。

输出中剩余的几行显示了一些重要特性。process() 是同步方法,这和你在代码列表中所见到的一样。内部类的编译和其他类一样,在日志中遵循 Java 的命名规则:outer-classname$inner-classname。不出所料,processRequest() 有异常处理器。

Servlet中的StockPriceHistoryImpl 的构造函数,它包含了一个大循环:

public StockPriceHistoryImpl(String s, Date startDate, Date endDate) {
    EntityManager em = emf.createEntityManager();
    Date curDate = new Date(startDate.getTime());
    symbol = s;
    while (!curDate.after(endDate)) {
         StockPrice sp = em.find(StockPrice.class, new StockPricePK(s, curDate));
         if (sp != null) {
            if (firstDate == null) {
                firstDate = (Date) curDate.clone();
            }
            prices.put((Date) curDate.clone(), sp);
            lastDate = (Date) curDate.clone();
         }
        curDate.setTime(curDate.getTime() + msPerDay);
    }
}

这个循环的执行次数比构造函数本身多,所以是 OSR 编译的目标。请注意,编译构造函数花费了一点时间,开始时它的编译 ID 为 25,但直到编译其他方法又过了 900 多个编译 ID 之后才再次出现该方法。(这个例子中的 OSR 行信息容易被误读成 25%,你会好奇其他 75% 是什么。请注意,这里的数字只是编译 ID,而 % 只表示 OSR 编译。)这是典型的 OSR 编译,栈上替换比较困难,期间还会持续进行其他编译。

检测编译过程小结

  1. 观察代码如何被编译的最好方法是开启 PrintCompilation。

  2. PrintCompilation 开启后所输出的信息可用来确认编译是否和预期一样。

高级编译器调优

本节将补充一些编译如何工作的细节,在此过程中探索一些可以影响编译的调优方法。不过,虽然可以更改这些值,但真的不建议这么做,因为这些调优标志很大程度上是为了帮助 JVM 工程诊断 JVM 行为的。如果你对编译器的工作原理非常好奇,那你会对本节感兴趣,如果不是,可直接跳过本节内容。

编译线程

前面“编译阈值”曾提到,当方法(或循环)适合编译时,就会进入到编译队列。队列则由一个或多个后台线程处理。这是件好事,意味着编译过程是异步的;这使得即便是代码正在编译的时候,程序也能持续执行。如果是用标准编译所编译的方法,那下次调用该方法时就会执行编译后的方法;如果是用 OSR 编译的循环,那下次循环迭代时就会执行编译后的代码。

编译队列并不严格遵守先进先出的原则:调用计数次数多的方法有更高的优先级。所以,即便在程序开始执行并有大量代码需要编译时,这样的优先顺序仍然有助于确保最重要的代码优先编译。(这是为何 PrintCompilation 输出中的 ID 为乱序的另一个原因。)

当使用 client 编译器时,JVM 会开启一个编译线程;使用 server 编译器时,则会开启两个这样的线程。当启用分层编译时,JVM 默认开启多个 client 和 server 线程,线程数依据一个略复杂的等式而定,包括目标平台 CPU 数取双对数之后的数值。表中显示的值即为计算出的数值。

CPU数量C1的线程数C2的线程数
111
211
412
812
1626
3237
6448
128410

编译器的线程数(3 种编译器都是如此)可通过 -XX:CICompilerCount=N 标志来设置(默认值参见前表)。这是 JVM 处理队列的线程总数;对分层编译来说,其中三分之一(至少一个)将用来处理 client 编译器队列,其余的线程(至少一个)用来处理 server 编译器队列。

你何时需要考虑调整该参数值?如果程序运行在单 CPU 系统上,那么只有设置成单个编译器线程才可以得到些好处:对可用 CPU 受限的系统来说,在许多情况下只有减少争抢资源的线程数才有利于性能提升。但是,这种好处也仅限于初始的热身阶段;在此之后,已编译过的方法将不会再引起 CPU 竞争。当股票配比处理应用运行在单 CPU 机器上,并且编译器线程数限制为一时,初始计算会快大约 10%(因为不用经常争抢 CPU)。运行的轮次越多,初始时的整体收益就越小,直到所有热点方法都被编译之后,这种收益就消失了。

使用分层编译时,线程数很容易超过系统限制,特别是有多个 JVM 同时运行的时候(每个都开启很多编译线程)。在这种情况下,减少线程数有助于提升整体的吞吐量(尽管代价可能是热身期会持续得更长)。

与此类似,如果有额外可用的 CPU 周期,理论上程序将会受益——至少在热身期间——此时编译器线程数会增加。在实际工作中,这样的好处很难获得。进一步来说,如果有很多可用的 CPU,那么在应用的整个执行过程中,你都可以去尝试那些能充分发挥可用 CPU 周期的方法(而不仅仅在开始时加快编译),这样会好得多。

另外一个编译线程的设定参数是 -XX:+BackgroundCompilation 标志,默认值为 true。这意味着,和参数所描述的一样,编译队列的处理是异步执行的。但这个参数也可以设置为 false,在这种情况下,当一个方法适合编译,执行该方法的代码将一直等到它确实被编译之后才执行(而不是继续在解释器中执行)。用 -Xbatch 可以禁止后台编译。

编译线程小结

  1. 放置在编译队列中的方法的编译会被异步执行。

  2. 队列并不是严格按照先后顺序的;队列中的热点方法会在其他方法之前编译。这是编译输出日志中的 ID 为乱序的另一个原因。

内联

编译器所做的最重要的优化是方法内联。遵循面向对象设计的良好代码通常都会包括一些需要通过 getter(也可能包含 setter)访问的属性:

public class Point {
    private int x, y;
    public void getX() { return x; }
    public void setX(int i)  { x = i; }
}

此类方法调用的开销很大,特别是相对于方法的代码量而言。事实上,在早期的 Java 中,考虑到所有此类调用对性能的影响,性能调优小贴士常常会信誓旦旦地反对此类封装。幸运的是,现在的 JVM 通常都会用内联代码的方式执行这些方法。因此,你可以这样写代码:

Point p = getPoint();
p.setX(p.getX() * 2);

而编译后的代码本质上执行的是:

Point p = getPoint();
p.x = p.x * 2;

内联默认是开启的。可通过 -XX:-Inline 关闭,然而由于它对性能的影响巨大,事实上你永远不会这么做。由于内联非常重要,并且还因为有许多其他控制标志,所以通常都会建议对 JVM 内联进行调优。

不幸的是,基本上没法看到 JVM 是如何内联代码的。(如果你从源代码编译 JVM,那可以用 -XX:+PrintInlining 生成带调试信息的版本。这个参数会提供所有关于编译器如何进行内联决策的信息。)最好的方法是查看代码的分析信息,如果在分析信息的顶部附近有简单的方法,并且看起来这些方法应该内联,可用内联标志做些试验。

方法是否内联取决于它有多热以及它的大小。JVM 依据内部计算来判定方法是否是热点(譬如,调用很频繁);是否是热点并不直接与任何调优参数相关。如果方法因调用频繁而可以内联,那只有在它的字节码小于 325 字节时(或 -XX:MaxFreqInlineSize=N 所设定的任意值)才会内联。否则,只有方法很小时,即小于 35 字节(或 -XX:MaxInlineSize=N 所设定的任意值)时才会内联。

有时你会看到增加 MaxInlineSize 的值以便内联更多方法的建议。两者之间常被忽略的是,MaxInlineSize 超过 35 意味着第一次调用方法时就会被内联。然而,方法只有经常被调用时——在这种情况下它的性能会受更大影响——最终才值得内联(假定它的大小小于 325 字节)。否则,MaxInlineSize 调优的最终结果就是减少了热身测试所需的时间,但不太可能对长期运行的程序产生重大影响。

内联小结

  1. 内联是编译器所能做的最有利的优化,特别是对属性封装良好的面向对象的代码来说。

  2. 几乎用不着调节内联参数,且提倡这样做的建议往往忽略了常规内联和频繁调用内联之间的关系。当考察内联效应时,确保考虑这两种情况。

逃逸分析

如果开启逃逸分析(-XX:+DoEscapeAnalysis,默认为 true),server 编译器将会执行一些非常激进的优化措施。比如,考虑以下计算阶乘的类:

public class Factorial {
    private BigInteger factorial;
    private int n;
    public Factorial(int n) {
        this.n = n;
    }
    public synchronized BigInteger getFactorial() {
        if (factorial == null)
            factorial = ...;
        return factorial;
    }
}

若想在数组中保存前 100 个阶乘值,使用以下代码:

ArrayList<BigInteger> list = new ArrayList<BigInteger>();
for (int i = 0; i < 100; i++) {
    Factorial factorial = new Factorial(i);
    list.add(factorial.getFactorial());
}

factorial 对象只在循环中引用;没有任何其他代码可以访问该对象。因此,JVM 会毫不犹豫地对这个对象进行一系列优化。

当调用 getFactorial() 时,没必要获得同步锁。

没必要在内存中保存 n;可以在寄存器中保存该值。同样,factorial 也可以保存在寄存器中。

事实上,根本就不需要分配实际的 factorial 对象;可以只追踪这个对象的个别字段。

此类优化非常复杂:虽然这个例子非常简单,但此类优化可能会伴随更复杂的代码。由于所用的代码不同,并不是所有的优化都有必要使用。但逃逸分析可以决定哪些优化是可能的,并决定编译后的代码中哪些是必要的改变。

逃逸分析默认开启。极少数情况下,它会出错,在此类情况下关闭它会变得更快或更稳定。如果你发现了这种情况,最好的应对行为就是简化相关代码:代码越简单越好。(不过如果是 bug,则应该发送报告。)

逃逸分析小结

  1. 逃逸分析是编译器能做得最复杂的优化。此类优化常常会导致微基准测试失败。

  2. 逃逸分析常常会给不正确的同步代码引入“bug”。

逆优化

PrintCompilation 标志输出的讨论中曾提到两种代码逆优化的情况。逆优化意味着编译器不得不“撤销”之前的某些编译;结果是应用的性能降低——至少是直到编译器重新编译相应代码为止。

有两种逆优化的情形:代码状态分别为“made not entrant”(代码被丢弃)和“made zombie”(产生僵尸代码)时。

代码被丢弃

有两种原因导致代码被丢弃。可能是和类与接口的工作方式有关,也可能与分层编译的实现细节有关。

先考虑第一种情况。回想一下 stock 应用的接口 StockPriceHistory。示例代码中有两种接口实现:基本实现(StockPriceHistoryImpl)和每个操作带有日志的实现(StockPriceHistoryLogger)。servlet 代码依据请求 URL 上的 log 参数来选择实现:

StockPriceHistory sph;
String log = request.getParameter("log");
if (log != null && log.equals("true")) {
    sph = new StockPriceHistoryLogger(...);
}
else {
    sph = new StockPriceHistoryImpl(...);
}
// 然后JSP调用:
sph.getHighPrice();
sph.getStdDev();
// 等等

如果向 localhost:8080/StockServlet 发起一组请求调用(没有 log 参数),那么编译器会看到 sph 的实际类型为 StockPriceHistoryImpl。然后它将内联代码,并据此执行其他优化。

之后再向 localhost:8080/StockServlet?log=true 发起一次调用。现在编译器依据 sph 类型所做的假定就不成立了,之前的优化也失效了。这就产生了逆优化陷阱(deoptimization trap),之前的优化也被废弃了。如果有更多这样带有 log=true 的请求调用,JVM 就会很快终止这部分代码编译,而开始新的编译。

该场景的编译日志包括了以下信息:

 841113   25 %           net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                 made not entrant
 841113  937  s          net.sdo.StockPriceHistoryImpl::process (248 bytes)
                                 made not entrant
1322722   25 %           net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                 made zombie
1322722  937  s          net.sdo.StockPriceHistoryImpl::process (248 bytes)
                                 made zombie

请注意,OSR 编译过的构造函数和标准编译过的方法都被标记成 made not entrant,过了一会,它们又被标记成 made zombie。

逆优化听起来不太好,至少作为性能优化的术语是这样,但并非总是如此。本章的第一个例子中用了股票的 servlet 应用,只测了触发 StockPriceHistoryImpl 的 URL。测试热身时间 300 秒,使用分层编译,达到 24.4 OPS。

假定上述测试之后立刻用 StockPriceHistoryLogger 测试——也就是我刚刚列举的那个逆优化例子。PrintCompilation 的完整输出显示,请求带日志的 StockPriceHistory 实现时,StockPriceHistoryImpl 类的所有方法都被逆优化了。不过,逆优化之后,如果再次请求 StockPriceHistoryImpl,这些代码又会重新编译(原先的假设会发生少许差异),最终我们仍将看到大约 24.4 OPS(在新一轮热身之后)。

当然这是最好的情况。如果混合这些调用,使得编译器无法假定采用哪种代码路径,会发生什么?因为有额外的日志,所以通过 servlet 访问带日志的路径大约需要 24.1 OPS。如果混合操作,大约为 24.3 OPS,与期望的平均值相近。在批处理程序中也能观察到类似的结果。所以,除了进入陷阱的短暂时间,逆优化对其他方面没有产生什么重大影响。

第二种导致代码被丢弃的原因是分层编译。在分层编译中,代码先由 client 编译器编译,然后由 server 编译器编译(实际上要比这复杂一些,下一节会进一步讨论)。当 server 编译器编译好代码之后,JVM 必须替换 client 编译器所编译的代码。它会将老代码标记为废弃,也用同样的办法替换新编译(和更有效)的代码。因此,当程序使用分层编译时,编译日志就会显示许多被丢弃的方法。不要慌张,这种“逆优化”事实上使代码变得更快了。

可以通过观察编译日志中的层次级别信息来检测逆优化:

40915   84 %     3      net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
40923 3697       3      net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
41418   87 %     4      net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
41434   84 %     3      net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                   made not entrant
41458 3749       4      net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
41469 3697       3      net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
                                   made not entrant
42772 3697       3      net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
                                   made zombie
42861 84 %       3      net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                   made zombie

这里,构造方法首先是级别 3 的 OSR 编译,然后在级别 3 得到完整编译。很快,OSR 代码变得适合级别 4 的编译,所以 JVM 在级别 4 上编译代码,而原先级别 3 的代码被丢弃。相同的过程在标准编译中也会发生,而级别 3 的编译代码最后会变成僵尸代码。

逆优化僵尸代码

编译日志显示产生了僵尸代码,意思是说 JVM 已经回收了之前被丢弃的代码。在上面的例子中,测试过 StockPriceHistoryLogger 之后,StockPriceHistoryImpl 类的编译代码就被丢弃了。但是 StockPriceHistoryImpl 类的对象仍然存在。最终,所有这些对象都会被 GC 回收。全部回收之后,编译器就会注意到,这个类现在适合标记为僵尸代码了。

从性能角度来看,这是好事。回想一下,编译代码保存在有固定大小的代码缓存中。如果发现僵尸方法,这意味着这些有问题的代码可以从代码缓存中移除,腾出空间给其他将被编译的代码(或者限制 JVM 之后需要分配的内存量)。

可能产生的不足是,如果代码被僵尸化以后被再次加载并且频繁使用,JVM 就需要重新编译和重新优化代码。而且情形就像上面所描述的那样,测试一会没日志,一会有日志,然后又没有日志。这种情况下,性能并没有受到明显的影响。一般来说,像僵尸代码重编译这样小的操作对大多数应用都不会有显著的影响。

逆优化小结

  1. 逆优化使得编译器可以回到之前版本的编译代码。

  2. 先前的优化不再有效时(例如,所涉及的对象类型发生了更改),才会发生代码逆优化。

  3. 代码逆优化时,会对性能产生一些小而短暂的影响,不过新编译的代码会尽快地再次热身。

  4. 分层编译时,如果代码之前由 client 编译器编译而现在由 server 编译器优化,就会发生逆优化。

分层编译级别

程序使用分层编译时,编译日志中会输出代码所编译的分层级别。上一节的示例中,代码最多编译到级别 4,甚至为了简化讨论,到目前为止,我已经说过只有两种编译器(加上解释器)。

因为 client 编译器有 3 种级别,所以总共有 5 种执行级别。因此,编译级别有:

0:解释代码

1:简单 C1 编译代码

2:受限的 C1 编译代码

3:完全 C1 编译代码

4:C2 编译代码

典型的编译日志可以显示,多数方法第一次编译的级别是 3,即完全 C1 编译。(当然,所有方法都从级别 0 开始。)如果方法运行得足够频繁,它就会编译成级别 4(级别 3 的代码就会被丢弃)。最常见的情况是:client 编译器从获取了代码如何使用的信息进行优化时才开始编译。

如果 server 编译器队列满了,就会从 server 队列中取出方法,以级别 2 进行编译,在这个级别上,C1 编译器使用方法调用计数器和回边计数器(但不需要性能分析的反馈信息)。这使得方法编译得更快,而方法也将在 C1 编译器收集分析信息之后被编译为级别 3,最终当 server 编译器队列不太忙的时候被编译为级别 4。

另一方面,如果 client 编译器全忙,原本排程在级别 3 编译的方法就既可以等待级别 3 编译,也适合进行级别 4 的编译。在这种情况下,方法编译会很快转到级别 2,然后由级别 2 转到级别 4。

那些不太重要的方法可以从级别 2 或级别 3 开始编译,但随后会因为它们的重要性没那么高而转为级别 1。另外,如果 server 编译器出于某些原因无法编译代码,也会转为级别 1。

当然,代码在逆编译时会转为级别 0。

有些标志可以控制某些级别转换行为,但调优能够得到很乐观的结果。当方法按期望的顺序,即级别 0 → 级别 3 → 级别 4 编译时,性能可以达到最优。如果方法经常被编译为级别 2,并且还额外有可用的 CPU 周期,那就可以考虑增加编译器的线程数,从而减少 server 编译器队列的长度。如果没有额外可用的 CPU 周期,那你唯一能做的就是尽力减小应用的大小。

分层编译小结

  1. 分层编译可以在两种编译器和 5 种级别之间进行。

  2. 不建议人为更改级别,本节仅仅是辅助解释编译日志的输出。

小结

本章提供了大量关于即时编译如何工作的细节。从调优角度看,简单的选择就是对所有应用都使用 server 编译器和分层编译,这将解决 90% 的与编译器相关的性能问题。只要确保代码缓存足够大,编译器就能提供尽善尽美的性能。

关于编译器调优的最后一句话

如果你有 Java 性能调优方面的经验,你或许会觉得惊奇,因为整章关于编译的讨论中都没有提及 final 关键字。final 总被认为是影响性能的重要因素,因为大家相信在进行内联和其他优化时,final 可以使 JIT 编译器作出更好的选择。

这种想法在落伍的过去或许有一些价值,但已经很多年很多年不是这样了(即便曾经是)。而其实它是流传甚广的谣言。准确地说,只要有必要时,你就应该使用 final:比如你不打算改变的不可变对象或原生值,内部类引用的外部参数等等。但无论有没有 final 关键字,都不会影响应用的性能。

本章也包括了大量关于编译器如何运作的背景知识。这么做的原因是,你可以理解前面关于小方法和小代码的一般性建议和前面所描述的微基准测试对编译器的影响。特别是,还涵盖了以下知识。

  1. 不用担心小方法——特别是 getter 和 setter,因为它们很容易内联。如果你觉得某个方法的负载过大,那你可能只是在理论上是正确的(通过移除内联给性能造成的巨大影响可以展示这点)。而实际上并不是这样,因为编译器会修复这些问题。

  2. 需要编译的代码在编译队列中。队列中代码越多,程序达到最佳性能的时间越久。

  3. 虽然代码缓存的大小可以(也应该)调整,但它仍然是有限的资源。

  4. 代码越简单,优化越多。分析反馈和逃逸分析可以使代码更快,但复杂的循环结构和大方法限制了它的有效性。

最后,如果你在分析代码时,很惊奇地发现一些方法出现在了分析信息的顶部——你原本期望不应该出现在那里——你可以使用本章的内容审视编译器正在做什么,以确保它正在以你所写的代码逻辑处理。

更新时间:2020-03-07 09:59:38

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优06jit编译器
最后更新:2020-03-07 09:59:38

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×