Java性能调优07——垃圾回收优化(上)

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

注:这里依然用JDK7和JDK8为主,在最新的jdk中已经被替换了。

这一章我们会一起探究 JVM 垃圾收集的基础知识。很多时候我们没有机会重写代码,又面临需要提高 Java 应用性能的压力,这种情况下对垃圾收集器的调优就变得至关重要。

现代 JVM 的类型繁多,最主流的四个垃圾收集器分别是:Serial 收集器(常用于单 CPU 环境)、Throughput(或者 Parallel)收集器、Concurrent 收集器(CMS)和 G1 收集器。它们的性能特征迥异,下一章将围绕每种垃圾收集器的特质进行深入讨论。虽然存在差异,不过它们也有不少共性,本章会针对这些共性概述垃圾收集器是如何工作的。

垃圾收集概述

对程序员而言,Java 最诱人的特性之一是不需要显式地管理对象的生命周期:我们可以在需要时创建对象,对象不再被使用时,会由 JVM 在后台自动进行回收。但是,如果你像我一样,花费大量的时间来优化 Java 程序的内存使用,这套机制看起来可能更像个缺点,而非其优点(在垃圾收集调优上耗费的时间似乎也证明了这一点)。

简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存。JVM 从查找不再使用的对象(垃圾对象)入手。有时,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。然而,这种靠引用计数的方式不太靠谱:假设有一个对象链接列表,列表中的每一个对象(除了头节点)都指向列表中的另一个对象,但是,如果没有任何一个引用指向列表头,这个列表就没人使用,可以被垃圾回收器回收。如果这是一个循环列表(即列表的尾元素反过来又指向了头元素),那么列表中的每一个元素都包含一个引用,即使这个列表内没有任何一个对象实际被使用,因为没有任何一个对象指向这个列表。

所以引用是无法通过计数的方式动态跟踪的;JVM 必须定期地扫描堆来查找不再使用的对象。一旦发现垃圾对象,JVM 会回收这些对象所持有的内存,把它们分配给需要内存的其他对象。然而,简单地记录空闲内存也无法保证将来有足够的可用内存,有些时候,我们还必须进行内存整理来防止内存碎片。

假设以下场景,一个程序需要分配大小为 1000 字节的数组,紧接着又分配一个大小为 24 字节的数组,并在一个循环中持续进行这样的分配。最终程序会耗尽整个堆,结果如图中的第一行所示:堆空间被占满,分配的数组间隔地分布于整个堆内。

堆内存用尽会触发 JVM 回收不再使用的数组空间。假设所有大小为 24 字节的数组都不再被使用,而大小为 1000 字节的数组还继续使用,这就形成了图 5-1 中第二行的场景。虽然堆内部有足够的空闲空间,却找不到任何一个大于 24 字节的连续空间,除非 JVM 移动所有大小为 1000 字节的数组,让它们连续存储,把空闲的空间整合成一块更大的连续空间,供其他的内存分配使用(如图中的第三行)。

深入到这些具体实现似乎太过于琐屑,但垃圾收集的性能就是由这些基本操作所决定的:找到不再使用的对象、回收它们使用的内存、对堆的内存布局进行压缩整理。完成这些操作时不同的收集器采用了不同的方法,这也是不同垃圾收集器表现出不同性能特征的原因。

如果垃圾收集进行时,没有任何应用程序线程在运行,那么完成这些操作将是一件轻松愉快的事情。然而实际情况要复杂得多,通常 Java 程序都启动了大量的线程,垃圾收集器自身往往也是多线程的。接下来的讨论中,我们从逻辑上将线程分成了两组,分别是应用程序线程和处理垃圾收集的线程。垃圾收集器回收对象,或者在内存中移动对象时,必须确保应用程序线程不再继续使用这些对象。这一点在收集器移动对象时尤其重要:在操作过程中,对象的内存地址会发生变化,因此这个过程中任何应用线程都不应再访问该对象。

所有应用线程都停止运行所产生的停顿被称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响最大,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

分代垃圾收集器

虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将堆划分成不同的代(Generation)。这些代被称为“老年代”(Old Generation 或 Tenured Generation)和“新生代”(Young Generation)。新生代又被进一步地划分为不同的区段,分别称为 Eden 空间和 Survivor 空间(不过 Eden 有时会被错误地用于指代整个新生代)。

采用分代机制的原因是很多对象的生存时间非常短。譬如下面这个例子,这是一个计算股价的循环,它将股价与股票均价的差值进行乘方运算,然后将结果加和(作为标准偏差计算的一部分)。

sum = new BigDecimal(0);
for (StockPrice sp : prices.values()) {
    BigDecimal diff = sp.getClosingPrice().subtract(averagePrice);
    diff = diff.multiply(diff);
    sum = sum.add(diff);
}

