1. 什么是CMS?
CMS是一款并发、使用标记-清除算法的、针对老年代进行回收的GC。它将垃圾回收中的绝大部分工作与应用程序的线程一起并发执行,以期能最小化暂停时间. 通常多并发低暂停收集器收集器不复制或也不压缩存活的对象. 垃圾回收不移动存活的对象, 如果产生内存碎片问题,就会分配/占用更大的堆内存空间。
- 适用场景:GC过程短暂停,适合对时延要求较高的服务,用户线程不允许长时间的停顿。
注意: 年轻代使用的CMS收集器也和并行收集器采用一样的算法.
2. CMS的工作流程
(1) 初始标记 (Initial Mark):(Stop the World Event,所有应用线程暂停) 在老年代(old generation)中的对象, 如果从年轻代(young generation)中能访问到, 则被 “标记,marked” 为可达的(reachable).对象在旧一代“标志”可以包括这些对象可能可以从年轻一代。暂停时间一般持续时间较短,相对小的收集暂停时间. 该阶段进行可达性分析,标记GC ROOT能直接关联到的对象。
注意是直接关联,间接关联的对象在下一阶段标记。
(2) 并发标记 (Concurrent Marking):在Java应用程序线程运行的同时遍历老年代(tenured generation)的可达对象图。扫描从被标记的对象开始,直到遍历完从root可达的所有对象. 调整器(mutators)在并发阶段的2、3、5阶段执行,在这些阶段中新分配的所有对象(包括被提升的对象)都立刻标记为存活状态. 该阶段进行GC ROOT TRACING,在第一个阶段被暂停的线程重新开始运行。由前阶段标记过的对象出发,所有可到达的对象都在本阶段中标记。
(3) 并发预清理:并发预处理阶段做的工作还是标记,与(4)的再次标记功能相似。
既然相似为什么要有这一步?
前面我们讲过,CMS是以获取最短停顿时间为目的的GC。
重标记需要STW(Stop The World),因此重标记的工作尽可能多的在并发阶段完成来减少STW的时间。
此阶段标记从新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。
-
如何确定老年代的对象是活着的?
答案很简单,通过GC ROOT TRACING可到达的对象就是活着的。
继续延伸,如果存在以下场景怎么办:
答案是必须扫描新生代来确保。这也是为什么CMS虽然是老年代的gc,但仍要扫描新生代的原因。(注意初始标记也会扫描新生代)
重点来了:全量的扫描新生代和老年代会不会很慢?肯定会。
CMS号称是停顿时间最短的GC,如此长的停顿时间肯定是不能接受的。
如何解决呢?
必须要有一个能够快速识别新生代和老年代活着的对象的机制。
先说新生代。
你应该已经知道,新生代垃圾回收完剩下的对象全是活着的,并且活着的对象很少。
如果在扫描新生代前进行一次Minor GC,情况是不是就变得好很多?
CMS 有两个参数:CMSScheduleRemarkEdenSizeThreshold、CMSScheduleRemarkEdenPenetration,默认值分别是2M、50%。两个参数组合起来的意思是预清理后,eden空间使用超过2M时启动可中断的并发预清理(CMS-concurrent-abortable-preclean),直到eden空间使用率达到50%时中断,进入remark阶段。
如果能在可中止的预清理阶段发生一次Minor GC,那就万事大吉、天下太平了。
这里有一个小问题,可终止的预清理要执行多长时间来保证发生一次Minor GC?
答案是没法保证。道理很简单,因为垃圾回收是JVM自动调度的,什么时候进行GC我们控制不了。
但此阶段总有一个执行时间吧?是的。
CMS提供了一个参数CMSMaxAbortablePrecleanTime ,默认为5S。
只要到了5S,不管发没发生Minor GC,有没有到CMSScheduleRemardEdenPenetration都会中止此阶段,进入remark。
如果在5S内还是没有执行Minor GC怎么办?
CMS提供CMSScavengeBeforeRemark参数,使remark前强制进行一次Minor GC。
这样做利弊都有。好的一面是减少了remark阶段的停顿时间;坏的一面是Minor GC后紧跟着一个remark pause。如此一来,停顿时间也比较久。
实际上为了减少remark阶段的STW时间,预清理阶段会尽可能多做一些事情来减少remark停顿时间。
remark的rescan阶段是多线程的,为了便于多线程扫描新生代,预清理阶段会将新生代分块。
每个块中存放着多个对象,这样remark阶段就不需要从头开始识别每个对象的起始位置。
多个线程的职责就很明确了,把分块分配给多个线程,很快就扫描完。
遗憾的是,这种办法仍然是建立在发生了Minor GC的条件下。
如果没有发生Minor GC,top(下一个可以分配的地址空间)以下的所有空间被认为是一个块(这个块包含了新生代大部分内容)。
这种块对于remark阶段并不会起到多少作用,因此并行效率也会降低。
ok,新生代的机制讲完了,下面讲讲老年代。
老年代的机制与一个叫CARD TABLE的东西(这个东西其实就是个数组,数组中每个位置存的是一个byte)密不可分。
CMS将老年代的空间分成大小为512bytes的块,card table中的每个元素对应着一个块。
并发标记时,如果某个对象的引用发生了变化,就标记该对象所在的块为 dirty card。
并发预清理阶段就会重新扫描该块,将该对象引用的对象标识为可达。
举个例子:
并发标记时对象的状态:
但随后current obj的引用发生了变化:
current obj所在的块被标记为了dirty card。
随后到了pre-cleaning阶段,还记得该阶段的任务之一就是标记这些在并发标记阶段被修改了的对象么?之后那些通过current obj变得可达的对象也被标记了,变成下面这样:
同时dirty card标志也被清除。
老年代的机制就是这样。
不过card table还有其他作用。
还记得前面提到的那个问题么?进行Minor GC时,如果有老年代引用新生代,怎么识别?
(有研究表明,在所有的引用中,老年代引用新生代这种场景不足1%.原因大家可以自己分析下)
当有老年代引用新生代,对应的card table被标识为相应的值(card table中是一个byte,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的)。
所以,Minor GC通过扫描card table就可以很快的识别老年代引用新生代。
这里点一下,hotspot 虚拟机使用字节码解释器、JIT编译器、 write barrier维护 card table。
当字节码解释器或者JIT编译器更新了引用,就会触发write barrier操作card table.
再点一下,由于card table的存在,当老年代空间很大时会发生什么?(这里大家可以自由发挥想象)
至此,预清理阶段的工作讲完。
(4) 再次标记(Remark):(Stop the World Event, 所有应用线程暂停) 查找在并发标记阶段漏过的对象,这些对象是在并发收集器完成对象跟踪之后由应用线程更新的.
(5) 并发清理(Concurrent Sweep):回收在标记阶段(marking phases)确定为不可及的对象. 死对象的回收将此对象占用的空间增加到一个空闲列表(free list),供以后的分配使用。死对象的合并可能在此时发生. 请注意,存活的对象并没有被移动.
(6) 重置(Resetting):清理数据结构,为下一个并发收集做准备.
3.CMS的优缺点:
- 优势:并发收集,低停顿;由于在整个过程和中最耗时的并发标记和 并发清除过程收集器程序都可以和用户线程一起工作,所以总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
- 缺点:
(1)CMS收集器对CPU资源非常敏感
在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程使应用程序变慢,总吞吐量会降低,为了解决这种情况,虚拟机提供了一种“增量式并发收集器” 的CMS收集器变种, 就是在并发标记和并发清除的时候让GC线程和用户线程交替运行,尽量减少GC 线程独占资源的时间,这样整个垃圾收集的过程会变长,但是对用户程序的影响会减少。(效果不明显,不推荐)
(2):CMS处理器无法处理浮动垃圾
CMS在并发清理阶段线程还在运行, 伴随着程序的运行自然也会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS无法再当次过程中处理,所以只有等到下次gc时候在清理掉,这一部分垃圾就称作“浮动垃圾”
(3)CMS是基于“标记–清除”算法实现的,所以在收集结束的时候会有大量的空间碎片产生。空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc。
补充
CMS可能引发的问题:
- promotion failed(提升失败)
- concurrent mode failure(并发模式失败)
CMS解决内存碎片的方案:
可以让CMS在进行一定次数的Full GC(标记清除)的时候进行一次标记整理算法,CMS提供了以下参数来控制:
-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5
也就是CMS在进行5次Full GC(标记清除)之后进行一次标记整理算法,从而可以控制老年带的碎片在一定的数量以内,甚至可以配置CMS在每次Full GC的时候都进行内存的整理。
另外,有些应用存在比较大的对象朝生熄灭,这些对象在救助空间无法容纳,因此,会提早进入老年带,老年带如果有碎片,也会产生promotion failed, 因此我们应该控制这样的对象在新生代,然后在下次Minor GC的时候就被回收掉,这样避免了过早的进行CMS Full GC操作
其次,我们在线上尤其要注意的是concurrent mode failure出现的频率,这可以通过-XX:+PrintGCDetails来观察,当出现concurrent mode failure的现象时,就意味着此时JVM将继续采用Stop-The-World的方式来进行Full GC,这种情况下,CMS就没什么意义了,造成concurrent mode failure的原因是当minor GC进行时,旧生代所剩下的空间小于Eden区域+From区域的空间,或者在CMS执行老年带的回收时有业务线程试图将大的对象放入老年带,导致CMS在老年带的回收慢于业务对象对老年带内存的分配。
解决这个问题的通用方法是调低触发CMS GC执行的阀值,CMS GC触发主要由CMSInitiatingOccupancyFraction值决定,默认情况是当旧生代已用空间为68%时,即触发CMS GC,在出现concurrent mode failure的情况下,可考虑调小这个值,提前CMS GC的触发,以保证旧生代有足够的空间。