Java性能调优08——垃圾回收优化(中)

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

上一章介绍了垃圾收集器的通用行为,包括普遍适用于所有垃圾回收算法的 JVM 调优标志:如何选择堆的大小,如何选择代的大小,如何开启和设置 GC 日志,等等。

这些基础的调优标志已经足以应付大多数的场景。当它们无法解决问题时,往往需要查看使用的 GC 算法中具体是哪些操作影响了性能,进一步判断如何调整对应的参数,从而最大程度地减少 GC 操作对应用程序性能的影响。

调优特定收集器最要紧的信息是启动垃圾收集器后 GC 日志中的数据。从本章开始,我们会从 GC 的日志输出角度详细分析每种垃圾收集算法的行为;分析 GC 日志能帮助我们更好地理解垃圾收集算法是如何工作的,以及怎样调节参数能让它们工作得更好。

还有一些其他的因素也会影响几乎所有垃圾回收算法的性能,包括分配巨型对象、对象的生命周期既不长又不短,等等。我们会在本章的末尾讨论这些场景。

理解Throughput收集器

我们会逐一分析各个垃圾收集器的行为,首先会介绍 Throughput 收集器。Throughput 收集器有两个基本的操作:其一是回收新生代的垃圾,其二是回收老年代的垃圾。

Throughput 垃圾回收中的新生代

通常新生代的垃圾回收发生在 Eden 空间快用尽时。新生代垃圾收集会把 Eden 空间中的所有对象挪走:一部分对象会被移动到 Survivor 空间(即这幅图中的 S0 区域),其他的会被移动到老年代;正如你看到的,回收之后老年代中保存了更多的对象。当然,还有大量的对象因为没有任何对象引用而被回收。

开启了 PrintGCDetails 标志的 GC 日志中,Minor GC 形式如下:

17.806: [GC [PSYoungGen: 227983K->14463K(264128K)]
             280122K->66610K(613696K), 0.0169320 secs]
             [Times: user=0.05 sys=0.00, real=0.02 secs]

这次 GC 在程序开始运行 17.806 秒后发生。现在新生代中对象占用的空间为 14 463 KB(约为 14 MB,位于 Survivor 空间内);GC 之前,新生代对象占用的空间为 227 983 KB(约为 227 MB)。(实际上,227 893 KB 严格折算只有 222 MB,为了便于讨论,本章中以 1000 为单位将它们折算到 KB。这里假设我是磁盘生产商。)新生代这时总的大小为 264 MB。

与此同时,堆的空间总的使用情况(包含新生代和老年代)从 280 MB 减少到了 66 MB,这个时刻整个堆的大小为 613 MB。完成垃圾回收操作耗时 0.02 秒(排在输出最后的 Real 时间是 0.0 169 320 秒——实际时间进行了归整)。程序消耗的 CPU 时间比 Real 时间往往更多,原因是新生代垃圾回收会使用多个线程(这个例子中,使用了 4 个线程)。

使用 Throughput 收集器的 Full GC

老年代垃圾收集会回收新生代中所有的对象(包括 Survivor 空间中的对象)。只有那些有活跃引用的对象,或者已经经过压缩整理的对象(它们占据了老年代的开始部分)会在老年代中继续保持,其余的对象都会被回收。

Full GC 的日志输出示例如下:

64.546: [Full GC [PSYoungGen: 15808K->0K(339456K)]
          [ParOldGen: 457753K->392528K(554432K)] 473561K->392528K(893888K)
          [PSPermGen: 56728K->56728K(115392K)], 1.3367080 secs]
          [Times: user=4.44 sys=0.01, real=1.34 secs]

新生代的空间使用在经历 Full GC 之后变为 0 字节(新生代的大小为 339 MB)。老年代中的空间使用从 457 MB 减少到了 392 MB,因此整个堆的使用从 473 MB 减少到了 392 MB。永久代空间的使用没有发生变化;在多数的 Full GC 中,永久代的对象都不会被回收。(如果永久代空间耗尽,JVM 会发起 Full GC 回收永久代中的对象,这时你会观察到永久代空间的变化——这是永久代进行回收唯一的情况。这个例子使用的是 Java 7;在 Java 8 中,类似的信息可以在元空间中找到)。由于 Full GC 要进行大量的工作,所以消耗了约 1.3 秒的 Real 时间,4.4 秒的 CPU 时间(同样源于使用了 4 个并行的线程)。

Throughput收集器基础小结

  1. Throughput 收集器会进行两种操作,分别是 Minor GC 和 Full GC。

  2. 通过 GC 日志中的时间输出,我们可以迅速地判断出 Throughput 收集器的 GC 操作对应用程序总体性能的影响。

堆大小的自适应调整和静态调整

Throughput 收集器的调优几乎都是围绕停顿时间进行的,寻求堆的总体大小、新生代的大小以及老年代大小之间平衡。

考虑 Throughput 收集器的调优方案时有两种取舍。首先比较经典的是编程技术上的取舍,即时间与空间的取舍。

