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 参数入手,这个参数设置是增加到默认责任周期的时间百分比。责任周期的默认值是 10%,默认情况下,安全因子(safety factor)的值是再增加 10%(这样默认的初始责任周期所占用的时间百分比就变成了 20%)。通过增大安全因子(最大可以增加到 90,不过这会导致增量周期占用所有的时间),可以让后台线程有更多的运行时间。

除此之外,如果参数 CMSIncrementalDutyCycleMin 设置得比默认值(10)更大也可以调整责任周期的长度。不过这个参数值会受 JVM 自动调节机制的影响,因为 JVM 的自动调节机制会监控由新生代晋升到老年代的对象数并进行相应的调节。所以,即使增大这个值,JVM 可能还是会依据自身的判断,即增量式垃圾收集运行不需要运行得过于频繁,而减小这个参数的值。如果应用程序运行时操作有爆发式的波峰,通过自动调节机制计算出的结果通常不准确,你需要显式地设置责任周期,同时调整 CMSIncrementalDutyCycle 标志关闭自动参数调节(CMSIncrementalDutyCycle 的值默认为真,即开启)。

增量式收集器小结

  1. 应用在 CPU 资源受限的机器上运行,同时又要求较小的停顿,这时使用增量式 CMS 收集器是一个不错的选择。

  2. 通过责任周期可以调整增量式 CMS 收集器;增加责任周期的运行时间可以避免 CMS 收集器发生并发模式失效。

理解G1垃圾收集器

G1 垃圾收集器是一种工作在堆内不同分区上的并发收集器。分区(region)既可以归属于老年代,也可以归属于新生代(默认情况下,一个堆被划分成 2048 个分区),同一个代的分区不需要保持连续。为老年代设计分区的初衷是我们发现并发后台线程在回收老年代中没有引用的对象时,有的分区垃圾对象的数量很多,另一些分区的垃圾对象相对较少。虽然分区的垃圾收集工作实际仍然会暂停应用程序线程,不过由于 G1 收集器专注于垃圾最多的分区,最终的效果是花费较少的时间就能回收这些分区的垃圾。这种只专注于垃圾最多分区的方式就是 G1 垃圾收集器名称的由来,即首先收集垃圾最多的分区。

不过这一算法并不适用于新生代的分区:新生代进行垃圾回收时,整个新生代空间要么被回收,要么被晋升(对象被移动到 Survivor 空间,或者移动到老年代)。新生代也采用分区机制的部分原因,是因为采用预定义的分区能够便于代的大小调整。

G1 收集器的收集活动主要包括 4 种操作:

  1. 新生代垃圾收集;

  2. 后台收集,并发周期;

  3. 混合式垃圾收集;

  4. 以及必要时的 Full GC。

我们会依次讨论每一种操作,首先讨论的是 G1 收集器的新生代垃圾收集,如图所示。

用 G1 垃圾收集器的新生代收集前后对比

图中的每一个小方块都代表一个 G1 的分区。分区中黑色的区域代表数据,每个分区中的字母表示该区域属于哪个代([E] 代表 Eden 空间,[O] 代表老年代,[S] 代表 Survivor 空间)。空的分区不属于任何一个代;需要的时候 G1 收集器会强制指定这些空的分区用于任何需要的代。

Eden 空间耗尽会触发 G1 垃圾收集器进行新生代垃圾收集(这个例子中,标识为 Eden 的 4 个分区填满之后就会触发新生代收集)。新生代收集之后不会有新的分区马上分配到 Eden 空间,因为这时 Eden 空间为空。不过至少会有一个分区分配到 Survivor 空间(这个例子中,Survivor 空间被部分填满),一部分数据会移动到老年代。

G1 垃圾收集器中,新生代垃圾收集的日志与其他的收集器略有不同。与往常一样,我们可以使用 PrintGCDetails 输出例子的垃圾回收日志,不过 G1 收集的日志要详细得多。这里仅仅列出了例子中重要的几行。

下面是新生代垃圾收集的标准流程:

23.430: [GC pause (young), 0.23094400 secs]
...
   [Eden: 1286M(1286M)->0B(1212M)
        Survivors: 78M->152M Heap: 1454M(4096M)->242M(4096M)]
   [Times: user=0.85 sys=0.05, real=0.23 secs]

这里新生代垃圾收集的 Real 时间消耗是 0.23 秒,这期间,垃圾收集线程消耗了 0.85 秒的 CPU 时间,1286 MB 的对象移出了 Eden 空间(Eden 空间的大小调整到了 1212 MB);这其中的 74 MB 移动到了 Survivor 空间(Survivor 空间的大小从 78 MB 增加到了 152 MB),其余的空间都被垃圾收集器回收掉了。通过观察堆的总占用降低了 1212 MB 我们知道,这些空间被释放了。通常情况下,一部分对象已经从 Survivor 空间移动到老年代空间,如果 Survivor 空间被填满,无法容纳新生代的晋升对象,部分 Eden 空间的对象会被直接晋升到老年代空间——这种情况下,老年代空间的占用也会增加。

如下图是并发 G1 垃圾收集周期(concurrent G1 cycle)开始和结束时的情况。

