CMS垃圾收集器

1,599 阅读8分钟

一、收集过程

1. 初始标记(Initial Mark)

标记出gc roots直接相连的对象,STW

2. 并发标记(Concurrent Mark)

该阶段多个gc线程与用户线程并发执行,从初始标记阶段标出的对象开始进行Tracing,标记出可达对象。

因为与用户线程并发执行,所以在这个阶段有可能会出现新生代晋升到老年代直接在老年代分配对象老年代对象引用关系发生变化等情况,这样就会造成某些对象的漏标现象,所以在并发标记之后还需要重新标记。为了提高标记效率,在这个阶段会维护一个card tablemod union table两个数据结构,将在这个过程中有引用关系更新的card标记为dirty,这样在之后重新标记阶段就只需要扫描dirty card里的对象,标记可达的对象,而并不需要扫描整个堆。

3. 预清理(Concurrent Precleaning)

该阶段由参数CMSPrecleaningEnabled控制,默认是开启的。也是与用户线程并发执行。这一阶段主要做的是根据card table和mod union table找到dirty card并扫描dirty card,根据dirty card中引用关系发生变化的对象标记出可达对象,处理完dirty card之后会将card置为clean,也就是清除card table和mod union table中对应的位。

4. 可中断的预清理(Concurrent Abortable Preclean)

该阶段同样是与用户线程并发执行。该阶段发生的前提是新生代Eden区的内存使用量大于参数CMSScheduleRemarkEdenSizeThreshold的值,默认是2M。如果新生代的内存使用量太小,则没有必要执行该过程。

目的是在重新标记之前尽最大努力那些在并发阶段被用户线程修改的老年代对象,减轻重新标记的负担,降低STW时间,这也是CMS gc的目标,降低停顿时间。

该阶段主要是循环地做两件事:

  1. 遍历survivor中的对象,标记可达的老年代对象;
  2. 和预清理一样,扫描dirty card,标记可达对象。

循环终止条件:

  1. 达到循环次数,通过参数CMSMaxAbortablePrecleanLoops设置循环次数,默认为0,表示无限循环。
  2. 达到运行最大时间,默认是5s,通过参数CMSMaxAbortablePrecleanTime设置。
  3. 新生代Eden区使用率达到阈值CMSScheduleRemarkEdenPenetration,默认是50%,该条件成立的前提是在Precleaning时,Eden区的使用率小于10%。

如果在循环退出之前,发生一次YGC,对后面的重新标记来说将大大减轻扫描年轻代的负担,但是这不是我们能控制的。

5. 重新标记(Final Remark)

该阶段是STW的,多个gc线程并行。该过程主要做的事:

  1. 遍历GC Roots重新标记;
  2. 遍历新生代,重新标记;(通过gc roots应该是可以遍历新生代的)
  3. 遍历dirty card,重新标记。

在这个过程中,遍历新生代会比较耗时,所以如果能在重新标记之前执行一次YGC,将会减轻负担,通过参数CMSScavengeBeforeRemark设置在重新标记之前强制执行一次YGC,但是如果在可中断的预清理阶段已经发生过YGC了,那么再执行一次YGC只会增加更多的停顿时间,而且回收效果很差,默认该参数是关闭的。

6. 并发清理(Concurrent Sweep)

与用户线程并发执行,清理垃圾对象。

7. 并发重置(Concurrent Reset)

重置CMS算法的内部数据结构,并为下一个周期做好准备。

二、card table

jvm在逻辑上将堆划分成大小相等的区域,称为card,默认大小为512字节,并将这些card映射到一个名为card table的字节数组上,用一个字节表示一个card,通过 (address >> 9)可以得到某个card所在table中的index,进而得到该card的状态。card table的目的是记录跨代引用关系,实际上是记录老年代到新生代的引用关系,因为新生代变化很快,记录新生代到老年代的引用关系没有意义。实际上,如果某个card中的对象的引用关系发生了变化,jvm并不会检查该对象是否引用了新生代对象,那么该对象所在的card将在table中置为dirty,这样YGC只需要扫描dirty card里的对象就能标记出新生代的可达对象,避免了扫描整个老年代。如果YGC在dirty card中没有找到到新生代对象的引用,将把该card置为clean