BigDecimal 同许多 Java 类一样是不可变对象:该对象代表的是一个不能修改的数字。运算使用这个对象时,会创建一个新的对象(通常,前一个对象及其值会被丢弃)。这个简单的循环处理一年的股票数据(大约 250 个循环)时,为了存储循环的中间值,会创建 750 个 BigDecimal 对象,只是在这一个循环里。这些对象在循环的下一个周期开始时会被丢弃。在 add() 以及其他方法内,JDK 的库方法会创建更多类似 BigDecimal(以及其他的类)的中间对象。最终,在一小段代码中大量的对象被快速地创建和丢弃。

Java 中,这种操作是非常普遍的,所以垃圾收集器设计时就特别考虑要处理大量(有时候是大多数)的临时对象。这也是分代设计的初衷之一。新生代是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其他地方。这种操作被称为 Minor GC。

采用这种设计有两个性能上的优势。其一,由于新生代仅是堆的一部分,与处理整个堆相比,处理新生代的速度更快。而这意味着应用线程停顿的时间会更短。你可能也看到了这其中的权衡,这意味着应用程序线程会更频繁地发生停顿,因为 JVM 不再等到整个堆都填满才进行垃圾收集;本章后续部分会针对其利弊进行深入的讨论。然而,就目前而言,更短的停顿显然能带来更多的优势,即使发生的频率更高。

第二个优势源于新生代中对象分配的方式。对象分配于 Eden 空间(占据了新生代空间的绝大多数)。垃圾收集时,新生代空间被清空,Eden 空间中的对象要么被移走,要么被回收;所有的存活对象要么被移动到另一个 Survivor 空间,要么被移动到老年代。由于所有的对象都被移走,相当于新生代空间在垃圾收集时自动地进行了一次压缩整理。

所有的垃圾收集算法在对新生代进行垃圾回收时都存在“时空停顿”现象。

对象不断地被移动到老年代,最终老年代也会被填满,JVM 需要找出老年代中不再使用的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的垃圾收集算法直接停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对堆空间进行整理。这个过程被称为 Full GC,通常导致应用程序线程长时间的停顿。

另一方面,通过更复杂的计算,我们还有可能在应用线程运行的同时找出不再使用的对象;CMS 和 G1 收集器就是通过这种方式进行垃圾收集的。由于它们不需要停止应用线程就能找出不再用的对象,CMS 和 G1 收集器被称为 Concurrent 垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器(有时也称为无停顿收集器,虽然这个叫法相当不确切)。Concurrent 收集器也使用各种不同的方法对老年代空间进行压缩。

使用 CMS 或 G1 收集器时,应用程序经历的停顿会更少(也更短)。其代价是应用程序会消耗更多的 CPU。CMS 和 G1 收集也可能遭遇长时间的 Full GC 停顿(尽量避免发生那样的停顿是这些调优算法要考虑的重要方面)。

评估垃圾收集器时,想想你需要达到的整体性能目标。每一个决定都需要权衡取舍。如果应用对单个请求的响应时间有要求(譬如 Java 企业版服务器),你应该考虑下面这些因素。

单个请求会受停顿时间的影响——不过其受 Full GC 长时间停顿的影响更大。如果目标是要尽可能地缩短响应时间,那么选择使用 Concurrent 收集器更合适。

如果平均响应时间比最大响应时间更重要(譬如 90% 的响应时间),采用 Throughput 收集器通常就能满足要求。

使用 Concurrent 收集器来避免长的停顿时间也有其代价,这会消耗额外的 CPU。

类似地,为批量应用选择垃圾收集器可以遵循下面的原则。

如果 CPU 足够强劲,使用 Concurrent 收集器避免发生 Full GC 停顿可以让任务运行得更快。

如果 CPU 有限,那么 Concurrent 收集器额外的 CPU 消耗会让批量任务消耗更多的时间。

分代回收器小结

  1. 所有的 GC 算法都将堆划分成了老年代和新生代。

  2. 所有的 GC 算法在清理新生代对象时,都使用了“时空停顿”(stop-the-world)方式的垃圾收集方法,通常这是一个能较快完成的操作。

GC算法

JVM 提供了以下 4 种不同的垃圾收集算法。

Serial垃圾收集器

Serial 垃圾收集器是四种垃圾收集器中最简单的一种。如果应用运行在 Client 型虚拟机(Windows 平台上的 32 位 JVM 或者是运行在单处理器机器上的 JVM)上,这也是默认的垃圾收集器。

