CMS垃圾收集器

15,455 阅读12分钟

CMS是老年代垃圾收集器,在收集过程中可以与用户线程并发操作。它可以与Serial收集器和Parallel New收集器搭配使用。CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。可以通过JVM启动参数:-XX:+UseConcMarkSweepGC来开启CMS。

CMS收集过程

CMS 处理过程有七个步骤:

  1. 初始标记(CMS-initial-mark) ,会导致stw;
  2. 并发标记(CMS-concurrent-mark),与用户线程同时运行;
  3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;
  5. 重新标记(CMS-remark) ,会导致swt;
  6. 并发清除(CMS-concurrent-sweep),与用户线程同时运行;
  7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行; 其运行流程图如下所示:
CMS收集过程

初始标记

这是CMS中两次stop-the-world事件中的一次。这一步的作用是标记存活的对象,有两部分:

  1. 标记老年代中所有的GC Roots对象,如下图节点1;
  2. 标记年轻代中活着的对象引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)如下图节点2、3;
CMS初始标记
在Java语言里,可作为GC Roots对象的包括如下几种: 1. 虚拟机栈(栈桢中的本地变量表)中的引用的对象 ; 2. 方法区中的类静态属性引用的对象 ; 3. 方法区中的常量引用的对象 ; 4. 本地方法栈中JNI的引用的对象; ps:为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,-XX:+CMSParallelInitialMarkEnabled,同时调大并行标记的线程数,线程数不要超过cpu的核数。

并发标记

从“初始标记”阶段标记的对象开始找出所有存活的对象; 因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代; 并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理; 如下图所示,也就是节点1、2、3,最终找到了节点4和5。并发标记的特点是和应用程序线程同时运行。并不是老年代的所有存活对象都会被标记,因为标记的同时应用程序会改变一些对象的引用等。 由于这个阶段是和用户线程并发的,可能会导致concurrent mode failure。

CMS并发标记

预清理阶段

前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Dirty的Card 如下图所示,在并发清理阶段,节点3的引用指向了6;则会把节点3的card标记为Dirty;

CMS预清理
最后将6标记为存活,如下图所示:
CMS预清理

可终止的预处理

这个阶段尝试着去承担下一个阶段Final Remark阶段足够多的工作。这个阶段持续的时间依赖好多的因素,由于这个阶段是重复的做相同的事情直到发生abort的条件(比如:重复的次数、多少量的工作、持续的时间等等)之一才会停止。 ps:此阶段最大持续时间为5秒,之所以可以持续5秒,另外一个原因也是为了期待这5秒内能够发生一次ygc,清理年轻带的引用,是的下个阶段的重新标记阶段,扫描年轻带指向老年代的引用的时间减少;

重新标记

这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。 这个阶段,重新标记的内存范围是整个堆,包含_young_gen和_old_gen。为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做cms的“gc root”,来扫描老年代; 因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作“GC ROOTS”:当此阶段耗时较长的时候,可以加入参数-XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次ygc,回收掉年轻带的对象无用的对象,并将对象放入幸存带或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存带非常小,这大大减少了扫描时间。 由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。 另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled。

并发清理

通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。 这个阶段主要是清除那些没有标记的对象并且回收空间; 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

使用CMS需要注意的几点

减少remark阶段停顿

一般CMS的GC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数: -XX:+CMSScavengeBeforeRemark。 在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。

内存碎片问题

CMS是基于标记-清除算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片,这时候我们需要用到这个参数: -XX:CMSFullGCsBeforeCompaction=n 意思是说在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做压缩。 如果把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的full GC才做一次压缩。

concurrent mode failure

这个异常发生在cms正在回收的时候。执行CMS GC的过程中,同时业务线程也在运行,当年轻带空间满了,执行ygc时,需要将存活的对象放入到老年代,而此时老年代空间不足,这时CMS还没有机会回收老年带产生的,或者在做Minor GC的时候,新生代救助空间放不下,需要放入老年代,而老年代也放不下而产生的。 设置cms触发时机有两个参数:

  • -XX:+UseCMSInitiatingOccupancyOnly
  • -XX:CMSInitiatingOccupancyFraction=70

-XX:CMSInitiatingOccupancyFraction=70 是指设定CMS在对内存占用率达到70%的时候开始GC。 -XX:+UseCMSInitiatingOccupancyOnly如果不指定, 只是用设定的回收阈值CMSInitiatingOccupancyFraction,则JVM仅在第一次使用设定值,后续则自动调整会导致上面的那个参数不起作用。

