首发于java虚拟机
剖析CMS收集原理

剖析CMS收集原理

前面聊了G1与ZGC


现在来聊聊另一款GC收集器:CMS

前言

CMS,全称Concurrent Low Pause Collector,是jdk1.4后期版本开始引入的新gc算法,在jdk5和jdk6中得到了进一步改进,它的主要适合场景是对响应时间的重要性需求 大于对吞吐量的要求,能够承受垃圾回收线程和应用线程共享处理器资源,并且应用中存在比较多的长生命周期的对象的应用。CMS是用于对tenured generation的回收,也就是年老代的回收,目标是尽量减少应用的暂停时间,减少full gc发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代。在我们的应用中,因为有缓存的存在,并且对于响应时间也有比较高的要求,因此希望能尝试使用CMS来替代默认的server型JVM使用的并行收集器,以便获得更短的垃圾回收的暂停时间,提高程序的响应性。

需要注意的是很多人以为CMS只是针对老年代的一款GC收集器,其实CMS除了收集老年代之外还可以收集新生代(young generation),只不过收集新生代时采用的并行复制算法与Parallel New GC算法是一样的而已。

并发收集

CMS提出时最大的创新在于其针对老年代并发收集的理念,下面来分析并发收集的过程。根据oracle提供的关于CMS的官方文档描述

整个并发收集通常包括以下步骤:

  1. 初始化标记
  2. 并发标记
  3. 重新标记
  4. 并发清除
  5. 并发重置

上述步骤只有初始化标记和重新标记会STW(Stop The World),其余三个步骤与应用程序mutator都是并发的,下面来看每个步骤具体的细节。


初始化标记

暂停应用程序线程,遍历GC ROOTS直接可达的对象并将其压入标记栈(mark-stack)。标记完之后恢复应用程序线程。


并发标记

这个阶段虚拟机会分出若干线程(GC 线程)去进行并发标记。标记哪些对象呢,标记那些GC ROOTS最终可达的对象。具体做法是推出标记栈里面的对象,然后递归标记其直接引用的子对象,同样的把子对象压到标记栈中,重复推出,压入。。。直至清空标记栈。这个阶段GC线程和应用程序线程同时运行。

当GC线程进行并发操作时,应用程序可能会进行新增对象、删除对象、变更对象引用等一系列操作。这种条件下可能会出现活动对象的漏标的情况,比如下面场景:

活动对象被遗漏标记

A是活动对象,A->B,标记B可达,将其压入标记栈,此时A所有直接子对象遍历完,A出栈,标记线程将不会再访问A。

同时应用程序移掉了B对C的引用,让A重新引用C。

B出栈时无法标记C可达,A虽然引用C但标记线程不会再访问A,此时C会被当成不可达对象

所以单纯的并发标记操作并不能保证GC的正确性,所以还需要额外的操作,这个操作就是write barrier

Write barrier就是当改写一个引用时:

A.x = C

执行一些额外操作。如果是上面场景可以假设为:

  write_barrier(obj, field, newobj){
        if(newobj.mark == FALSE)
            newobj.mark = TRUE
            push(newobj, $mark_stack)
        *field = newobj
    }

即当赋值引用时,如果赋值的对象还没有被标记,将标记该对象将其压入标记栈。

在使用Write bariier之后同样的情景就不会出现活动对象被遗漏的情况了

活动对象不会被遗漏



所以我们知道并发标记阶段,并不是只有单纯的并发标记,还有一个额外的write barrier操作,避免活动对象被漏标。


重新标记

重新标记可以理解成一个同步刷新对象间引用的操作,整个过程是STW。在并发标记其间,应用程序不断变更对象引用,此时的GC ROOTS有可能会发生变化,这个时候需要同步更新这个增量变化。于是重新从当前的GC ROOTS和指针更新的区域出发(mod-union table)再进行一次标记,所以这个过程被叫作重新标记。需要注意的是:已经标记的对象是不会再遍历一次,标记线程识别对象在并发阶段已经标记过了,就会跳过该对象。所以重新标记只会遍历那些新增没有标记过的活动对象和其间有指针更新的活动对象,如果指针更新频繁,重新标记很有可能会遍历新生代中的大部分甚至全部对象。所以如果重新标记阶段很慢,可以启动一次YGC,来减少并发标记的工作量减少其停顿时间。


并发清除

重新标记结束后,应用程序继续运行,此时分出一个处理器去进行垃圾回收工作。

