垃圾处理器-CMS

2021年7月6日 9点热度 0条评论

一、简介

CMS垃圾收集器是一款用于老年代的,使用复制-清除-整理算法的垃圾收集器。

二、GC阶段

1、初始化标记(STW)

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

2、并发标记

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

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

  1. A 是活动对象,A->B,标记 B 可达,将其压入标记栈,此时 A 所有直接子对象遍历完,A 出栈,标记线程将不会再访问 A。
  2. 同时应用程序移掉了 B 对 C 的引用,让A重新引用 C。
  3. 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 Bariier 操作,避免活动对象被漏标。

3、重新标记(STW)

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

4、并发清除

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

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

5、并发重置

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

三、知识点

1、卡表

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

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

2、Concurrent Mode Failure

如果 CMS 在清理掉垃圾对象之前,老年代中没有足够的空间存放新产生的对象,就会出现 Concurrent Mode Failure,

四、缺点

  1. 内存碎片(原因是采用了标记-清除算法)
  2. 对 CPU 资源敏感(原因是并发时和用户线程一起抢占 CPU)
  3. 浮动垃圾:在并发标记阶段产生了新垃圾不会被及时回收,而是只能等到下一次GC

然后我产生了一个疑问:既然重新标记可以修正并发标记阶段的变动,那么为何还有浮动垃圾问题?

由于标记阶段是从 GC Roots 开始标记可达对象,那么在并发标记阶段可能产生两种变动:

  1. 本来可达的对象,变得不可达了。(浮动垃圾)
  2. 本来不可达的内存,变得可达了。

浮动垃圾是可容忍的问题,而不是错误。那么为什么重新标记阶段不处理第一种变动呢?也许是由可达变为不可达这样的变化需要重新从 GC Roots 开始遍历,相当于再完成一次初始标记和并发标记的工作,这样不仅前两个阶段变成多余的,浪费了开销浪费,还会大大增加重新标记阶段的开销,所带来的暂停时间是追求低延迟的CMS所不能容忍的。

四、日志解读

# 年轻代 GC,使用 ParNew
0.210 [GC (Allocation Failure) [ParNew: 279616K->34942K(314560K), 0.0246537 secs] 279616K->79707K(1013632K), 0.0246999 secs] [Times: user=0.06 sys=0.09, real=0.03 secs]

# 老年代 GC

# 初始计标记,速度很快只用了3.1毫秒
# 372071K:老年代的使用量;699072K:老年代的总容量;413038K:堆的使用量;1013632K:整个堆的容量;0.0003105 secs:耗时
0.526: [GC (CMS Initial Mark) [1 CMS-initial-mark: 372071K(699072K)] 413038K(1013632K), 0.0003105 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

# 启动并发标记步骤
0.526: [CMS-concurrent-mark-start]
0.529: [CMS-concurrent-mark: 0.003/0.003 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]

# 启动预清理步骤,这个阶段会尽可能在重新标记前,处理掉一些在并发标记阶段发生变化的引用关系,从而降低重新标记阶段的停顿时间
# 清理 Eden 区中发生变化的引用(dirty card)
0.529: [CMS-concurrent-preclean-start]
0.530: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

# 启动可中断的预清理,这个阶段主要处理 from 和 to 区域对象引用 old gen 的变化,同样也会继续处理 dirty card 的对象引用。这个阶段默认设置的时间是5s,如果执行逻辑超过5s,会自动终止这个阶段,或者当eden区使用内存值小于 CMSScheduleRemarkEdenPenetration,默认 50% 时,也会退出这个阶段。
0.530: [CMS-concurrent-abortable-preclean-start]
0.844: [CMS-concurrent-abortable-preclean: 0.006/0.315 secs] [Times: user=1.34 sys=0.12, real=0.31 secs]

# 重新标记
# 35015K:年轻代当前的使用量;314560K:年轻代的总容量;
0.845: [GC (CMS Final Remark) [YG occupancy: 35015 K (314560 K)]
# Rescan:进行重新标记,耗时 0.0004597 secs
0.845: [Rescan (parallel) , 0.0004597 secs]
# 处理弱引用
0.845: [weak refs processing, 0.0000086 secs]
# 卸载 class
0.845: [class unloading, 0.0004208 secs]
# 清理类级元数据和内部化字符串的符号和字符串表
0.845: [scrub symbol table, 0.0004006 secs]
0.846: [scrub string table, 0.0001479 secs]
# 老年代的使用情况及堆的使用情况
[1 CMS-remark: 677072K(699072K)] 712088K(1013632K), 0.0014893 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

# 启动并发清除,任务是清除那些没有标记的无用对象并回收内存
0.846: [CMS-concurrent-sweep-start]
0.847: [CMS-concurrent-sweep: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

# 启动并发重置,作用是重新设置CMS算法内部的数据结构
0.847: [CMS-concurrent-reset-start]
0.849: [CMS-concurrent-reset: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]