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 日志得到对应的数据)。

注意,即使你显式地设置了堆的最大容量,还是会发生堆的自动调节:初始时堆以默认的大小开始运行,为了达到根据垃圾收集算法设置的性能目标,JVM 会逐步增大堆的大小。将堆的大小设置得比实际需要更大不一定会带来性能损耗:堆并不会无限地增大,JVM 会调节堆的大小直到其满足 GC 的性能目标。

另一方面,如果你确切地了解应用程序需要多大的堆,那么你可以将堆的初始值和最大值直接设置成对应的数值(譬如:-Xms4096m -Xmx4096m)。这种设置能稍微提高 GC 的运行效率,因为它不再需要估算堆是否需要调整大小了。

堆大小调整小结

  1. JVM 会根据其运行的机器,尝试估算合适的最大、最小堆的大小。

  2. 除非应用程序需要比默认值更大的堆,否则在进行调优时,尽量考虑通过调整 GC 算法的性能目标(具体内容在下一章介绍),而非微调堆的大小来改善程序性能。

代空间的调整

一旦堆的大小确定下来,你(或者 JVM)就需要决定分配多少堆给新生代空间,多少给老年代空间。我们应该清楚地了解代的划分对性能的影响:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。任何事物都有两面性,采用这种分配方法,老年代就相对比较小,比较容易被填满,会更频繁地触发 Full GC。这里找到一个恰当的平衡点是解决问题的关键。

不同的 GC 算法尝试使用不同的方法来解决这些平衡问题。虽然方法不同,不过所有的 GC 方法都使用了同一套标志来设置代的大小;这一节会详细介绍这些通用的标志。

所有用于调整代空间的命令行标志调整的都是新生代空间;新生代空间剩下的所有空间都被老年代占用。多个标志都能用于新生代空间的调整,它们分别如下所列。

-XX:NewRatio=N设置新生代与老年代的空间占用比率。

-XX:NewSize=N设置新生代空间的初始大小。

-XX:MaxNewSize=N设置新生代空间的最大大小。

-XmnN将 NewSize 和 MaxNewSize 设定为同一个值的快捷方法。

最初新生代空间大小是由 NewRatio 指定大小,NewRatio 的默认值为 2。影响堆空间大小的参数通常以比率的方式指定;这个值被用于一个计算空间分配的公式之中。下面是使用 NewRatio 计算空间的公式:

Initial Young Gen Size = Initial Heap Size / (1 + NewRatio)

代入堆的初始大小和 NewRatio 的值就能得到新生代的设置值。那么我们很容易得出,默认情况下,新生代空间的大小是初始堆大小的 33%。

除此之外,新生代的大小也可以通过 NewSize 标志显式地设定。使用 NewSize 标志设定的新生代大小,其优先级要高于通过 NewRatio 计算出来的新生代大小。NewSize 标志没有默认的设置(虽然使用 Printflagsfinal 标志输出的值为 1 MB)。NewSize 不设置的情况下,初始的新生代大小由 NewRatio 计算出的值决定。

如果堆的大小扩张,新生代的大小也会随之增大,直到由 MaxNewSize 标志设定的最大容量。默认情况下,新生代的最大值也是由 NewRatio 的值设定的,不过它也同时受制于堆的最大容量(注意,不是初始大小)。

试图通过指定新生代的最大及最小值区间的方式调优新生代的结果是十分困难的。如果堆的大小是固定的(可以通过将 -Xms 和 -Xmx 指定为相等的值实现),通常推荐使用 -Xmn 标志将新生代也设定为固定大小。如果应用程序需要动态调整堆的大小,并希望有一个更大(或者更小)的新生代,那就需要关注 NewRatio 值的设定。

代空间调整小结

  1. 整个堆范围内,不同代的大小划分是由新生代所占用的空间控制的。

  2. 新生代的大小会随着整个堆大小的增大而增长,但这也是随着整个堆的空间比率波动变化的(依据新生代的初始值和最大值)。

永久代和元空间的调整

JVM 载入类的时候,它需要记录这些类的元数据。从终端用户的角度来看,这些只是一些“书签”信息。这部分数据被保存在一个单独的堆空间中。在 Java 7 里,这部分空间被称为永久代(Permgen 或 Permanent Generation),在 Java 8 中,它们被称为元空间(Metaspace)

不过永久代和元空间并不完全一样。Java 7 中,永久代还保存了一些与类数据无关的杂项对象(miscellaneous object);这些对象在 Java 8 中被挪到了普通的堆空间内。除此之外,Java 8 还从根本上改变了保存在这个特殊区域内的元数据的类型——不过由于普通用户不需要了解这个区域内保持了什么样的数据,所以这些改变不会对我们造成什么影响。作为终端用户,我们需要知道的仅仅是永久代级元空间内保存了大量与类相关的数据,有些时候我们可能会需要调整这部分空间的大小。

