6.2 理解CMS收集器

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

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

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

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

图 6-4 展示了使用 CMS 回收新生代的情况。

{%}

图 6-4:使用 CMS 收集器回收新生代空间

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

  1. 89.853: [GC 89.853: [ParNew: 629120K->69888K(629120K), 0.1218970 secs]
  2. 1303940K->772142K(2027264K), 0.1220090 secs]
  3. [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 秒。并发的垃圾回收周期如图 6-5 所示。

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

{%}

图 6-5:由 CMS 收集器完成的并发垃圾收集

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

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

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

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

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

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

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

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

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

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

  1. 90.892: [CMS-concurrent-abortable-preclean-start]
  2. 92.392: [GC 92.393: [ParNew: 629120K->69888K(629120K), 0.1289040 secs]
  3. 1331374K->803967K(2027264K), 0.1290200 secs]
  4. [Times: user=0.44 sys=0.01, real=0.12 secs]
  5. 94.473: [CMS-concurrent-abortable-preclean: 3.451/3.581 secs]
  6. [Times: user=5.03 sys=0.03, real=3.58 secs]
  7. 94.474: [GC[YG occupancy: 466937 K (629120 K)]
  8. 94.474: [Rescan (parallel) , 0.1850000 secs]
  9. 94.659: [weak refs processing, 0.0000370 secs]
  10. 94.659: [scrub string table, 0.0011530 secs]
  11. [1 CMS-remark: 734079K(1398144K)]
  12. 1201017K(2027264K), 0.1863430 secs]
  13. [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 收集器根据以往的历史记录推算下一次新生代垃圾收集可能持续的时间。1 这个例子中,CMS 收集器计算出的时长大约是 4.2 秒。所以 2.1 秒之 后(即 94.4 秒),CMS 收集器停止了预清理阶段(这种行为被称为“放弃”了这次回收,不过这可能是唯一能停止该次回收的方式)。这之后,CMS 回收器终于开始了标记阶段的工作执行,标记阶段的回收工作将应用程序线程暂停了 0.18 秒(在可中断预清理过程中,应用程序线程不会被暂停)。

1此处原文可能有误。根据新生代垃圾收集器的历史能得到的应该是新生代垃圾收集的持续时间,而非准确的什么时候发生。——译者注

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

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

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

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

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

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

  1. 98.049: [GC 98.049: [ParNew: 629120K->69888K(629120K), 0.1487040 secs]
  2. 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):

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

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

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

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

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

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

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

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

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

6.2 理解CMS收集器 - 图3 快速小结

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

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

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

6.2.1 针对并发模式失效的调优

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

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

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

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

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

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

自适应调优和 CMS 垃圾搜集

CMS 收集器使用两个配置 MaxGCPauseMllis=NGCTimeRatio=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 周期启动时,老年代空间的占用情况如下所示:

  1. 89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
  2. 772530K(2027264K), 0.0830120 secs]
  3. [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

2原文可能有误,CMSInitiatingOccupancyFraction 应该设置得比活跃数据小才能提前触发 CMS 周期。——译者注

2. 调整CMS后台线程

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

  1. ConcGCThreads = (3 + ParallelGCThreads) / 4

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

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

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

6.2 理解CMS收集器 - 图4 快速小结

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

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

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

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

6.2.2 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)。

6.2.3 增量式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 的值默认为真,即开启)。

6.2 理解CMS收集器 - 图5 快速小结

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

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