第二个取舍与完成垃圾回收所需的时长相关。增大堆能够减少 Full GC 停顿发生的频率,但也有其局限性:由于 GC 时间变得更长,平均响应时间也会变长。类似地,为新生代分配更多的堆空间可以缩短 Full GC 的停顿时间,不过这又会增大老年代垃圾回收的频率(因为老年代空间保持不变或者变得更小了)。

如图展示了采用这些取舍的效果。图上显示的是运行在 GlassFish 实例上的股票 Servlet 应用,在使用不同大小的堆时,最大吞吐量的变化情况。使用 256 MB 的小堆时,应用服务器在垃圾回收上消耗了大量的时间(实际消耗的时间高达总时间的 36%);吞吐量因此受到限制,比较低。随着堆大小的增加,吞吐量迅速提升——直到堆的容量增大到 1500 MB。这之后吞吐量的增速迅速减缓,这时应用程序实际已经不太受垃圾回收的影响(垃圾回收消耗的时间仅仅只占总时间的 6% 左右)。收益递减规律逐渐凸显出来:虽然应用程序可以通过增加内存的方式提升吞吐量,不过其效果已经很有限了。

堆的大小达到 4500 MB 后,吞吐量开始出现少量下滑。这时,应用程序面临着第二个选择:增加的内存导致 GC 周期愈加冗长,虽然它们发生的频率小得多,但这些超长的 GC 周期也会影响系统整体的吞吐量。

这幅图中的数据取自关闭了自适应调整的 JVM;它的最大、最小堆的容量设置成了同样的大小。对任何一种应用,我们都可以通过实验确定堆和代的最佳大小,但是,让 JVM 自己来选择通常是更容易的方法(这也是最通常的做法,因为默认情况下自适应调整就是开启的)。

使用不同大小的堆时吞吐量的变化

为了达到停顿时间的指标,Throughput 收集器的自适应调整会重新分配堆(以及代)的大小。使用这些标志可以设置相应的性能指标:-XX:MaxGCPauseMillis=N 和 -XX:GCTimeRatio=N。

MaxGCPauseMillis 标志用于设定应用可承受的最大停顿时间。我们可以将其设置为 0 或者一些非常小的值,譬如 50 毫秒。请注意,这个标志设定的值同时影响 Minor GC 和 Full GC。如果设置的值非常小,那么应用的老年代最终就会非常小:譬如,你设定该参数希望应用在 50 毫秒内完成垃圾回收,这将会触发非常频繁的 Full GC,对应用程序的性能而言将是灾难性的。因此,设定该值时,请尽量保持理性,将该值设定为可达到的合理值。缺省情况下,我们不设定该参数。

GCTimeRatio 标志可以设置你希望应用程序在垃圾回收上花费多少时间(与应用线程的运行时间相比较)。它是一个百分比,因此 N 值的计算稍微有些复杂。将 N 值代入下面的公式可以计算出理想情况下应用线程的运行时间所占的百分比:

GCTimeRatio 的默认值是 99。将该值代入公式能得到 0.99,这意味着应用程序的运行时间占总时间的 99%,只有 1% 的时间消耗在垃圾回收上。但是,不要被列出的默认值搞糊涂。譬如,GCTimeRatio 设置为 95 并不意味着会使用总时间的 5% 去做垃圾回收;它表示的是最多会使用总时间的 1.94% 去做垃圾回收。

先确定你期望应用程序线程工作的时间(譬如 95%),再根据下面这个公式计算 GCTimeRatio 是一个更容易操作的方法。

对于 95%(0.95)的吞吐量目标,利用该公式计算出的 GCTimeRatio 是 19。

JVM 使用这两个标志在堆的初始值(-Xms)和最大值(-Xmx)之间设置堆的大小。MaxGCPauseMillis 标志的优先级最高:如果设置了这个值,新生代和老年代会随之进行调整,直到满足对应停顿时间的目标。一旦这个目标达成,堆的总容量就开始逐渐增大,直到运行时间的比率达到设定值。这两个目标都达成后,JVM 会尝试缩减堆的大小,尽可能以最小的堆大小来满足这两个目标。

由于默认情况不设置停顿时间目标,通常自动堆调整的效果是堆(以及代空间)的大小会持续增大,直到满足设置的 GCTimeRatio 目标。不过,在实际操作中,该标志的默认设置已经相当优化了。每个人的使用经验各有不同,但是根据我以往的经验,如果应用程序在垃圾回收上消耗总时间的 3% 至 6%,其效果会是相当不错的。有些时候,我甚至会在内存严重受限的环境中调优应用程序的性能;这些应用通常会在垃圾回收上消耗 10% 至 15% 的时间。垃圾回收对这些应用程序的性能影响巨大,不过整体的性能目标还是能够达到的。

因此,依据应用程序的性能目标,最佳的配置也有所不同。本例没有其他的性能目标,我从时间百分比 19(垃圾收集时间占整个时间的 5% 左右)开始。