注意永久代或者元空间内并没有保存类实例的具体信息(即类对象),也没有反射对象(譬如方法对象);这些内容都保存在常规的堆空间内。永久代和元空间内保存的信息只对编译器或者 JVM 的运行时有用,这部分信息被称为“类的元数据”。

到目前为止都没有一个能提前计算出程序的永久代 / 元空间需要多大空间的好算法。永久代或者元空间的大小与程序使用的类的数量成比率相关,应用程序越复杂,使用的对象越多,永久代或者元空间就越大。使用元空间替换掉永久代的优势之一是我们不再需要对其进行调整——因为(不像永久代)元空间默认使用尽可能多的空间。表中列出了永久代和元空间的初始值及最大值。

JVM类型默认的初始大小默认永久代大小的最大值默认元空间大小的最大值
32 位客户端型 JVM12 MB64 MB没有限制
32 位服务器型 JVM16 MB64 MB没有限制
64 位 JVM20.75 MB82 MB没有限制

这些内存区域的行为就像是分隔开的普通堆空间。它们会根据初始的大小动态地调整,需要的时候会增大到最大的堆空间。对于永久代而言,可以通过 -XX:PermSize=N、-XX:MaxPermSize=N 标志调整大小。而元空间的大小可以通过 -XX:MetaspaceSize=N 和 -XX:MaxMetaspaceSize=N 调整。

元空间会过大吗?

由于元空间默认的大小是没有作限制的,因此 Java 8(尤其是 32 位系统)的应用可能由于元空间被填满而耗尽内存。后面将介绍的工具本地内存跟踪器(Native Memory Tracking,NMT)可以帮助诊断这种类型的问题。如果元空间增长得过大,通过设置 MaxMetaspaceSize 你可以调整元空间的上限,将其限制为一个更小的值,不过这又会导致应用程序最后由于元空间耗尽,发生 OutOfMemoryError 异常。解决这类问题的终极方法还是定位出为什么类的元空间会变得如此巨大。

调整这些区间会触发 Full GC,所以是一种代价昂贵的操作。如果程序在启动时发生大量的 Full GC(因为需要载入数量巨大的类),通常都是由于永久代或者元空间发生了大小调整,因此这种情况下为了改善启动速度,增大初始值是个不错的主意。对于定义了大量类的 Java 7 应用,同时还需要增大永久代空间的最大值。譬如,通常情况下应用服务器永久代的最大值会设置为 128 MB、192 MB 或者更多。

虽然名称叫“永久代”,保存在永久代空间中的数据并不能永久保存(元空间这个名字可能更准确)。尤其是,保存在其中的类像其他的对象一样会经历垃圾回收。在应用服务器中,这是一种非常普遍的现象,每次有新的应用部署,应用服务器都会创建新的类加载器

(classloader)。之后老的类加载器就不再被引用,像它定义的任何一个类一样,等待 GC 的 回收。应用服务器漫长的运行周期中,很容易发现部署中触发的 Full GC:永久代或元空间被新的类所充斥填满,老的类的元数据等待被回收。

堆转储的信息可以用于诊断存在哪些类加载器,而这些信息反过来可以帮助确定是否存在类加载器的泄漏,最终导致永久代(或者元空间)被耗尽。除此之外,使用 jmap 和 -permstat 参数(适用于 Java 7)、或者 -clstats 参数(适用于 Java 8)可以输出类加载器相关的信息。不过这些命令都不是非常稳定,所以不大推荐使用。

元空间调整小结

  1. 永久代或元空间保存着类的元数据(并非类本体的数据)。它以分离的堆的形式存在。

  2. 典型应用程序在启动后不需要载入新的类,这个区域的初始值可以依据所有类都加载后的情况设置。使用优化的初始值能够加速启动的过程。

  3. 开发中的应用服务器(或者任何需要频繁重新载入类的环境)上经常能碰到由于永久代或元空间耗尽触发的 Full GC,这时老的元数据会被丢弃回收。

控制并发

除 Serial 收集器之外几乎所有的垃圾收集器使用的算法都基于多线程。启动的线程数由 -XX:ParallelGCThreads=N 参数控制。对下面这些操作,这个参数值会影响线程的数目:

使用 -XX:+UseParallelGC 收集新生代空间

使用 -XX:+UseParallelOldGC 收集老年代空间