Serial 收集器使用单线程清理堆的内容。使用 Serial 收集器,无论是进行 Minor GC 还是 Full GC,清理堆空间时,所有的应用线程都会被暂停。进行 Full GC 时,它还会对老年代空间的对象进行压缩整理。通过 -XX:+UseSerialGC 标志可以启用 Serial 收集器(大多数情况下,如果可以使用这个标志,默认就会开启)。注意,跟大多数的 JVM 标志不同,关闭 Serial 收集器不能简单地将加号符变成减号符(譬如,使用 -XX:-UseSerialGC)。在 Serial 收集器作为默认收集器的系统上,如果需要关闭 Serial 收集器,可以通过指定另一种垃圾收集器来实现

Throughput垃圾收集器

Throughput 收集器是 Server 级虚拟机(多 CPU 的 Unix 机器以及任何 64 位虚拟机)的默认收集器。

Throughput 收集器使用多线程回收新生代空间,Minor GC 的速度比使用 Serial 收集器快得多。处理老年代时 Throughput 收集器也能使用多线程方式。这已经是 JDK 7u4 及之后的版本的默认行为,对于之前老版本的 JDK 7 虚拟机,通过 -XX:+UseParallelOldGC 标志可以开启这个功能。由于 Throughput 收集器使用多线程,Throughput 收集器也常常被称为 Parallel 收集器。Throughput 收集器在 Minor GC 和 Full GC 时会暂停所有的应用线程,同时在 Full GC 过程中会对老年代空间进行压缩整理。由于在大多数适用的场景,它已经是默认的收集器,所以你基本上不需要显式地启用它。如果需要,可以使用 -XX:+UseParallelGC、-XX:+UseParallelOldGC 标志启用 Throughput 收集器。

CMS收集器

CMS 收集器设计的初衷是为了消除 Throughput 收集器和 Serial 收集器 Full GC 周期中的长时间停顿。CMS 收集器在 Minor GC 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。然而,这其中最显著的不同是,CMS 不再使用 Throughput 的收集算法(-XX:+UseParallelGC),改用新的算法来收集新生代对象(使用 -XX:+UseParNewGC 标志)。

CMS 收集器在 Full GC 时不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。这种算法帮助 CMS 成为一个低延迟的收集器:应用线程只在 Minor GC 以及后台线程扫描老年代时发生极其短暂的停顿。应用程序线程停顿的总时长与使用 Throughput 收集器比起来短得多。

这里额外付出的代价是更高的 CPU 使用:必须有足够的 CPU 资源用于运行后台的垃圾收集线程,在应用程序线程运行的同时扫描堆的使用情况。除此之外,后台线程不再进行任何压缩整理的工作,这意味着堆会逐渐变得碎片化。如果 CMS 的后台线程无法获得完成他们任务所需的 CPU 资源,或者如果堆变得过度碎片化以至于无法找到连续空间分配对象,CMS 就蜕化到 Serial 收集器的行为:暂停所有应用线程,使用单线程回收、整理老年代空间。这之后又恢复到并发运行,再次启动后台线程(直到下一次堆变得过度碎片化)。通过 -XX:+UseConcMarkSweepGC、-XX:+UseParNewGC 标志(默认情况下,这两个标志都是禁用的)可以启用 CMS 垃圾收集器。

G1垃圾收集器

G1 垃圾收集器(或者垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于 4 GB)时产生的停顿。G1 收集算法将堆划分为若干个区域(Region),不过它依旧属于分代收集器。这些区域中的一部分包含新生代,新生代的垃圾收集仍然采用暂停所有应用线程的方式,将存活对象移动到老年代或者 Survivor 空间。同其他的收集算法一样,这些操作也利用多线程的方式完成。

G1 收集器属于 Concurrent 收集器:老年代的垃圾收集工作由后台线程完成,大多数的工作不需要暂停应用线程。由于老年代被划分到不同的区域,G1 收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,这也意味着在正常的处理过程中,G1 收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用 G1 收集器的堆不大容易发生碎片化——虽然这种问题无法避免。

同 CMS 收集器一样,避免 Full GC 的代价是消耗额外的 CPU 周期:负责垃圾收集的多个后台线程必须能在应用线程运行的同时获得足够的 CPU 运行周期。通过标志 -XX:+UseG1GC(默认值是关闭的)可以启动 G1 垃圾收集器。

触发及禁用显式的垃圾收集

通常情况下垃圾收集是由 JVM 在需要的时候触发:新生代用尽时会触发 Minor GC,老年代用尽时会触发 Full GC,或者堆空间即将填满时会触发 Concurrent 垃圾收集(如果情况需要)。

Java 也提供了一种机制让应用程序强制进行 GC:这就是 System.gc() 方法。通常情况下,试图通过调用这个方法显式触发 GC 都不是个好主意。调用这个方法会触发 Full GC(即使 JVM 使用 CMS 或者 G1 垃圾收集器),应用程序线程会因此而停顿相当长的一段时间。同时,调用这个方法也不会让应用程序更高效,它会让 GC 更早地开始,但那实际只是将性能的影响往后推迟而已。