表中展示的是一个应用,仅需要一个容量较小的堆,也很少做 GC(这是一个运行在 GlassFish 服务器上的股票 Servlet 程序,它不保持会话状态,几乎没有长期活跃的对象),使用动态调整后的效果。

垃圾收集参数最终堆的大小垃圾收集时间所占百分比OPS
默认值649 MB0.9%9.2
MaxGCPauseMillis=50ms560 MB1.0%9.2
Xms=Xmx = 2048m2 GB0.04%9.2

默认情况下,堆容量的最小值是 64 MB,最大为 2 GB(因为这台机器配备了 8GB 的物理内存)。这时,GCTimeRatio 就如我们预期的那样工作得很好:堆的容量动态地调整到了 649 MB,这时应用程序在垃圾回收上只花费了大约 1% 的总运行时间。

在这个例子中,如果设置了 MaxGCPauseMillis 参数,JVM 为了达到停顿时间的目标,这之后就开始逐步减小堆的大小。由于本例中垃圾收集器不需要做太多的工作,堆的调整进行得很顺利,调整之后的堆仍能维持只消耗 1% 的时间在垃圾回收上,同时保持跟之前同样 9.2 次每秒(OPS)的吞吐量。

最后你会发现堆并不是越大越好——使用大小为 2 GB 的堆可以减少应用程序在垃圾回收上消耗的时间,但是这个例子中垃圾回收并不是影响性能的决定性因素,因此也无法提高程序的吞吐量。正如之前提到的,在错误的方向上投注精力进行优化无法提升应用的性能。

同样的应用程序,如果改变了行为,需要保持每个用户前 50 个请求的会话状态,垃圾收集器的工作量就会大大增加。下表展示了这种情况下的取舍。

垃圾收集参数最终堆的大小垃圾收集时间所占百分比OPS
默认值1.7 GB9.3%8.4
MaxGCPauseMillis=50ms588 MB15.1%7.9
Xms=Xmx=2048m2 GB5.1%9.0
Xms=3560m;MaxGCRatio=192.1 GB8.8%9.0

如果测试中应用程序消耗了大量的时间在垃圾回收上,情况就不一样了。这时,JVM 将无法达到设定的吞吐量目标,即只花总运行时间的 1% 在垃圾回收上,它会拼命地尝试各种途径来达到设定的目标,最终使用的堆空间为 1.7 GB。

如果设定的停顿时间目标不切实际,情况会更糟。为了让垃圾收集的时间控制在 50 毫秒以内,我们需要将堆的大小保持在 588 MB 以下,但这又意味着垃圾收集的频率变得过于频繁。最终,应用程序的吞吐量会因此显著降低。这种情况下,让 JVM 使用整个堆的容量,即将堆的初始值和最大值都设置成 2 GB 能获得更好的性能。

最终,我们通过努力将堆的大小设置得比较合理,将时间比率目标也设置得比较现实(5%),表的最后一行展示了这时的结果。JVM 通过自身的计算确定了 2 GB 是最优的堆大小,达到了设定的吞吐量目标,这与手工调整的效果几乎一致

堆大小调整小结

  1. 采用动态调整是进行堆调优极好的入手点。对很多的应用程序而言,采用动态调整就已经足够,动态调整的配置能够有效地减少 JVM 的内存使用。

  2. 静态地设置堆的大小也可能获得最优的性能。设置合理的性能目标,让 JVM 根据设置确定堆的大小是学习这种调优很好的入门课程。

理解CMS收集器

CMS 收集器有 3 种基本的操作,分别是:

CMS 收集器会对新生代的对象进行回收(所有的应用线程都会被暂停);

CMS 收集器会启动一个并发的线程对老年代空间的垃圾进行回收;

如果有必要,CMS 会发起 Full GC。

使用 CMS 收集器回收新生代空间

CMS 收集器的新生代垃圾收集与 Throughput 收集器的新生代垃圾收集非常相似:对象从 Eden 空间移动到 Survivor 空间,或者移动到老年代空间。CMS 收集的 GC 日志也非常相似:

89.853: [GC 89.853: [ParNew: 629120K->69888K(629120K), 0.1218970 secs]
                1303940K->772142K(2027264K), 0.1220090 secs]
                [Times: user=0.42 sys=0.02, real=0.12 secs]

这时的新生代空间大小为 629 MB;垃圾回收之后变成了 69 MB(位于 Survivor 空间)。与 Throughput 收集器的日志类似,整个堆的大小为 2027 MB,其中 772 MB 在垃圾回收之后依然被占用。虽然并行的 GC 线程使用了 0.42 秒的 CPU 时间,但整个垃圾回收过程仅耗时 0.12 秒。并发的垃圾回收周期如图所示。