使用 -XX:+UseParNewGC 收集新生代空间

使用 -XX:+UseG1GC 收集新生代空间

CMS 收集器的“时空停顿”阶段(但并非 Full GC)

G1 收集器的“时空停顿”阶段(但并非 Full GC)

由于 GC 操作会暂停所有的应用程序线程,JVM 为了尽量缩短停顿时间就必须尽可能地利用更多的 CPU 资源。这意味着,默认情况下,JVM 会在机器的每个 CPU 上运行一个线程,最多同时运行 8 个。一旦达到这个上限,JVM 会调整算法,每超出 5/8 个 CPU 启动一个新的线程。所以总的线程数就是(这里的 N 代表 CPU 的数目):

ParallelGCThreads = 8 + ((N - 8) * 5 / 8)

有时候使用这个算法估算出来的线程数目会偏大。如果应用程序使用一个较小的堆(譬如大小为 1 GB)运行在一个八颗 CPU 的机器上,使用 4 个线程或者 6 个线程处理这个堆可能会更高效。在一个 128 颗 CPU 的机器上,启动 83 个垃圾收集线程可能也太多了,除非系统使用的堆已经达到了最大上限。

除此之外,如果机器上同时运行了多个 JVM 实例,限制所有 JVM 使用的线程总数是个不错的主意。这时,垃圾收集线程运行起来会更加高效,每个线程都能 100% 地利用各 CPU 的资源(这就是前面的例子中 Throughput 收集器的平均 CPU 使用率比预期值更高的原因)。在 8 核或者 CPU 更少的机器上,垃圾收集线程会 100% 地占用机器的 CPU 处理资源。在拥有更多 CPU、运行了多个 JVM 的机器上,通常出现的问题是有太多的垃圾回收线程在同时并发运行。

以 16 核 CPU 的机器同时运行 4 个 JVM 实例为例,每个 JVM 默认会启动 13 个垃圾收集线程。如果四个 JVM 同时进行垃圾回收操作,机器上会启动大约 52 个 CPU 密集型线程竞争 CPU 资源。这会导致大量的冲突,如果能够限制每个 JVM 最多启动 4 个垃圾收集线程,效率会高很多。即使在同一个时刻,4 个 JVM 中的线程不大可能同时进行 GC 操作,一个 JVM 上同时运行 13 个线程也意味着其他 JVM 上的应用程序线程不得不在一台总共有 16 个 CPU,且其中 13 个 CPU 被繁忙的垃圾收集任务 100% 占用的机器上竞争资源。这种情况下,将每个 JVM 的垃圾收集线程数限制到 4 个是一个比较合理的平衡。注意,这个标志不会对 CMS 收集器或者 G1 收集器的后台线程数作设定(虽然它们也会受设置的影响)。关于其中的细节,我们会在下一章中介绍。

并发控制小结

  1. 几乎所有的垃圾收集算法中基本的垃圾回收线程数都依据机器上的 CPU 数目计算得出。

  2. 多个 JVM 运行于同一台物理机上时,依据公式计算出的线程数可能过高,必须进行优化(减少)。

自适应调整

根据调优的策略,JVM 会不断地尝试,寻找优化性能的机会,所以在 JVM 的运行过程中,堆、代以及 Survivor 空间的大小都可能会发生变化。

这是一种尽力而为(Best-Effort)的方案,它进行性能调优的依据是以往的性能历史:这其中隐含了一个假设,即将来 GC 周期的状况跟最近历史 GC 周期的状况可能很类似。事实证明,在多种负荷下这一假设都是合理的,即使某个时刻内存的分配发生突变的情况,JVM 也能够依据最新的情况重新调整它的大小。

自适应调整在两个方面能提供重要的帮助。其一,这意味着小型应用程序不需要再为指定了过大的堆而担心。譬如用于调整应用服务器的命令行管理程序,这类型的程序通常使用 16 MB(或者 64 MB)的堆,即使默认的堆可能增长到 1 GB 那么大的容量。有了自适应调整之后,这种类型的应用程序不再需要额外花费精力去调优,平台默认的配置就能确保他们不会使用大量的内存。

其次,这意味着很多应用程序根本不需要担心它们堆的大小,如果需要使用的堆的大小超过了平台的默认值,他们可以放心地分配更大的堆,而不用关心其他的细节。JVM 会自动调整堆和代的大小,依据垃圾回收算法的性能目标,使用优化的内存量。自适应调整就是让自动调整生效的法宝。