cms在进行老年代回收时会复用card table,用来记录下并发标记阶段引用发生改变的老年代对象。但是如果这个过程中发生YGC,YGC将有可能修改card table,将某个card置为clean,cms就会丢失这部分信息,所以jvm在cms的并发标记过程中还会维护一个mod union table,该table是一个bit数组,如果YGC修改了card table中某一位,同时会将mod union table中对应的位置为1,这样cms就不会丢失在并发过程中引用关系发生变化的对象。只需要同时扫描card table和mod union table,只有某个card在这两个table中的任一个中被标记为dirty,都会被重新扫描。

Write Barrier

Write Barrier可以理解为在写的时候插入一条特定的操作。 在CMS中老年代引用年轻代的时候就是通过触发一个Write Barrier来更新Card Table的标志位。这是一个同步操作,在更新引用的时候顺带执行,只需要两个指令,引入的消耗不大。

三、cms的缺点

1. 多并发会抢占用户线程的CPU资源

由于cms是多线程收集器,所以在并发标记和清理阶段会抢占用户线程的CPU资源,默认cms gc线程数=(cpu核数 + 3)/4

2. 无法处理浮动垃圾

由于和用户线程并发处理,如果在这个过程中,一个对象被标记为可达,但是之后用户线程抛弃了该对象的引用,那么这个对象将不能在本次gc中被回收,这些对象被称为浮动垃圾

3. 有Concurrent Mode Failure的风险

由于cms是并发垃圾收集器,所以不能等到用户线程把老年代填满才进行垃圾回收,cms将预留一部分内存留作收集过程中使用,如果在cms执行的过程中,预留内存无法满足程序需要,就会出现"Concurrent Mode Failure",这时候会启动后备预案,临时启用Serial Old收集器重新进行老年代垃圾收集,这样停顿时间就更长了。可以通过参数 -XX:CMSInitiatingOccupancyFraction进行设置,默认为92%。

4. 产生内存碎片

cms是基于“标记-清除”算法实现的垃圾收集器,所以会产生内存碎片,这样即使老年代剩余内存还很多,但是如果没有一块连续内存来满足新对象分配的需求时将进行Full GC,Full GC也是使用SerialOld来进行老年代垃圾回收。

四、CMS GC触发的条件

如果在老年代对象分配失败,会触发gc。此外,cms后台线程每隔2s执行一次是否需要进行gc的判断,判断的条件如下:

  1. 如果没有设置参数 -XX:UseCMSInitiatingOccupancyOnly,则cms会根据收集到的信息来判断是否需要一次gc,判断逻辑是在进行gc的时间里,用户线程是否会把老年代填满。这些判断是需要历史cms gc的统计指标的,但是在第一次gc的时候并没有数据可以参考,所以会根据老年代使用率来判断,这个使用率默认是50%。
  2. 如果设置了**-XX:UseCMSInitiatingOccupancyOnly**,则仅根据老年代内存使用率来判断,当内存使用率达到**-XX:CMSInitiatingOccupancyFraction**,则进行垃圾回收。
  3. 判断晋升担保是否会失败,判断条件是,老年代是否能够容纳当前新生代的所有对象或者能否容纳历史晋升到老年代对象的平均值,如果两个条件都不满足,则开始一次垃圾回收。
  4. 根据meta space判断,这里主要看 metaspace 的 shouldconcurrent_collect 标志,这个标志在 meta space 进行扩容前如果配置了 CMSClassUnloadingEnabled 参数时,会进行设置。这种情况下就会进行一次 CMS GC。因此经常会有应用启动不久,Old Gen 空间占比还很小的情况下,进行了一次 CMS GC,让你很莫名其妙,其实就是这个原因导致的。

五、cms调优

...待续