为什么要有这两个参数? 由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。 CMS前五个阶段都是标记存活对象的,除了”初始标记”和”重新标记”阶段会stop the word ,其它三个阶段都是与用户线程一起跑的,就会出现这样的情况gc线程正在标记存活对象,用户线程同时向老年代提升新的对象,清理工作还没有开始,old gen已经没有空间容纳更多对象了,这时候就会导致concurrent mode failure, 然后就会使用串行收集器回收老年代的垃圾,导致停顿的时间非常长。 CMSInitiatingOccupancyFraction参数要设置一个合理的值,设置大了,会增加concurrent mode failure发生的频率,设置的小了,又会增加CMS频率,所以要根据应用的运行情况来选取一个合理的值。如果发现这两个参数设置大了会导致full gc,设置小了会导致频繁的CMS GC,说明你的老年代空间过小,应该增加老年代空间的大小了。

promotion failed

在进行Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的,多数是由于老年带有足够的空闲空间,但是由于碎片较多,新生代要转移到老年带的对象比较大,找不到一段连续区域存放这个对象导致的。

过早提升与提升失败

在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步,如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。

早提升的原因

  1. Survivor空间太小,容纳不下全部的运行时短生命周期的对象,如果是这个原因,可以尝试将Survivor调大,否则端生命周期的对象提升过快,导致老年代很快就被占满,从而引起频繁的full gc;
  2. 对象太大,Survivor和Eden没有足够大的空间来存放这些大对象。

提升失败原因

当提升的时候,发现老年代也没有足够的连续空间来容纳该对象。为什么是没有足够的连续空间而不是空闲空间呢?老年代容纳不下提升的对象有两种情况:

  1. 老年代空闲空间不够用了;
  2. 老年代虽然空闲空间很多,但是碎片太多,没有连续的空闲空间存放该对象。

解决方法

  1. 如果是因为内存碎片导致的大对象提升失败,cms需要进行空间整理压缩;
  2. 如果是因为提升过快导致的,说明Survivor 空闲空间不足,那么可以尝试调大 Survivor;
  3. 如果是因为老年代空间不够导致的,尝试将CMS触发的阈值调低。

CMS相关参数

参数 类型 默认值 说明
-XX:+UseConcMarkSweepGC boolean false 老年代采用CMS收集器收集
-XX:+CMSScavengeBeforeRemark boolean false The CMSScavengeBeforeRemark forces scavenge invocation from the CMS-remark phase (from within the VM thread as the CMS-remark operation is executed in the foreground collector).
-XX:+UseCMSCompactAtFullCollection boolean false 对老年代进行压缩,可以消除碎片,但是可能会带来性能消耗
-XX:CMSFullGCsBeforeCompaction=n uintx 0 CMS进行n次full gc后进行一次压缩。如果n=0,每次full gc后都会进行碎片压缩。如果n=0,每次full gc后都会进行碎片压缩
–XX:+CMSIncrementalMode boolean false 并发收集递增进行,周期性把cpu资源让给正在运行的应用
–XX:+CMSIncrementalPacing boolean false 根据应用程序的行为自动调整每次执行的垃圾回收任务的数量
–XX:ParallelGCThreads=n uintx (ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8) 并发回收线程数量
-XX:CMSIncrementalDutyCycleMin=n uintx 0 每次增量回收垃圾的占总垃圾回收任务的最小比例
-XX:CMSIncrementalDutyCycle=n uintx 10 每次增量回收垃圾的占总垃圾回收任务的比例
-XX:CMSInitiatingOccupancyFraction=n uintx jdk5 默认是68% jdk6默认92% 当老年代内存使用达到n%,开始回收。CMSInitiatingOccupancyFraction = (100 - MinHeapFreeRatio) + (CMSTriggerRatio * MinHeapFreeRatio / 100)
-XX:CMSMaxAbortablePrecleanTime=n intx 5000 在CMS的preclean阶段开始前,等待minor gc的最大时间。

总结

  1. CMS收集器只收集老年代,其以吞吐量为代价换取收集速度。
  2. CMS收集过程分为:初始标记、并发标记、预清理阶段、可终止预清理、重新标记和并发清理阶段。其中初始标记和重新标记是STW的。CMS大部分时间都花费在重新标记阶段,可以让虚拟机先进行一次Young GC,减少停顿时间。CMS无法解决"浮动垃圾"问题。
  3. 由于CMS的收集线程和用户线程并发,可能在收集过程中出现"concurrent mode failure",解决方法是让CMS尽早GC。在一定次数的Full GC之后让CMS对内存做一次压缩,减少内存碎片,防止年轻代对象晋升到老年代时因为内存碎片问题导致晋升失败。

参考资料

《深入理解Java虚拟机——JVM高级特性与最佳实践》-周志明