任何原则都有例外,尤其是在做性能监控或者基准测试时。运行少量的代码进行基准测试时,为了更快地预热 JVM,在测量周期之前强制进行一次 GC 还是有意义的。类似的情况还包括在进行堆分析时,通常在获取堆转储之前,强制进行一次 Full GC 是一个不错的主意。虽然大多数抓取堆转储的方法都能进行 Full GC,也存在其他的方法可以强制进行 Full GC:你可以通过执行 jcmd < 进程号 > GC.run,或者使用 jconsole 连接到 JVM 在内存面板上单击“进行 GC”按钮。

另一个例外是 RMI,作为它分布式垃圾收集器的一部分,每隔一小时它会调用 System.gc() 一次。这里的调用时间可以通过系统属性中的 -Dsun.rmi.dgc.server.gcInterval=N 和 -Dsun.rmi.dgc.client.gcInterval=N 进行修改。N 值的单位以毫秒记,在 Java 7(该值与之前的版本亦不相同)中的默认值为 3 600 000(即一个小时)。

如果你运行的程序调用的第三方代码中错误地调用了 System.gc() 方法,可以通过 JVM 参数 -XX:+DisableExplicitGC 显式地禁止这种类型的 GC;默认情况下该标志是关闭的。

GC算法小结

  1. 这四种垃圾收集算法分别采用了的不同的方法来缓解 GC 对应用程序的影响。

  2. Serial 收集器常用于仅有单 CPU 可用以及当其他程序会干扰 GC 的情况(通常是默认值)。

  3. Throughput 收集器在其他的虚拟机上是默认值,它能最大化应用程序的总吞吐量,但是有些操作可能遭遇较长的停顿。

  4. CMS 收集器能够在应用线程运行的同时并行地对老年代的垃圾进行收集。如果 CPU 的计算能力足以支撑后台垃圾收集线程的运行,该算法能避免应用程序发生 Full GC。

  5. G1 收集器也能在应用线程运行的同时并发地对老年代的垃圾进行收集,在某种程度上能够减少发生 Full GC 的风险。G1 的设计理念使得它比 CMS 更不容易遭遇 Full GC。

选择GC算法

GC 算法的选择一方面取决于应用程序的特征,另一方面取决于应用的性能目标。

Serial 收集器最适用于应用程序的内存使用少于 100 MB 的场景。这种情况下应用程序只需要很小的堆,无论是 Throughput 收集器的并行收集,还是 CMS 收集器或 G1 收集器的后台收集都发挥不了太大的作用。

这个 Sizing 准则也限制了 Serial 收集器的使用范畴。大多数的程序需要在 Throughput 和 Concurrent 收集器之间做出抉择;而选择的依据大多数情况下是由应用程序的性能目标所决定的。

关于这个主题在第二章有过概述:不同应用在耗时、吞吐量、或者平均(或者总量 90% 的)响应时间上的要求迥异。

GC算法及批量任务

对批量任务而言,Throughput 收集器所引入的停顿,尤其是 Full GC 的停顿是主要的顾虑。每个任务的执行都会为总的执行时间增加一部分的延迟时间(elapse time)。如果每次 Full GC 耗时 0.5 秒,程序 5 分钟的运行时间内要进行 20 个这样的周期,那么性能的损耗就高达 3.4%:如果没有这些停顿,程序可以在 290 秒而不是 300 秒内完成运行。

如果有额外的 CPU 处理能力(这很可能是个问题),那么使用 Concurrent 收集器将极大地提升应用程序的性能。这里的关键在于我们能否提供足够的 CPU 给 Concurrent 收集器的线程进行后台的处理工作。举个简单的例子:一个单 CPU 的机器上,单线程的应用程序已经消耗了 100% 的 CPU 资源。该应用程序使用 Throughput 收集器运行时,GC 间歇性地发生,导致应用程序线程出现停顿。同样的程序,如果切换到 Concurrent 收集器,操作系统一会在 CPU 上运行应用程序线程,一会儿运行 GC 的后台线程。最终的结果是一样的:操作系统运行其他线程时,应用程序线程依然会发生停顿(不过有可能是更短 时间的停顿)。

这个原则同样适用于通用情况,即多个应用程序线程、多个后台 GC 线程运行于多 CPU 的系统上。如果操作系统无法在同时运行所有应用程序线程和 GC 后台线程,那么对 CPU 的竞争就会反映到应用程序线程的停顿上。