JVM 会依据堆的使用情况启动并发回收。当堆的占用达到某个程度时,JVM 会启动后台线程扫描堆,回收不用的对象。扫描结束的时候,堆的状况就像这幅图中最后一列所描述的情况一样。请注意,如果使用 CMS 回收器,老年代空间不会进行压缩整理:老年代空间由已经分配对象的空间和空闲空间共同组成。新生代垃圾收集将对象由 Eden 空间挪到老年代空间时,JVM 会尝试使用那些空闲的空间来保存这些晋升的对象。

由 CMS 收集器完成的并发垃圾收集

通过 GC 日志,我们看到回收过程划分成了好几个阶段。虽然主要的并发回收(Concurrent Cycle)阶段都使用后台线程进行工作,有些阶段还是会暂停所有的应用线程,并因此引入短暂的停顿。

并发回收由“初始标记”阶段开始,这个阶段会暂停所有的应用程序线程:

89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
                772530K(2027264K), 0.0830120 secs]
 	    [Times: user=0.08 sys=0.00, real=0.08 secs]

这个阶段的主要任务是找到堆中所有的垃圾回收根节点对象。从第一组数据中可以看到这个例子中对象占用了老年代空间 1398 MB 中的 702 MB 空间。第二组数据显示整个堆的大小为 2027 MB,其中 772 MB 被占用。应用程序线程在这个 CMS 回收周期中被暂停了 0.08 秒。

下一个阶段是“标记阶段”,这个阶段中应用程序线程可以持续运行,不会被中断。GC 日志中,这个阶段的标识如下:

90.059: [CMS-concurrent-mark-start]
90.887: [CMS-concurrent-mark: 0.823/0.828 secs]
                [Times: user=1.11 sys=0.00, real=0.83 secs]

标识阶段耗时 0.83 秒(以及 1.11 秒的 CPU 时间)。由于这个阶段进行的工作仅仅是标记,不会对堆的使用情况产生实质性的改变,所以没有任何相关的数据输出。如果这个阶段还有数据输出,很可能是由于这 0.83 秒内新生代对象的分配导致了堆的增长,因为应用程序线程还在持续运行着。

然后是“预清理”阶段,这个阶段也是与应用程序线程的运行并发进行的:

90.887: [CMS-concurrent-preclean-start]
90.892: [CMS-concurrent-preclean: 0.005/0.005 secs]
                [Times: user=0.01 sys=0.00, real=0.01 secs]

接下来的是“重新标记”阶段,这个阶段涵盖了多个操作:

90.892: [CMS-concurrent-abortable-preclean-start]
92.392: [GC 92.393: [ParNew: 629120K->69888K(629120K), 0.1289040 secs]
                1331374K->803967K(2027264K), 0.1290200 secs]
                [Times: user=0.44 sys=0.01, real=0.12 secs]
94.473: [CMS-concurrent-abortable-preclean: 3.451/3.581 secs]
                [Times: user=5.03 sys=0.03, real=3.58 secs]

94.474: [GC[YG occupancy: 466937 K (629120 K)]
        94.474: [Rescan (parallel) , 0.1850000 secs]
        94.659: [weak refs processing, 0.0000370 secs]
        94.659: [scrub string table, 0.0011530 secs]
                [1 CMS-remark: 734079K(1398144K)]
                1201017K(2027264K), 0.1863430 secs]
        [Times: user=0.60 sys=0.01, real=0.18 secs]

且慢,CMS 收集不是只执行一次预清理阶段吗?这个“可中断预清理”(abortable preclean)阶段是做什么的呢?

使用可中断预清理阶段是由于标记阶段(严格说起来,它应该是最后的输出项)不是并发的,所有的应用线程进入标记阶段后都会被暂停。如果新生代收集刚刚结束,紧接着就是一个标记阶段的话,应用线程会遭遇 2 次连续的停顿操作,CMS 收集器希望避免这样的情况发生。使用可中断预清理阶段的目的就是希望尽量缩短停顿的长度,避免连续的停顿。

因此,可中断预清理阶段会等到新生代空间占用到 50% 左右时才开始。理论上,这时离下一次新生代收集还有半程的距离,给了 CMS 收集器最好的机会避免发生连续停顿。这个例子中,可中断预清理阶段在 90.8 秒开始,等待常规的新生代收集开始花了 1.5 秒(根据日志的记录,92.392 秒开始)。CMS 收集器根据以往的历史记录推算下一次新生代垃圾收集可能持续的时间。这个例子中,CMS 收集器计算出的时长大约是 4.2 秒。所以 2.1 秒之 后(即 94.4 秒),CMS 收集器停止了预清理阶段(这种行为被称为“放弃”了这次回收,不过这可能是唯一能停止该次回收的方式)。这之后,CMS 回收器终于开始了标记阶段的工作执行,标记阶段的回收工作将应用程序线程暂停了 0.18 秒(在可中断预清理过程中,应用程序线程不会被暂停)。

接下来是另一个并发阶段——清除(sweep)阶段:

94.661: [CMS-concurrent-sweep-start]
95.223: [GC 95.223: [ParNew: 629120K->69888K(629120K), 0.1322530 secs]
                999428K->472094K(2027264K), 0.1323690 secs]
                [Times: user=0.43 sys=0.00, real=0.13 secs]
