目录
可中断预清理concurrent abortable preclean
概述
CMS垃圾收集器是一款优秀的老年代并发垃圾收集器,通过与用户线程并发执行的方式减少GC停顿的时间。本文主要聊一下CMS设计到的相关的数据结构、具体的执行过程、运行中会出现的异常情况。
在CMS之前并行垃圾收集器通过下图方式进行,虽然GC阶段多线程并行执行单此时用户线程是完全暂停的。如果GC时间过长,将引发服务响应超时、调用接口超时等各类异常。
而CMS垃圾收集器大部分时间GC线程与用户线程并发执行,只有在初始标记和重新标记阶段才暂停用户线程
总体思路:当达到GC条件时,开始并发标记存活的对象,并发的过程中记录对象引用关系的变化。并发标记结束后,暂停用户线程,处理引用关系变化,得到所有存活的对象和可以清理的对象。最终并发清理掉不被使用的对象(存在已经不被引用但本次清理不掉的对象)。
执行过程
初始标记initial mark
暂停用户线程,标记GC root和新生代直接关联的对象。
这里的GC root关联的对象包含虚拟机栈中引用的对象、类静态属性引用的对象、本地方法栈中JNI引用的对象。另外,CMS是老年代的垃圾收集器,被新生代引用的对象应该被标记为存活,所以这里还包含新生代对象。
并发标记 concurrent mark
启动用户线程,三色遍历法遍历标记老年代的所有对象。
三色表示
- 黑色对象:自己被标记,且引用对象已经处理完成。
- 灰色对象:自己被标记,但引用对象未处理。
- 白色对象:没有对它做标记。
标记过的意思就是认为这个对象是存活的,本次GC不回收这个对象。
遍历算法
说到对象的遍历方式,自然而然会想到借助栈或队列进行图遍历。对象之间的关系正好构建成一个图,对象就是节点,引用关系就是边。但是图遍历有这样一个问题: GC root和新生代的对象都要装进栈或队列当中,可能会导致占用特别大的额外空间。
为了避免上的问题,CMS采用了线性遍历的方式。
首先,引入一个bit数组,用它表示内存中每个位置的状态,每个bit位代表4字节的内存空间,所以它的大小是固定的。
遍历的策略也直接引用论文中的图片,感兴趣的同学可以细品~
遍历bitmap,找到被标记为存活的对象cur,将它压入栈中,然后开始遍历这个对象出发的所有对象。遍历栈的过程中,如果遇到地址比cur低的对象则标记并压如栈中,遇到地址比cur高则只标记不入栈。所有GC root引用的对象已经在初始标记阶段标记成了存活对象,遍历过程遇到其中一个就开始利用栈遍历它及它之前的所有对象,这就保证了栈中最多有1个GC root直接引用的对象,有效控制了栈空间的大小。
标记过程举例
标记的过程伴随着对象引用的修改,下图举例说明了并发标记的过程
- 初始标记(图中未体现,参考步骤a):初始标记时对a进行了标记。
- 步骤a:对a引用的对象bc,及b引用的对象e进行标记,根据当时的对象引用关系,abcegd是存活的。
- 步骤b:对象引用关系发生了改变,b不再引用c新增引用d,g不再引用d
- 步骤c:完成了并发标记的过程,abceg被标记。第二个和第四个区域内的对象引用关系发生了改变,被记录了下来。这里面运用到了card table、mod union table数据结构和write barrier技术,后面进行说明。
- 步骤d:实际上是重新标记后的结果,可以看到对象d在并发标记结束时未进行标记,但是它还在被对象b引用,不应该回收。这就依赖重新标记阶段对dirty card(对象引用关系发生变化的区域)的处理。
从上面的例子中可以看到CMS的一些特性:
- 初始标记时,不可达的对象,在本次GC中一定会被回收,如对象f
- 已经标记过的对象,即使最终已经不被引用本次GC也不会回收,如对象c
并发过程中变化的维护
card table与mod union table
card table是一个数组,数组中每个位置存的是一个byte,每个比特位有不同的作用(具体的作用可能得研究下源码,没有找到相关的资料)。CMS将老年代的空间分成大小为512bytes的块,card table中的每个元素对应着一个块。
对于新生代:它记录老年代到新生代的引用,younggc时不用遍历整个老年代
对于老年代:它记录并发标记开始引用发生变化的card,并发标记结束后需要处理这些card
由于新生代GC与老年代GC同时使用card table,所以会出现冲突的情况。新生代GC时,发现老年代的dirty card(card的一种状态)没有指向新生代的引用,会将这个card设置为clean(改变了老年代对象引用发生设置的状态),但这个card必须在remark阶段进行重新标记。所以增加了另一个数据结构mod union table解决此问题。
mod union table是一个bit位向量,一个bit表示一个card的状态。
它由新生代垃圾收集器维护,新生代GC将card设置为clean之前,把mod union table设置为dirty。card table状态为dirty、或者mod union table标记为dirty、或者同时两种数据结构都标记为dirty的card表示并发标记阶段引用发生了变化,需要在后面的阶段进行处理。
write barrie
write barrie写屏障类似于一个切面,用户线程写对象引用的时候就触发write barrier的逻辑,将对象所处的card设置为dirty。
并发预清理concurrent preclean
处理dirty card,降低remark阶段暂停时间。
重新标记的过程是STW的,所以为了缩短停顿时间,在并发标记之前应该尽可能多的完成重新标记阶段的工作。并发预清理就是对dirty card进行遍历处理,降低重新标记需要处理的dirty card的数量。
可中断预清理concurrent abortable preclean
继续处理dirty card,满足条件时停止当前阶段,进入下一阶段。
可中断预清理也会处理dirty card,替remark阶段分担一部分工作。这个阶段更主要的目的是控制remark和新生代GC分开执行,避免连续两次暂停导致总的暂停时间过长,其中运用了一些策略。
预清理阶段结束之后,如果Eden空间大于CMSScheduleRemarkEdenSizeThreshold(默认2M),则进入可中断预清理阶段。
当Eden空间达到CMSScheduleRemarkEdenPenetration(默认50%)时进入remark阶段。
如果等待超过了CMSMaxAbortablePrecleanTime(默认5s)同样进入remark阶段。
另外,还有个CMSMaxAbortablePrecleanLoops参数可以控制可中断预清理循环的次数,到达次数则退出预清理阶段进入remark,默认是0不限制次数。
重新标记final remark
暂停用户线程,从GC root(包含新生代对象)出发重新标记,并处理完所有dirty card。
并发阶段,老年代可能面对如下变化,重新标记后全部确定哪些是存活的或者不存活的
- 晋升到老年代的对象
- 直接分配到老年代的对象
- 老年代中引用关系改变的对象
- 新生代到老年代引用关系的改变
- GC root到老年代引用关系的改变
新生代大小的影响和控制:
重新标记阶段需要遍历新生代对象,但新生代里大多都是垃圾,如果remark之前发生一次新生代GC,则会大大减小remark阶段需要遍历的对象数量。可以设置CMSScavengeBeforeRemark参数强制在remark之前执行一次新生代GC。但是正如可中断预清理阶段的分析,新生代GC也是有停顿的,这样两次停顿连在一起也可能会很长,需要进行权衡。
并发清除concurrent sweep
并发清除标记为不可达的对象,回收并合并空闲内存。
并发重置concurrent reset
重新设置CMS相关的各种状态及数据结构,为下一个垃圾收集周期做好准备。
缺点
cpu资源敏感,降低吞吐量
CMS没有运行的时候所有全部cpu资源都供用户线程使用,CMS开始并发运行后就要跟用户线程竞争cpu资源,导致应用线程运行变慢。对于cpu资源非常紧缺的系统,假设只有2核,CMS运行起来后将占用一半的cpu资源,用户线程将感知到运行速度减半。
并发带来的好处是可以降低用户线程的停顿时间,对于在线服务类应用非常有益,因为长时间的停顿可能导致响应超时等问题。但相对于非并发垃圾收集器,CMS整个周期内很多工作是重复的(比如重新标记阶段对dirty card中的对象重新标记,而在并发标记阶段可能已经标记过了),导致整体的吞吐量是降低的。
浮动垃圾
因为CMS垃圾收集器的特性,被标记过的对象,即使最终变成垃圾本次GC也不会回收它,这些垃圾就是浮动垃圾。浮动垃圾的产生意味着内存里不光装着存活对象,还要装着这些浮动垃圾,所以容纳同样多的存活对象CMS需要占用更大的内存空间。
内存碎片
CMS使用标记清除算法,收集结束之后会产生大量内存碎片。当有大对象需要分配空间时,可能总的空间大小是足够的,但是没有连续的空间装下此对象。
CMS默认开启UseCMSCompactAtFullCollection 参数,在FullGC时进行内存碎片的合并整理。内存碎片虽然解决了,但负面影响就是停顿时间变长了。还有另外一个CMSFullGCsBeforeCompaction参数可以控制多少次FullGC才会进行整理,默认是0代表每次FullGC都会进行碎片整理。
运行过程常见问题
concurrent mode failure
并发虽好,但会引入一些问题。对于非并发的垃圾收集器,可以等到老年代无法分配对象时再执行GC。但对于cms则需要预留出空间提前开始GC,预留的空间供并发期间新对象的分配及新生代对象的晋升使用。如果在老年代分配对象发现老年代装不下,则会触发concurrent mode failure,此时将会暂停用户线程执行FullGC或者串行模式的CMS。
promotion failed
这个错误涉及到CMS担保机制,新生代GC之前会根据历史晋升到老年代对象的大小,预估本次老年代是否足够容纳新生代晋升的对象。如果预估时空间足够,但新生代GC实际执行时发现容纳不了,则会引起promotion failed错误。
OutOfMemoryError
CMS垃圾收集器发现大部分时间都浪费在GC上就会抛出OutOfMemoryError异常,具体为98%的时间在GC但回收不到2%的空间。这样做实际上是为了防止程序进入一种虽然在运行实际上一直在GC假死状态,也可以通过设置-XX:-UseGCOverheadLimit禁用该机制。
阅读建议:不管是大V或是小号都会有自己的关注点、忽略的点,都有自己理解到位以及理解有偏差的地方。如果有缘读到本文,建议参考下官方文档、论文。至于源码,阅读成本比较高,感觉看一下自己感兴趣的地方就可以了,网上有一些源码理解的分享。
官网:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html
论文:https://www.cs.purdue.edu/homes/hosking/ismm2000/papers/printezis.pdf