下表展示了这个取舍是如何工作的。计算股票数据的批量应用已经运行于特定的模式,它们会将结果集保持在内存中数分钟(目的是填满整个堆);测试分别使用了 CMS 和 Throughput 垃圾收集算法。

采用不同的GC算法进行批量处理的时间消耗:

垃圾收集算法4核CPU(CPU利用率)1核CPU(CPU利用率)
CMS78.09(30.7%)120.0(100%)
Throughput81.00(27.7%)111.6(100%)

表中的时间即是完成这个测试所花费的秒数,机器的 CPU 使用情况标记在括号中。存在 4 个可用 CPU 时,CMS 收集器运行批量操作比 Throughput 收集器要快大约 3 秒钟,但是请留意每个用例中 CPU 的使用情况。4 个 CPU 中有一个应用线程一直处于运行状态,因此应用线程使用了 4 个 CPU 的 25%。

表中其他额外的 CPU 消耗都源于 GC 线程。使用 CMS 收集器时,后台线程间歇性地消耗了一个 CPU,或者该机器上 25% 的 CPU 资源。这里后台线程是间歇性地运行的,结果表明,它消耗了大约 5% 的 CPU 时间,所以平均 CPU 使用率为 30%。

类似地,Throughput 收集器运行了 4 个 GC 线程。GC 周期中,这些线程占用了 100% 的可用 CPU 资源,在整个测试中,使用了大约 28% 的 CPU 周期。在 Minor GC 时,CMS 也运行了 4 个 GC 线程,占用了 100% 的可用 CPU。

只有一个 CPU 可用时,CPU 将一直处于忙碌状态,要么是运行应用程序线程,要么是运行 GC 线程。这种情况下,CMS 额外的后台线程就变成了一种负担,最终 Throughput 收集器提前 9 秒钟完成了运行任务。

平均 CPU 利用率和 GC

测试中如果只看平均 CPU 利用率可能会错过 GC 周期中的一些有趣的场景。Throughput 收集器运行时会(默认)100% 占用机器上所有的 CPU,因此测试中 CPU 使用情况更精准的表述应该如图所示。

CPU 使用率的实际值与平均值(使用 Throughput 收集器)

大多数情况下,如果只有应用程序线程运行,大概会消耗总 CPU 处理能力的 25%。一旦垃圾收集开始,CPU 就被 100% 占用。因此,即使测试中的平均值如图中水平虚线所示,实际的 CPU 使用情况更贴近于图中的锯齿形模式。

使用 Concurrent 收集器时,后台线程会与应用线程并行运行,表现出的效果也不大一样。这种情况下 CPU 的使用情况看起来如图下所示。

CPU 使用率的实际值与平均值(使用 CMS 收集器)

初始时应用线程使用了 25% 的 CPU 处理能力。到某个时刻线程产生足够的垃圾触发了 CMS 收集器的后台线程;垃圾回收的后台线程占用了另一颗 CPU,将 CPU 的使用率提高到 50%。CMS 线程的垃圾回收工作完成后,CPU 的使用率又回落到 25%,如此周而往复。注意,这个图中没有出现 CPU 使用 100% 的峰值时期,不过这是一种简化:CMS 新生代垃圾收集的过程中,实际可能有非常短的时段 CPU 使用率冲高到 100%,但是这些时段非常少,所以在这里的讨论中我们忽略了这部分时间。

Concurrent 收集器可以同时运行多个后台线程,其效果是类似的:这些后台线程运行时会消耗额外的 CPU 资源,进一步抬高了长期的 CPU 的平均使用率。

监控系统中定义的由 CPU 使用率触发的规则尤其重要:你需要确保 100% 的 CPU 使用率不是由 Full GC 所引起的临时性 CPU 暴涨,或者是由于后台并行处理线程所引起的持续时间更长(不过使用率稍低)的 CPU 高峰。在 Java 程序的世界里,这些峰值都是正常的状况。

小结

  1. 使用 Throughput 收集器处理应用程序线程的批量任务能最大程度地利用 CPU 的处理能力,通常能获得更好的性能。

  2. 如果批量任务并没有使用机器上所有可用的 CPU 资源,那么切换到 Concurrent 收集器往往能取得更好的性能。

GC算法和吞吐量测试

测试度量的目标是吞吐量时,选择 GC 算法的最基本的取舍跟批量任务一样,但是停顿所产生的影响却是大相径庭。CPU 仍然是影响总体性能非常重要的一环。

我们使用股票 servlet 程序作为测试的基准;servlet 程序运行在 GlashFish 服务器实例上,该 GlashFish 实例配有 2G 大小的堆,servlet 需要为每个用户的 HTTP 会话保存前 10 个 HTTP 请求(这个需求给系统的垃圾回收带来了更多的压力)。下表展示了分别使用 Throughput 收集器和 CMS 收集器,servlet 程序的吞吐量测试结果。测试运行的机器配置了 4 核的 CPU。