95.474: [CMS-concurrent-sweep: 0.680/0.813 secs]
                [Times: user=1.45 sys=0.00, real=0.82 secs]

这个阶段耗时 0.82 秒,回收线程与应用程序线程并发运行。碰巧这次的并发 - 清除过程被新生代垃圾回收中断了。新生代垃圾回收与清除阶段并没有直接的联系,将这个例子保留在这里是为了说明新生代的垃圾收集与老年代的垃圾收集可以并发进行。可以看到,新生代的状态在并发收集的过程中发生了变化——清除过程中新生代可能发生了多次垃圾收集(至少发生了一次新生代垃圾收集,因为可中断的预清理至少会经历一次新生代垃圾收集)。

接下来是并发重置(concurrent reset)阶段:

95.474: [CMS-concurrent-reset-start]
95.479: [CMS-concurrent-reset: 0.005/0.005 secs]
        [Times: user=0.00 sys=0.00, real=0.00 secs]

这是并发运行的最后一个阶段;CMS 垃圾回收的周期至此告终,老年代空间中没有被引用的对象被回收。遗憾的是,我们无法从日志中了解到底有多少对象被回收;重置阶段的日志也没有提供更多的信息,最后还有多少堆空间被占用不得而知。为了发掘这些信息,我们尝试从新生代垃圾收集日志中找到一些蛛丝马迹,如下所示:

98.049: [GC 98.049: [ParNew: 629120K->69888K(629120K), 0.1487040 secs]
                1031326K->504955K(2027264K), 0.1488730 secs]

与 89.853 秒时(即 CMS 回收周期开始之前)老年代空间的占用情况相比较,那时的空间占用大约是 703 MB(整个堆的占用为 772 MB,其中包含 69 MB 的 Survivor 空间占用,因此老年代占用了剩下的 703 MB)。到 98.049 秒,垃圾收集结束,老年代空间占用大约为 504 MB,由此可以计算出 CMS 周期回收了大约 199 MB 的内存。

如果一切顺利,这些就是 CMS 垃圾回收会经历的周期,以及所有可能出现在 CMS 垃圾收集日志中的消息。不过,事实并不是这么简单,我们还需要查看另外三种消息,出现这些日志表明 CMS 垃圾收集碰到了麻烦。首当其冲的是并发模式失效(concurrent mode failure):

267.006: [GC 267.006: [ParNew: 629120K->629120K(629120K), 0.0000200 secs]
        267.006: [CMS267.350: [CMS-concurrent-mark: 2.683/2.804 secs]
        [Times: user=4.81 sys=0.02, real=2.80 secs]
        (concurrent mode failure):
        1378132K->1366755K(1398144K), 5.6213320 secs]
        2007252K->1366755K(2027264K),
        [CMS Perm : 57231K->57222K(95548K)], 5.6215150 secs]
        [Times: user=5.63 sys=0.00, real=5.62 secs]

新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成 Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收,释放空间之后老年代的占用为 1366 MB——这次操作导致应用程序线程停顿长达 5.6 秒。这个操作是单线程的,这就是为什么它耗时如此之长的原因之一(这也是为什么发生并发模式失效比堆的增长更加恶劣的原因之一)。

第二个问题是老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败:

6043.903: [GC 6043.903:
        [ParNew (promotion failed): 614254K->629120K(629120K), 0.1619839 secs]
        6044.217: [CMS: 1342523K->1336533K(2027264K), 30.7884210 secs]
2004251K->1336533K(1398144K),
        [CMS Perm : 57231K->57231K(95548K)], 28.1361340 secs]
        [Times: user=28.13 sys=0.38, real=28.13 secs]

在这个例子中,CMS 启动了新生代垃圾收集,判断老年代似乎有足够的空闲空间可以容纳所有的晋升对象(否则,CMS 收集器会报告发生并发模式失效)。这个假设最终被证明是错误的:由于老年代空间的碎片化(或者,不太贴切地说,由于晋升实际要占用的内存超过了 CMS 收集器的判断),CMS 收集器无法晋升这些对象。

因此,CMS 收集器在新生代垃圾收集过程中(所有的应用线程都被暂停时),对整个老年代空间进行了整理和压缩。好消息是,随着堆的压缩,碎片化问题解决了(至少在短期内不是问题了)。不过随之而来的是长达 28 秒的冗长的停顿时间。由于需要对整个堆进行整理,这个时间甚至比 CMS 收集器遭遇并发模式失效的时间还长的多,因为发生并发模式失效时,CMS 收集器只需要回收堆内无用的对象。这时的堆就像刚由 Throughput 收集器做完 Full GC 一样:新生代空间完全空闲,老年代空间也已经整理过。

最终,CMS 收集的日志中可能只有一条 Full GC 的记录,不含任何常规并发垃圾回收的日志。