不过,空间大小的调整终归要花费一定的时间开销,这部分时间大多数消耗在 GC 停顿的时候。如果你投注了大量的时间精细地调优了垃圾回收的参数、定义了应用程序堆的大小限制,可以考虑关闭自适应调整。如果应用程序的运行明显地可以划分成不同的阶段,你希望对这些阶段中的某一个阶段进行垃圾回收的优化,那么关闭自适应调优也是很有帮助的。

使用 -XX:-UseAdaptiveSizePolicy 标志可以在全局范围内关闭自适应调整功能(默认情况下,这个标志是开启的)。如果堆容量的最大、最小值设置成同样的值,与此同时新生代的初始值和最大值也设置为同样大小,自适应调整的功能会被关闭。不过这时的 Survivor 空间是个例外,我们在下一章中会详细介绍其中的细节。

如果你想了解应用程序运行时 JVM 的空间是如何调整的,可以设置 -XX:+PrintAdaptiveSizePolicy 标志。开启该标志后,一旦发生垃圾回收,GC 的日志中会包含垃圾回收时不同的代进行空间调整的细节信息。

自适应调整小结

  1. JVM 在堆的内部如何调整新生代及老年代的百分比是由自适应调整机制控制的。

  2. 通常情况下,我们应该开启自适应调整,因为垃圾回收算法依赖于调整后的代的大小来达到它停顿时间的性能目标。

  3. 对于已经精细调优过的堆,关闭自适应调整能获得一定的性能提升。

垃圾回收工具

由于垃圾回收对 Java 的性能影响至关重要,业界提供了大量的工具用于监控它的性能。

观察垃圾回收对应用程序的性能影响最好的方法就是尽量熟悉垃圾回收的日志,垃圾回收日志中包含了程序运行过程中的每一次垃圾回收操作。

垃圾回收日志的细节依据使用的垃圾回收算法各有不同,不过垃圾回收日志的基本结构(management)是一致的。这一节我们会介绍这些结构,与具体垃圾回收算法相关的更多日志细节会在下一章中介绍。

多种方法都能开启 GC 的日志功能,其中包括:使用 -verbose:gc 或 -XX:+PrintGC 这两个标志中的任意一个能创建基本的 GC 日志(这两个日志标志实际上互为别名,默认情况下的 GC 日志功能是关闭的)。使用 -XX:+PrintGCDetails 标志会创建更详细的 GC 日志。我们推荐使用 -XX:+PrintGCDetails 标志(这个标志默认情况下也是关闭的);通常情况下使用基本的 GC 日志很难诊断垃圾回收时发生的问题。除了使用详细的 GC 日志,我们还推荐使用 -XX:+PrintGCTimeStamps 或者 -XX:+PrintGCDateStamps,便于我们更精确地判断几次 GC 操作之间的时间。这两个参数之间的差别在于时间戳是相对于 0(依据 JVM 启动的时间)的值,而日期戳(date stamp)是实际的日期字符串。由于日期戳需要进行格式化,所以它的效率可能会受轻微的影响,不过这种操作并不频繁,它造成的影响也很难被我们感知。

默认情况下 GC 日志直接输出到标准输出,不过使用 -Xloggc:filename 标志也能修改输出到某个文件。除非显式地使用 -PrintGCDetails 标志,否则使用 -Xloggc 会自动地开启基本日志模式。使用日志循环(Log rotation)标志可以限制保存在 GC 日志中的数据量;对于需要长时间运行的服务器而言,这是一个非常有用的标志,否则累积几个月的数据很可能会耗尽服务器的磁盘。通过 -XX:+UseGCLogfileRotation -XX:NumberOfGCLogfiles=N -XX:GCLogfileSize=N 标志可以控制日志文件的循环。默认情况下,UseGCLogfileRotation 标志是关闭的。开启 UseGCLogfileRotation 标志后,默认的文件数目是 0(意味着不作任何限制),默认的日志文件大小是 0(同样也是不作任何限制)。因此,为了让日志循环功能真正生效,我们必须为所有这些标志设定值。需要注意的是,如果设定的数值不足 8 KB 的话,日志文件的大小会以 8 KB 为单位规整。