客户端会话数Throughput TPS(CPU使用率)CMS TPS(CPU使用率)
130.43(29%)31.98(31%)
1081.34(97%)60.20(85%)

我们运行了两组测试来度量总的吞吐量。第一组测试使用了 fhb 程序来模拟单一的客户端;第二组测试使用了 10 个客户端发起负荷,最终目标机器的 CPU 被撑满。

存在空闲 CPU 周期时,CMS 收集器的性能更好,比 Throughput 收集器的每秒处理的事务数(TPS)高出大约 5%。Throughput 收集器在测试中经历了 24 次 Full GC 的停顿(停顿时它无法继续处理请求);这些停顿约占测试稳定运行时间的 5%。通过避免这样的停顿,CMS 提供了更好的吞吐量。

然而,当 CPU 资源受限时,CMS 收集器的表现就差很多:比 Throughput 收集器的每秒吞吐量少大约 23.5%。注意,测试中,这时 CMS 收集器甚至无法让 CPU 以接近 100% 忙碌的程度运行。那是因为可用的 CPU 周期无法支撑后台的 CMS 收集线程运行,所以 CMS 收集器发生了并发模式失效(Concurrent Mode Failure)。发生这种失效意味着 JVM 不得不蜕化到单线程的 Full GC 模式,所以那段时间内(4 颗 CPU 的机器只有 25% 的 CPU 处于忙碌状态)平均 CPU 的使用率骤降。

GC算法及响应时间测试

下表表明请求之间的 Think Time 置为 250 毫秒时,使用同样的测试,负荷处理速度恒定在每秒 29 个事务。性能度量的标准是每个请求的平均响应时间,90% 的响应时间以及 99% 的响应时间。

使用不同GC算法的响应时间

第一个测试在用户会话状态中保持了 10 个请求。对比这两种收集器,得到的结果是比较典型的:Throughput 收集器在平均响应时间甚至是 90% 响应时间的指标上都比 Concurrent 收集器快。但是 CMS 在 99% 响应时间上显示了巨大的优势:Throughput 收集器在完成剩下 1% 的垃圾收集工作上消耗了更长的时间(Full GC 期间这部分操作被完全停止了)。CMS 收集器用大约 10% 的 CPU 处理能力提升了 99% 时的响应时间的结果。

会话数据中的请求数增大到 50 个,GC 周期的影响就愈发明显,尤其是使用 Throughput 收集器时。这时 Throughput 收集器的响应时间远远大于 CMS 收集器,这些都源于大量的超额负荷(outliers)将 99% 的响应时间不断拉长,有的甚至会超过 3 秒钟。不过还有一个有趣的现象,Throughput 收集器在 90% 响应时间的指标上低于 CMS 收集器——JVM 不进行 Full GC 的时候,Throughput 收集器依然持有优势。

这样的情况时不时会发生,但是其频率要远远低于第一种情况。某种程度上,上一个例子中,CMS 收集器是幸运的:通常是堆内保持了大量的活跃数据导致 Throughput 收集器进行 Full GC 的时间占用了大部分的响应时间,这种情况下使用 CMS 收集器也会发生并行模式失效(Concurrent Mode Failure)。而这个例子中,CMS 收集器的后台处理线程恰好能满足应用程序的需要。

以上这些是你在选择适合自身性能目标的垃圾回收算法时需要考虑的各种取舍。如果你关注的仅仅是平均响应时间,那么 Throughput 收集器和 Concurrent 收集器似乎差别不大,都能满足你的要求,你可以进一步考察 CPU 的使用情况(这时 Throughput 收集器可能是更优的选择)。如果你关注的是 90% 或者其他百分比的响应时间,那就只能通过性能测试来了解,完成这些任务应用程序会进行多少次 Full GC,最后决定选择哪种收集器。

GC算法选择小结

  1. 衡量标准是响应时间或吞吐量,在 Throughput 收集器和 Concurrent 收集器之间做选择的依据主要是有多少空闲 CPU 资源能用于运行后台的并发线程。

  2. 通常情况下,Throughput 收集器的平均响应时间比 Concurrent 收集器要差,但是在 90% 响应时间或者 99% 响应时间这几项指标上,Throughput 收集器比 Concurrent 收集器要好一些。

  3. 使用 Throughput 收集器会超负荷地进行大量 Full GC 时,切换到 Concurrent 收集器通常能获得更低的响应时间。

CMS收集器和G1收集器之间的抉择