279.803: [Full GC 279.803:
                [CMS: 88569K->68870K(1398144K), 0.6714090 secs]
                558070K->68870K(2027264K),
                [CMS Perm : 81919K->77654K(81920K)],
                0.6716570 secs]

永久代空间用尽,需要回收时,就会发生这样的状况;应注意到,CMS 收集后永久代空间大小减小了。Java 8 中,如果元空间需要调整,也会发生同样的情况。默认情况下,CMS 收集器不会对永久代(或元空间)进行收集,因此,它一旦被用尽,就需要进行 Full GC,所有没有被引用的类都会被回收。CMS 高级调优一节会有针对性地介绍如何解决这种问题。

CMS理解小结

  1. CMS 垃圾回收有多个操作,但是期望的操作是 Minor GC 和并发回收(concurrent cycle)。

  2. CMS 收集过程中的并发模式失效以及晋升失败的代价都非常昂贵;我们应该尽量调优 CMS 收集器以避免发生这些情况。

  3. 默认情况下 CMS 收集器不会对永久代进行垃圾回收。

针对并发模式失效的调优

调优 CMS 收集器时最要紧的工作就是要避免发生并发模式失效以及晋升失败。正如我们在 CMS 垃圾收集日志中看到的那样,发生并发模式失效往往是由于 CMS 不能以足够快的速度清理老年代空间:新生代需要进行垃圾回收时,CMS 收集器计算发现老年代没有足够的空闲空间可以容纳这些晋升对象,不得不先对老年代进行垃圾回收。

初始时老年代空间中对象是一个接一个整齐有序排列的。当老年代空间的占用达到某个程度(默认值为 70%)时,并发回收就开始了。一个 CMS 后台线程开始扫描老年代空间,寻找无用的垃圾对象时,竞争就开始了:CMS 收集器必须在老年代剩余的空间(30%)用尽之前,完成老年代空间的扫描及回收工作。如果并发回收在这场速度的比赛中失利,CMS 收集器就会发生并发模式失效。

有以下途径可以避免发生这种失效。

想办法增大老年代空间,要么只移动部分的新生代对象到老年代,要么增加更多的堆空间。

以更高的频率运行后台回收线程。

使用更多的后台回收线程。

自适应调优和 CMS 垃圾搜集

CMS 收集器使用两个配置 MaxGCPauseMllis=N 和 GCTimeRatio=N 来确定使用多大的堆和多大的代空间。

CMS 收集与其他的垃圾收集方法一个显著的不同是除非发生 Full GC,否则 CMS 的新生代大小不会作调整。由于 CMS 的目标是尽量避免 Full GC,这意味着使用精细调优的 CMS 收集器的应用程序永远不会调整它的新生代大小。

程序启动时可能频发并发模式失效,因为 CMS 收集器需要调整堆和永久代(或者元空间)的大小。使用 CMS 收集器,初始时采用一个比较大的堆(以及更大的永久代 / 元空间)是一个很好的主意,这是一个特例,增大堆的大小反而帮助避免了那些失效。

如果有更多的内存可用,更好的方案是增加堆的大小,否则可以尝试调整后台线程运行的 方式来解决这个问题。

  1. 给后台线程更多的运行机会

为了让 CMS 收集器赢得这场比赛,方法之一是更早地启动并发收集周期。显然地,CMS 收集器在老年代空间占用达到 60% 时启动并发周期,这和老年代空间占用到 70% 时才启动相比,前者完成垃圾收集的几率更大。为了实现这种配置,最简单的方法是同时设置下面这两个标志:-XX:CMSInitiatingOccupancyFraction=N 和 -XX:+UseCMSInitiatingOccupancyOnly。同时使用这两个参数能帮助 CMS 更容易地进行决策:如果同时设置这两个标志,那么 CMS 就只依据设置的老年代空间占用率来决定何时启动后台线程。默认情况下,UseCMSInitiatingOccupancyOnly 标志的值为假,CMS 会使用更复杂的算法判断什么时候启动并行收集线程。如果有必要提前启动后台线程,推荐使用最简单的方法,即将 UseCMSInitiatingOccupancyOnly 标志的值设置为真。

CMSInitiatingOccupancyFraction 参数值的调整可能需要多次迭代才能确定。如果开启了 UseCMSInitiatingOccupancyOnly 标志,CMSInitiatingOccupancyFraction 的默认值就被置为 70,即 CMS 会在老年代空间占用达到 70% 时启动并发收集周期。

对特定的应用程序,该标志的更优值可以根据 GC 日志中 CMS 周期首次启动失败时的值得到。具体方法是,在垃圾回收日志中寻找并发模式失效,找到后再反向查找 CMS 周期最近的启动记录。日志中含有 CMS-initial-mark 信息的一行包含了 CMS 周期启动时,老年代空间的占用情况如下所示:

89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
                772530K(2027264K), 0.0830120 secs]
                [Times: user=0.08 sys=0.00, real=0.08 secs]