老年代的对象通常是存活时间长,回收比例低,所以采用的回收算法是标记-清除。这个阶段GC回收线程是遍历整个老年代,遇到没有被标记的对象(垃圾)就清空掉相应的内存块,并加入可分配列表。遇到被标记的对象保持原来的位置不动,只是重置其标记位,用于下一次GC。


并发重置

oracle官方文档中描述这个阶段的工作是:重新调整堆的大小,并为下一次GC做好数据结构支持,比如重置卡表的标位,具体细节有待考证。


卡表

CMS中一个与YGC相关并十分重要的数据结构是:卡表(card table)。之所以出现卡表这样的一个数据结构是因为:YGC时为了标记活动标记对象除了tracing GC ROOTS之外, 别忘了老年代里也可能会引用新生代对象。所以正常来说还要扫描一次老年代,如果是扫描整个老年代这将会随着堆的增大变得越来越慢,特别是现在内存都越来越大了。所以为了提升性能就引入卡表。

卡表提升性能的原理:逻辑上把老年代内存分成一个个大小相等的卡片(card,论文中提到适合大小是128个字节),然后对每个卡片准备一个与其对应的标记位,并将这些位集中起管理就好像一个表格(mark table)一样,当改写对象引用是从老年代指向新生代时,在老年代对应的卡片标记位上设置标志位即可,通常这样的卡片我们称之为dirty card。这项操作可以通过上面的提到的write barrier来实现,这样就算对象跨多张卡片也不会有什么问题。卡表通常是用byte数组实现的,byte的值只能取[0,1]这两种。所以btye[i] = 1 就表示第i + 1 卡片所在内存上有指向新生代引用的老年代对象,这时只要tracing这个卡片上的对象即可。如果每个card大小的是128字节(1024位,)那卡表就只占整个老年代的1/1024之一。所以遍历卡表的时间会远比遍历整个老年代快得多!这其中背后思想就是典型以空间换时间的思路!这种思路在G1中也有体现,只不其对应的数据是remember set而已。

卡片标记


标记-清除VS标记-压缩

对于老年代来说除了标记-清除(mark-sweep)算法之外,还有标记-整理(mark-compact)。一般通常是采用标记-清除,因为不需要移动对象。老年代回收比例低,采用标记-整理则需要移动对象来将其压缩到一边,故性能不划算。

不过需要的注意的是标记-清除会带来的影响就是内存碎片化,当内存中碎片过多时,就不得不进行内存压缩了。如果并发收集所回收到的空间赶不上分配的需求,就会回退到使用serial GC的mark-compact算法做full GC,这时候GC带来的暂停可能会比较长,这种情况又被称为并发模式失败(Concurrent Mode Failure)


停顿调度

因为YGC和OGC的暂停通常是独立的,如果YGC后面又紧接着一个OGC,则又变成了一个长时间的暂停。所以CMS会在YGC重新标记之间做一个暂停调度,YGC暂停后不会马上进入重新标记,而是有一个时间间隔。YGC和初始化标记则没有这样的限制,因为初始化标记通常的暂停很短。

YGC

前面提到CMS的YGC与Parallel New GC算法一致,大致可以理解为serial 收集器的多线程版。YGC的过程STW,采用多线程进行标记和复制清除,以减少停顿时间。回收的线程数可通过-XX:ParallelGCThreads来配置,默认的配置逻辑是:小于8核时按CPU核数,大于8核时公式:8+( Processor - 8 ) ( 5/8 )

应对内存碎片化

采用了标记-清除就永远跳不过碎片化这个坑,目前CMS的解决办法是在Full GC时进行内存压缩。有两个参数配置:UseCMSCompactAtFullCollection 每一次Full GC都进行压缩,CMSFullGCsBeforeCompaction 经过多少次不压缩的Full GC后,执行一次带压缩的Full GC。

并发收集带来的影响

  1. cpu资源的占用,因为并发,CMS至少占用一个处理器的份额。如果是重计算的应用,吞吐量可能会有不少的下降。
  2. 浮动垃圾,并发收集的过程中为了保证GC的正确性(保证存活的对象不被回收),一些本应该可以回收的对象会被标记成活动对象,逃过GC。
  3. 吞吐量的下降。影响因素主要有两点:1、cpu的占用 2、write barrier的额外操作。

所以决定使用CMS之前,先考量一下应用程序是不是符合前言中描述的特点。

关于CMS的原理,就介绍到这里了,后面看有没有时间弄个G1的详细对比。

编辑于 2019-01-09 16:48