上一节的测试使用 CMS 收集器作为 Concurrent 收集器。一般情况下,堆空间小于 4 GB 时,CMS 收集器的性能比 G1 收集器好。CMS 收集器使用的算法比 G1 更简单,因此在比较简单的环境中(譬如堆的容量很小的情况),它运行得更快。使用大型堆或巨型堆时,由于 G1 收集器可以分割工作,通常它比 CMS 收集器表现更好。

回收任何对象之前,CMS 收集器的后台线程必须扫描完整个老年代空间。显然,扫描完整个堆的时间与堆的大小密切相关。如果堆还未填满之前,CMS 收集器的后台线程就停止了堆的扫描,直接回收对象,CMS 收集器会发生并发模式失效(Concurrent Mode Failure):一旦发生这样的状况,CMS 收集器就不得不回退,暂停所有的应用线程,进行 Full GC 操作。这时处理 Full GC 的仅有唯一一个线程,性能的损耗非常严重。虽然通过调优 CMS 收集器,我们可以使用多个后台线程来减少变化带来的损失,不过随着堆的增大,CMS 后台线程需要处理的工作也越多。(CMS 收集器发生并发模式失效同时也会受应用程序的内存分配影响。)

G1 收集器采用了不同的方式来处理这个问题,它将老年代划分成不同的区域(Region),能更加容易地使用多个线程分担扫描老年代空间的任务。如果后台线程跟不上处理的速度,G1 收集器也会发生并发模式失效,但是 G1 算法已经使得发生这种状况的几率减小了很多。

由于 CMS 收集器不对堆进行压缩整理(除非发生了耗时的 Full GC),堆的碎片化也会触发 CMS 收集器进行 Full GC。G1 算法在处理过程中随时进行着堆的压缩整理,不过 G1 收集器依然可能遭遇堆的碎片化问题,但是与 CMS 收集器比较起来,它的设计让它又领先了一步。

调优 CMS 收集器和 G1 收集器避免发生这些失效的方法很多,但对一些应用程序来说却不一定奏效。随着堆的不断增大(发生 Full GC 的代价变得更加昂贵),使用 G1 收集器更易于避免这些问题的发生。(另一方面,对有的程序,试图通过调优这两种收集器的任何一种避免发生并发模式失效几乎是不可能的任务。因此,即使应用程序的性能目标似乎与 Concurrent 收集器保持一致,使用 Throughput 收集器却可能是更明智的选择)。

最后,在这三种收集器的选择时还有一些微妙的无形因素需要考虑。Throughput 收集器是这三个收集器中年代最久远的一个,这意味着 JVM 工程师们已经花费了大量的时间精力雕琢把玩它,它的习性也更为大家所熟知。G1 作为相对较新的一种垃圾收集算法,更容易碰到设计时无法预期的极端情况。相对而言,G1 算法中影响性能的调优控制开关更少,这可能是好事,也可能是坏事。直到 Java 7u4,G1 都一直被当作实验版本,它的一些调优特性直到 Java 7u10 中才提供出来。相对于 Java 7 及之前的版本而言,G1 的性能提升主要体现在 Java 8 中。G1 将来的工作可能会关注在如何提高它在较小的堆上相对于 CMS 的性能优势。

小结

  1. 选择 Concurrent 收集器时,如果堆较小,推荐使用 CMS 收集器。

  2. G1 的设计使得它能够在不同的分区(Region)处理堆,因此它的扩展性更好,比 CMS 更易于处理超大堆的情况。

GC调优基础

虽然处理堆时各种 GC 算法有所差异,但是它们的基本配置参数是一致的。很多情况下,我们只需要这些基础的配置就能运行应用程序。

调整堆的大小

GC 调整的第一堂课是调整应用程序堆的大小。关于堆大小的调整还有更高级的话题,不过作为第一步,我们首先讨论如何设置总体堆的大小。

与其他的性能问题一样,选择堆的大小其实是一种平衡。如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上,没有足够的时间去运行应用程序的逻辑。但是,简单粗暴地设置一个特别大的堆也不是解决问题的方法。GC 停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。

使用超大堆还有另一个风险。操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有 8 G 的物理内存,不过操作系统可能让你感觉有更多的可用内存。虚拟内存的数量取决于操作系统的设置,譬如操作系统可能让你感觉它的内存达到了 16 G。操作系统通过名为“交换”(swapping)(或者称之为分页,虽然这两者之间在技术上存在着差异,但是这些差异在这里不影响我们的讨论)。你可以载入需要 16 G 内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。

系统中运行着大量不同的应用程序时,这个流程工作得很顺畅,因为大多数的应用程序不会同时处于活跃状态。但是,对于 Java 应用,它工作得并不那么好。如果一个 Java 应用使用了这个系统上大约 12 G 的堆,操作系统可能在 RAM 上分配了 8 G 的堆空间,另外 4 G 的空间存在于磁盘(这个假设对实际情况进行了一些简化,因为应用程序也会使用部分的 RAM)。JVM 不会了解这些:操作系统完全屏蔽了内存交换的细节。这样, JVM 愉快地填满了分配给它的 12 G 堆空间。但这样就导致了严重的性能问题,因为操作系统需要将相当一部分的数据由磁盘交换到内存(这是一个昂贵操作的开始)。