在这个例子中,根据日志的输出,我们可以判断该时刻老年代空间的占用率为 50%(老年代空间大小为 1398 MB,其中 702 MB 被占用)。不过这个值还不够早,因此我们需要调整 CMSInitiatingOccupancyFraction 将其值设定为小于50的某个值。(虽然 CMSInitiatingOccupancyFraction 的默认值为 70,不过这个例子中没有开启 UseCMSInitiatingOccupancyOnly 标志,所以例子中 CMS 收集器在老年代空间占用达到 50% 时启动了 CMS 后台线程。)

了解了 CMSInitiatingOccupancyFraction 的工作原理之后,你可能会有疑问,我们能不能将参数值设置为 0 或者其他比较小的值,让 CMS 的后台线程持续运行。通常我们不推荐进行这样的设置,但是,如果你对其中的取舍非常了解,适当地妥协也是可以接受的。

这其中的第一个取舍源于 CPU:CMS 后台线程会持续运行,它们会消耗大量的 CPU 时钟——每个 CMS 后台线程运行时都会 100% 地占用一颗 CPU。多个 CMS 线程同时运行时还会有短暂的爆发,机器的总 CPU 使用因此也会暴涨。如果这些线程都是毫无目的地持续运行,只会白白浪费宝贵的 CPU 资源。

另一方面,这并不是说使用了过多的 CPU 周期就是问题。后台的 CMS 线程需要时必须运行,即使在最好的情况下,这也是很难避免的。因此,机器必须预留足够的 CPU 周期来运行这些 CMS 线程。所以规划机器时,你必须考虑留出余量给这部分 CPU 的使用。

CMS 周期中,如果 CMS 后台线程没有运行,这些 CPU 时钟可以用于运行其他的应用吗?通常不会。如果还有另一个应用也在使用同一个时钟周期,它没有途径了解何时 CMS 线程会运行。因此,应用程序线程和 CMS 线程会竞争 CPU 资源,而这很可能会导致 CMS 线程的“失速”(lose its race)。有些时候,通过复杂的操作系统调优,有可能让应用线程以低于 CMS 线程优先级的方式让两种线程在同一个时钟周期内运行,但是这些方法都相当复杂,很容易出错。因此,答案是肯定的,CMS 周期运行得越频繁,CPU 周期越长,如果不这样,这些 CPU 周期就是空闲状态(idle)。

第二个取舍更加重要,它与应用程序的停顿相关。正如我们在 GC 日志中观察到的,CMS 在特定的阶段会暂停所有的应用线程。使用 CMS 收集器的主要目的就是要限制 GC 停顿的影响,因此频繁地运行更多无效的 CMS 周期只能适得其反。CMS 停顿的时间与新生代的停顿时间比起来要短得多,应用线程甚至可能感受不到这些额外的停顿——这也是一种取舍,我们是要避免额外的停顿还是要减少发生并发模式失败的几率。不过,正如我们前面提到的,持续地运行后台 GC 线程所造成的停顿可能会导致总体的停顿,而这最终会降低应用程序的性能。

除非这些取舍都能接受,否则不要将 CMSInitiatingOccupancyFraction 参数的值设置得比堆内的活跃数据数还多,至少要少 10% 到 20%。

2.调整CMS后台线程

每个 CMS 后台线程都会 100% 地占用机器上的一颗 CPU。如果应用程序发生并发模式失效,同时又有额外的 CPU 周期可用,可以设置 -XX:ConcGCThreads=N 标志,增加后台线程的数目。默认情况下,ConcGCThreads 的值是依据 ParallelGCThreads 标志的值计算得到的:

ConcGCThreads = (3 + ParallelGCThreads) / 4

上述计算使用整数计算方法,这意味着如果 ParallelGCThreads 的取值区间在 1 到 4,ConcGCThread 的值就为 1,如果 ParallelGCThreads 的取值在 5 到 8 之间,ConcGCThreads 的值就为 2,以此类推。

调整这一标志的要点在于判断是否有可用的 CPU 周期。如果 ConcGCThreads 标志值设置的偏大,垃圾收集会占用本来能用于运行应用线程的 CPU 周期;最终效果上,这种配置会导致应用程序些微的停顿,因为应用程序线程需要等待再次在 CPU 上继续运行的机会。

除此之外,在一个配备了大量 CPU 的系统上,ConcGCThreads 参数的默认值可能偏大。如果没有频繁遭遇并发模式失败,可以考虑减少后台线程数,释放这部分 CPU 周期用于应用线程的运行。

并发模式失效的调优小结

  1. 避免发生并发模式失效是提升 CMS 收集器处理能力、获得高性能的关键。

  2. 避免并发模式失效(如果有可能的话)最简单的方法是增大堆的容量。

  3. 否则,我们能进行的下一个步骤就是通过调整 CMSInitiatingOccupancyFraction 参数,尽早启动并发后台线程的运行。

  4. 另外,调整后台线程的数目对解决这个问题也有帮助。

CMS收集器的永久代调优