根据需要,你可以手工地解析、研读垃圾回收日志,也可以利用一些工具来完成这部分工作。GC Histogram(http://java.net/projects/gchisto)就是这些工具中的一员。GC Histogram 能够读入 GC 日志,根据日志文件中的数据生成对应的图表和表格。图 5-4 是由 GC Histogram 生成的 GC 开销的概略表。

GC Histogram 的停顿状态选项卡

在这个例子中,JVM 消耗了 41% 的时间进行垃圾回收,完成一次 Full GC 的时间长达 7 秒钟。很明显,这个应用程序需要调优它的内存使用。

使用 jconsole 可以实时监控堆的使用情况。jconsole 的内存面板可以实时查看堆的使用状况,如图所示。

查看堆的实时状况

从这幅视图我们能看到整个堆的使用情况,它在介于 100 MB 到 160 MB 的区间内周期性地循环。使用 jconsole 一次只能看到一个分区的使用情况:要么是 Eden 空间,要么是 Survivor 空间,要么是老年代,或者是永久代。如果选择 Eden 空间作为绘制图表的区域,能看到 Eden 空间以相似的模式在 0 MB 到 60 MB 之间波动(并且,跟你的猜测一样,如果依据老年代的数据作图,那它基本将是一条横在 100 MB 的水平线)。

如果你希望使用脚本的方式获取数据,jstat 是理想的工具。jstat 提供了 9 个选项,提供堆的各种数据;使用 jstat -options 选项能够列出所有这些选项。这其中最常用的一个选项是 -gcutil,它能够输出消耗在 GC 上的时间,以及每个 GC 区域使用的百分比。其他的选项能够以 KB 为单位输出各 GC 空间的大小。

注意,jstat 接受一个可选的参数,指定每隔多少毫秒重复执行这个命令,这个选项帮助我们长时间地监控应用程序的垃圾回收过程。下面是一个示例的输出,它以每隔一秒钟的频率运行。

% jstat -gcutil process_id 1000
  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT    GCT
 51.71   0.00  99.12  60.00  99.93     98    1.985     8    2.397   4.382
  0.00  42.08   5.55  60.98  99.93     99    2.016     8    2.397   4.413
  0.00  42.08   6.32  60.98  99.93     99    2.016     8    2.397   4.413
  0.00  42.08  68.06  60.98  99.93     99    2.016     8    2.397   4.413
  0.00  42.08  82.27  60.98  99.93     99    2.016     8    2.397   4.413
  0.00  42.08  96.67  60.98  99.93     99    2.016     8    2.397   4.413
  0.00  42.08  99.30  60.98  99.93     99    2.016     8    2.397   4.413
 44.54   0.00   1.38  60.98  99.93    100    2.042     8    2.397   4.439
 44.54   0.00   1.91  60.98  99.93    100    2.042     8    2.397   4.439

在这个例子中,监控开始时,程序已经在新生代(YGC)中进行了 99 次垃圾回收操作,这总共消耗了大约 1.985 秒的时间(YGCT)。于此同时,它还完成了 8 次 Full GC(FGC),消耗了 2.397 秒的时间(FGCT);因此 GC 消耗的总时长(GCT)为 4.382 秒。

新生代中三个区间的数据都在这里列出:两个 Survivor 空间(分别是 S0 和 S1)以及 1 个 Eden 空间(标记为 E)。监控开始时,Eden 空间几乎要被填满了(已经占用了 99.12% 的空间),因此下一秒就有一次新生代的垃圾回收:这之后 Eden 空间的使用率回落到 5.55%,Survivor 空间发生了交换,一部分内存对象被晋升到了老年代空间(标记为 O),老年代的空间使用率增长到 60.98%。跟典型的场景一样,我们没有在永久代(标记为 P)发现大幅度的变化,因为几乎所有需要的类都已经在程序启动时载入内存。

如果你不记得如何开启 GC 日志,这是一个很好的替代方法,它能帮助我们观察垃圾回收是如何在较长的时间跨度内工作的。

垃圾回收工具小结

  1. GC 日志是分析 GC 相关问题的重要线索;我们应该开启 GC 日志标志(即使是在生产服务器上)。

  2. 使用 PrintGCDetails 标志能获得更详尽的 GC 日志信息。

  3. 使用工具能很有效地帮助我们解析和理解 GC 日志的内容,尤其是在对 GC 日志中的数据进行归纳汇总时,它们非常有帮助。

  4. 使用 jstat 能动态地观察运行程序的垃圾回收操作。

小结

任何一个 Java 应用程序而言,垃圾收集的性能都是其构成整体性能的关键一环。虽然对大多数的应用程序来说,调优的工作仅仅是选择合适的垃圾收集算法,或者在需要的时候,增大应用程序的堆空间。

自适应调整让 JVM 能够自动地调整它的行为,使用给定的堆,提供尽可能好的性能。

更复杂的应用往往需要额外的调优,尤其是针对特定 GC 算法的调优。如果这一章中简单的 GC 设置并没有解决应用程序的性能问题,请参考下一章中介绍的调优内容。

更新时间:2020-03-09 15:42:04

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

评论

Your browser is out of date!

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

×