G1 收集器进行的并发垃圾收集

这幅图中有三方面值得我们关注。首先,新生代的空间占用情况发生了变化:在并发周期中,至少有一次(很可能是多次)新生代垃圾收集。因此,在将 Eden 空间中的分区标记为完全释放之前,新的 Eden 分区已经开始分配了。

其次,我们注意到一些分区现在被标记为 X。这些分区属于老年代(注意,它们依然还保持着数据),它们就是标记周期(marking cycle)找出的包含最多垃圾的分区。

最后,我们还要留意老年代(包括标记为 O 或者 X 的分区)的空间占用,在周期结束时实际可能更多。这是因为在标记周期中,新生代的垃圾收集会晋升对象到老年代。除此之外,标记周期中实际不会释放老年代中的任何对象:它仅仅锁定了那些垃圾最多的分区。这些分区中的垃圾数据会在之后的周期中被回收释放。

G1 收集器的并发周期包括多个阶段,其中的一些会暂停所有应用线程,另一些则不会。并发周期的第一个阶段是初始—标记(initial-mark)阶段。这个阶段会暂停所有应用线程——部分源于初始—标记阶段也会进行新生代垃圾收集。

50.541: [GC pause (young) (initial-mark), 0.27767100 secs]
    [Eden: 1220M(1220M)->0B(1220M)
        Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)]
    [Times: user=1.02 sys=0.04, real=0.28 secs]

同常规的新生代垃圾收集一样,初始—标记阶段中,应用线程被暂停(大约时长 0.28 秒),之后新生代被清空(71 MB 的数据从新生代移到了老年代)。初始—标记阶段的输出日志表明后台并发周期启动。由于初始—标记阶段也需要暂停所有的应用线程,G1 收集器重用了新生代 GC 周期来完成这部分的工作。在新生代垃圾收集中添加初始标记阶段的影响并不大:与之前的垃圾收集相比较,CPU 周期的开销增加了大约 20%,即便如此,停顿时间只有些微的增长(幸运的是,这台机器上有空闲的 CPU 周期可以运行并发 G1 收集线程,否则停顿时间会更长一些)。

接下来,G1 收集器会扫描根分区(root region):

50.819: [GC concurrent-root-region-scan-start]
51.408: [GC concurrent-root-region-scan-end, 0.5890230]

这个过程耗时 0.58 秒,不过扫描过程中不需要暂停应用现场,G1 收集器使用后台线程进行扫描工作。不过,这个阶段中不能发生新生代垃圾收集,因此预留足够的 CPU 周期给后台线程运行是非常重要的。如果扫描根分区时,新生代空间刚巧用尽,新生代垃圾收集(会暂停所有的应用线程)必须等待根扫描结束才能完成。效果上,这意味着新生代垃圾收集的停顿时间会更长(远超过正常的耗时)。这种情况在 GC 日志中如下所示:

350.994: [GC pause (young)
        351.093: [GC concurrent-root-region-scan-end, 0.6100090]
        351.093: [GC concurrent-mark-start],
        0.37559600 secs]

此处 GC 的停顿发生在根分区扫描之前,这意味着 GC 停顿还会继续等待,我们会看到 GC 日志中的相互交织的输出。GC 日志的时间戳显示应用线程等待了大概 100 毫秒——这就是新生代 GC 停顿时间比日志中其他停顿的平均持续时间还长 100 毫秒的原因。这是一个信号,说明你的 G1 收集器需要进行调优,下一节我们将详细讨论这部分内容。

根分区扫描完成后,G1 收集器就进入到并发标记阶段。这个阶段完全在后台运行,阶段启动和停止时在 GC 日志中各会打印一条日志。

111.382: [GC concurrent-mark-start]
....
120.905: [GC concurrent-mark-end, 9.5225160 sec]

并发标记阶段是可以中断的,所以这个阶段中可能发生新生代垃圾收集。紧接在标记阶段之后的是重新标记(remarking)阶段和正常的清理阶段。

120.910: [GC remark 120.959:
        [GC ref-PRC, 0.0000890 secs], 0.0718990 secs]
        [Times: user=0.23 sys=0.01, real=0.08 secs]
120.985: [GC cleanup 3510M->3434M(4096M), 0.0111040 secs]
        [Times: user=0.04 sys=0.00, real=0.01 secs]

这几个阶段都会暂停应用线程,虽然暂停的时间通常很短。紧接着是一个额外的并发清理阶段:

120.996: [GC concurrent-cleanup-start]
120.996: [GC concurrent-cleanup-end, 0.0004520]

这之后,正常的 G1 周期就结束了——至少是垃圾的定位就完成了。清理阶段真正回收的内存数量很少,G1 到这个点为止真正做的事情是定位出哪些老的分区可回收垃圾最多(即图 6-7 中标记为 X 的分区)。

现在,G1 会执行一系列的混合式垃圾回收(mixed GC)。这些垃圾回收被称作“混合式”是因为它们不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集的效果如图所示。

使用 G1 收集器进行的混合式 GC