从例子的 CMS 垃圾收集日志中我们发现,如果永久代需要进行垃圾收集,就会发生 Full GC(如果元空间的大小需要调整也会发生同样的情况)。这往往发生在程序员频繁部署(或者重新部署)应用的服务器上,或者发生在需要频繁定义(或者回收)类的应用中。

默认情况下,Java 7 中的 CMS 垃圾收集线程不会处理永久代中的垃圾,如果永久代空间用尽,CMS 会发起一次 Full GC 来回收其中的垃圾对象。除此之外,还可以开启 -XX:+CMSPermGenSweepingEnabled 标志(默认情况下,该标志的值为 false),开启后,永久代中的垃圾使用与老年代同样的方式进行垃圾收集:通过一组后台线程并发地回收永久代中的垃圾对象。注意,触发永久代垃圾回收的指标与老年代的指标是相互独立的。使用 -XX:CMSInitiatingPermOccupancyFraction=N 参数可以指定 CMS 收集器在永久代空间占用比达到设定值时启动永久代垃圾回收线程,这个参数的默认值为 80%。

不过,开启永久代垃圾收集只是整个流程中的一步,为了真正释放不再被引用的类,我们还需要设置 -XX:+CMSClassUnloadingEnabled 标志。否则,即使启用了永久代垃圾回收也只能释放少量的无效对象,类的元数据并不会被释放。由于永久代中大量的数据都是类的元数据,因此启动 CMS 永久代垃圾收集时,这个标志同时也应该开启。

Java 8 中,CMS 收集器默认就会收集元空间中不再载入的类。如果由于某些原因,你希望关闭这一功能,可以通过 -XX:-CMSClassUnloadingEnabled 标志进行关闭(默认情况下这个标志是开启的,即该值为 true)。

增量式CMS垃圾收集

这一章中我们多次提到了这样一个事实:为了进行有效的 CMS 垃圾收集,需要消耗额外的 CPU 处理资源。如果你只有一个单 CPU 的机器,或者你有多个非常忙碌的 CPU,但是希望使用低延迟的垃圾收集器,这时有什么好的建议呢?

增量式 CMS 垃圾收集在 Java 8 中已经不推荐使用

增量式 CMS 垃圾收集(iCMS)在 Java 8 中已经不推荐使用了,不过暂时还保留在其中,但是在 Java 9 中很可能会被移除。
使用增量式 CMS 垃圾收集的主要好处是后台线程会间歇性地停顿,让出一部分 CPU 给应用程序线程运行,从而使得 CMS 收集器即使在只配备了有限 CPU 资源的机器上也能运行。随着多核技术的发展,多处理器几乎已经成为所有系统的标准配置(连我的手机都装载了 4 核的 CPU 芯片),这使得 iCMS 存在的意义变得不再那么重要。
如果系统确实只配备了极其有限的 CPU,作为替代方案,可以考虑使用 G1 收集器——因为 G1 收集器的后台线程在垃圾收集的过程中也会周期性地暂停,客观上减少了与应用线程竞争 CPU 资源的情况。

这些情况下,使用 CMS 收集器进行增量式的垃圾收集,即只要有后台线程运行(同一个时刻处于运行状态的线程数不应该超过一个),垃圾收集器就不会马上对整个堆进行垃圾收集。这个后台线程间断性地暂停,有助于整个系统吞吐量的提高,因为更多的 CPU 处理资源让给了应用线程的运行。当然,如果 CMS 收集线程一旦运行起来,还是会与应用程序线程争夺有限的 CPU 处理周期。

指定 -XX:+CMSIncrementalMode 标志可以开启增量式 CMS 垃圾收集。通过改变标志 -XX:CMSIncrementalSafetyFactor=N、-XX:CMSIncrementalDutyCycleMin=N 和 -XX:CMSIncrementalPacing 可以控制垃圾收集后台线程为应用程序线程让出多少 CPU 周期。

增量式 CMS 垃圾收集依据责任周期(duty cycle)原则进行工作,这个原则决定了 CMS 垃圾收集器的后台线程在释放 CPU 周期给应用线程之前,每隔多长时间扫描一次堆。从操作系统的层次上看,CMS 垃圾收集器的后台线程已经和应用的线程发生了竞争(通常是基于时间片的)。换个角度看,这些标志实际控制着主动暂停运行、释放资源给应用线程运行之前,后台线程持续运行的时间。

责任周期的时间长度是以新生代相邻两次垃圾收集之间的时间长度计算得出的;默认情况下,增量式 CMS 垃圾收集持续的时间是该时长的 20% 左右(至少初始时是这个值,不过 CMS 会不断调整该值以适应不断晋升到老年代的对象数目)。如果这个时间不够长,就会发生并发模式失效(以及 Full GC)。我们的目标就是通过调整增量式 CMS 垃圾收集,避免发生这种 GC(或者尽量减少它们发生的频率)。

我们从调整增大 CMSIncrementalSafetyFactor 参数入手,这个参数设置是增加到默认责任周期的时间百分比。责任周期的默认值是