更糟糕的是,这种原本期望一次性的内存交换操作在 Full GC 时一定会再次重演,因为 JVM 必须访问整个堆的内容。如果 Full GC 时系统发生内存交换,停顿时间会以正常停顿时间数个量级的方式增长。类似地,如果使用 Concurrent 收集器,后台线程在回收堆时,它的速度也可能会被拖慢,因为需要等待从磁盘复制数据到内存,结果导致发生代价昂贵的并发模式失效。

因此,调整堆大小时首要的原则就是永远不要将堆的容量设置得比机器的物理内存还大,另外,如果同一台机器上运行着多个 JVM 实例,这个原则适用于所有堆的总和。除此之外,你还需要为 JVM 自身以及机器上其他的应用程序预留一部分的内存空间:通常情况下,对于普通的操作系统,应该预留至少 1 G 的内存空间。

堆的大小由 2 个参数值控制:分别是初始值(通过 -Xms N 设置)和最大值(通过 -Xmx N 设置)。默认值的调节取决于多个因素,包括操作系统类型、系统内存大小、使用的 JVM。其他的命令行标志也会对该值造成影响;堆大小的调节是 JVM 自适应调优的核心。

JVM 的目标是依据系统可用的资源情况找到一个“合理的”默认初始值,当且仅当应用程序需要更多的内存(依据垃圾回收时消耗的时间来决定)时将堆的大小增大到一个合理的最大值。到目前为止,JVM 的高级调优标志以及调优细节都没有提及。为了让大家有一个感性的认识,我们列出了堆大小的默认最大值和最小值供大家参考,参见下表。(为了使内存对齐,JVM 会对这些值进行圆整操作;所以 GC 日志中输出的大小可能与表中给出的值并不完全一致)。

操作系统及JVM类型初始堆的大小(Xms)最大堆的大小(Xmx)
Linux/Solaris,32 位客户端16 MB256 MB
Linux/Soaris,32 位服务器64 MB取 1 GB 和物理内存大小 1/4 二者中的最小值
Linux/Soaris,64 位服务器取 512 MB 和物理内存大小 1/64 二者中的最小值取 32 GB 和物理内存大小 1/4 二者中的最小值
MacOS,64 位服务器型 JVM64 MB取 1 GB 和物理内存大小 1/4 二者中的最小值
32 位 Window 系统,客户端型 JVM16 MB256 MB
64 位 Window 系统,服务器型 JVM64 MB1 GB 和物理内存大小 1/4 二者中的最小值

如果机器的物理内存少于 192 MB,最大堆的大小会是物理内存的一半(大约 96 MB,或者更少)。

堆的大小具有初始值和最大值的这种设计让 JVM 能够根据实际的负荷情况更灵活地调整 JVM 的行为。如果 JVM 发现使用初始的堆大小,频繁地发生 GC,它就会尝试增大堆的空间,直到 JVM 的 GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。

对很多应用来说,这意味着堆的大小不再需要调整了。实际上,你只需要为你选择的 GC 算法设定性能目标:譬如你能忍受的停顿持续时间、你期望垃圾回收在整个时间中所占用的百分比等。具体的细节设置取决于你选择的垃圾收集算法,在接下来的章节我们会进行深入的讨论(然而,即使到了那个时候,为了能尽可能地适用于更多的应用程序,减少调整的代价,仍然可能使用默认值)。

通常,如果应用程序运行需要的堆不会使用超过运行平台默认的最大值,这个方法就工作得非常好。然而,如果应用程序在 GC 时消耗了太长的时间,你很有可能需要使用 -Xmx 标志增大堆的大小。选择什么样的大小没有一个硬性的或简单的规则(不过你需要确保设置的大小是机器可以支持的)。一个经验法则是完成 Full GC 后,应该释放出 70% 的空间(30% 的空间仍然占用)。为了衡量这个结果,你可以持续运行应用程序,直到其到达稳定态配置:这时它已经载入了需要缓存的所有对象,或者已经创建了最多的客户端连接数,诸如此类。之后,使用 jconsole 连接应用程序,强制进行 Full GC,观察 Full GC 结束后 还有多少内存被占用(此外,对于 Throughput 垃圾收集器,如果有日志的话,你可以通过查询 GC 日志得到对应的数据)。

注意,即使你显式地设置了堆的最大容量,还是会发生堆的自动调节:初始