同新生代垃圾收集通常的行为一样,G1 收集器已经清空了 Eden 空间,同时调整了 Survivor 空间的大小。此外,标记的两个分区也已经被回收。这些分区在之前的扫描中已经证实包含大量垃圾对象,因此绝大部分已经被释放。

这些分区中的活跃数据被移动到另一个分区(就像把活跃数据从新生代移动到老年代的分区)。这就是为什么 G1 收集器最终出现碎片化的堆的频率,跟 CMS 收集器比较起来要小得多的原因——随着 G1 垃圾的回收以这种方式移动对象,实际伴随着压缩。

关于混合式垃圾回收操作,请参考下面的日志:

79.826: [GC pause (mixed), 0.26161600 secs]
....
    [Eden: 1222M(1222M)->0B(1220M)
         Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)]
    [Times: user=1.01 sys=0.00, real=0.26 secs]

应注意,减少的整个堆的使用不仅仅是 Eden 空间移走的 1222 MB。这其中的差异看起来很小(只有 16 MB),但是同时还有部分 Survivor 空间的对象晋升到了永久代,除此之外,每次混合式垃圾回收只会清理部分目标老年代分区。接下来的讨论中,我们会看到确保混合式垃圾收集清理掉足够的内存对避免将来发生并发失效有多重要。

混合式垃圾回收周期会持续运行直到(几乎)所有标记的分区都被回收,这之后 G1 收集器会恢复常规的新生代垃圾回收周期。最终,G1 收集器会启动再一次的并发周期,决定哪些分区应该在下一次垃圾回收中释放。

同 CMS 收集器一样,有的时候你会在垃圾回收日志中观察到 Full GC,这些日志是一个信号,表明我们需要进一步调优(具体的方式很多,甚至很可能要分配更多的堆空间)才能提升应用程序的性能。主要有 4 种情况会触发这类的 Full GC,如下所列。

并发模式失效

G1 垃圾收集启动标记周期,但老年代在周期完成之前就被填满,在这种情况下,G1 收集器会放弃标记周期:

51.408: [GC concurrent-mark-start]
65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs]
 [Times: user=7.87 sys=0.00, real=6.20 secs]
71.669: [GC concurrent-mark-abort]

发生这种失败意味着堆的大小应该增加了,或者 G1 收集器的后台处理应该更早开始,或者是需要调整周期,让它运行得更快(譬如,增加后台处理的线程数)。

晋升失败

G1 收集器完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。垃圾回收日志中,这种情况的现象通常是混合式 GC 之后紧接着一次 Full GC。

2226.224: [GC pause (mixed)
        2226.440: [SoftReference, 0 refs, 0.0000060 secs]
        2226.441: [WeakReference, 0 refs, 0.0000020 secs]
        2226.441: [FinalReference, 0 refs, 0.0000010 secs]
        2226.441: [PhantomReference, 0 refs, 0.0000010 secs]
        2226.441: [JNI Weak Reference, 0.0000030 secs]
                (to-space exhausted), 0.2390040 secs]
....
    [Eden: 0.0B(400.0M)->0.0B(400.0M)
        Survivors: 0.0B->0.0B Heap: 2006.4M(2048.0M)->2006.4M(2048.0M)]
    [Times: user=1.70 sys=0.04, real=0.26 secs]
2226.510: [Full GC (Allocation Failure)
        2227.519: [SoftReference, 4329 refs, 0.0005520 secs]
        2227.520: [WeakReference, 12646 refs, 0.0010510 secs]
        2227.521: [FinalReference, 7538 refs, 0.0005660 secs]
        2227.521: [PhantomReference, 168 refs, 0.0000120 secs]
        2227.521: [JNI Weak Reference, 0.0000020 secs]
                2006M->907M(2048M), 4.1615450 secs]
    [Times: user=6.76 sys=0.01, real=4.16 secs]

这种失败通常意味着混合式收集需要更迅速地完成垃圾收集;每次新生代垃圾收集需要处理更多老年代的分区。

疏散失败

进行新生代垃圾收集时,Survivor 空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在 GC 日志中通常被当成一种特别的新生代:

60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]

这条日志表明堆已经几乎完全用尽或者碎片化了。G1 收集器会尝试修复这一失败,但是你可以预期,结果会更加恶化:G1 收集器会转而使用 Full GC。解决这个问题最简单的方式是增加堆的大小,除此之外,其他一些可能的解决方案会在下篇 “高级调优”讨论。

巨型对象分配失败

使用 G1 收集器时,分配非常巨大对象的应用程序可能会遭遇另一种 Full GC;目前为止没有工具可以很方便地专门诊断这种类型的失败,尤其是从标准垃圾收集日志中进行诊断。不过,如果发生了莫名其妙的 Full GC,其源头很可能是巨型对象分配导致的问题。

G1基本小结

  1. G1 垃圾收集包括多个周期(以及并发周期内的阶段)。调优良好的 JVM 运行 G1 收集器时应该只经历新生代周期、混合式周期和并发 GC 周期。

  2. G1 的并发阶段会产生少量的停顿。

  3. 恰当的时候,我们需要对 G1 进行调优,才能避免 Full GC 周期发生。

更新时间:2020-03-09 18:28:19

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

评论

Your browser is out of date!

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

×