CMS 与三色标记算法

774 阅读10分钟

感谢原创 来自知乎

CMS(Concurrent Mark Sweep)是一款里程碑式的垃圾收集器,为什么这么说呢?因为在它之前,GC 线程和用户线程是无法同时工作的,即使是 Parallel Scavenge,也不过是 GC 时开启多个线程并行回收而已,GC 的整个过程依然要暂停用户线程,即 Stop The World。这带来的后果就是 Java 程序运行一段时间就会卡顿一会,降低应用的响应速度,这对于运行在服务端的程序是不能被接收的。

GC 时为什么要暂停用户线程?
首先,如果不暂停用户线程,就意味着期间会不断有垃圾产生,永远也清理不干净。
其次,用户线程的运行必然会导致对象的引用关系发生改变,这就会导致两种情况:漏标和错标。

  1. 漏标
    原本不是垃圾,但是 GC 的过程中,用户线程将其引用关系修改,导致 GC Roots 不可达,成为了垃圾。这种情况还好一点,无非就是产生了一些浮动垃圾,下次 GC 再清理就好了。
  2. 错标
    原本是垃圾,但是 GC 的过程中,用户线程将引用重新指向了它,这时如果 GC 一旦将其回收,将会导致程序运行错误。

针对这些问题,CMS 是如何解决的呢?它是如何做到 GC 线程和用户线程并发工作的呢???

CMS 收集器

Concurrent Mark Sweep,从名字上就可以看出来,这是一款采用「标记清除」算法的垃圾收集器,它运行的示意图大概如下:

大概可分为四个主要步骤:

1、初试标记
初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。初始标记的过程是需要触发 STW 的,不过这个过程非常快,而且初试标记的耗时不会因为堆空间的变大而变慢,是可控的,因此可以忽略这个过程导致的短暂停顿。

2、并发标记
并发标记就是将初始标记的对象进行深度遍历,以这些对象为根,遍历整个对象图,这个过程耗时较长,而且标记的时间会随着堆空间的变大而变长。不过好在这个过程是不会触发 STW 的,用户线程仍然可以工作,程序依然可以响应,只是程序的性能会受到一点影响。因为 GC 线程会占用一定的 CPU 和系统资源,对处理器比较敏感。CMS 默认开启的 GC 线程数是:(CPU 核心数 + 3)/4,当 CPU 核心数超过 4 个时,GC 线程会占用不到 25% 的 CPU 资源,如果 CPU 数不足 4 个,GC 线程对程序的影响就会非常大,导致程序的性能大幅降低。

3、重新标记
由于并发标记时,用户线程仍在运行,这意味着并发标记期间,用户线程有可能改变了对象间的引用关系,可能会发生两种情况:一种是原本不能被回收的对象,现在可以被回收了,另一种是原本可以被回收的对象,现在不能被回收了。针对这两种情况,CMS 需要暂停用户线程,进行一次重新标记。

4、并发清理
重新标记完成后,就可以并发清理了。这个过程耗时也比较长,且清理的开销会随着堆空间的变大而变大。不过好在这个过程也是不需要 STW 的,用户线程依然可以正常运行,程序不会卡顿,不过和并发标记一样,清理时 GC 线程依然要占用一定的 CPU 和系统资源,会导致程序的性能降低。

CMS 的缺点

尽管 CMS 是一款里程碑式的垃圾收集器,开启了 GC 线程和用户线程同时工作的先河,但是不管是哪个 JDK 版本,CMS 从来都不是默认的垃圾收集器,究其原因,还是因为 CMS 不太完美,存在一些缺点。

1、对处理器敏感
并发标记、并发清理阶段,虽然 CMS 不会触发 STW,但是标记和清理需要 GC 线程介入处理,GC 线程会占用一定的 CPU 资源,进而导致程序的性能下降,程序响应速度变慢。CPU 核心数多的话还稍微好一点,CPU 资源紧张的情况下,GC 线程对程序的性能影响非常大。

2、浮动垃圾
并发清理阶段,由于用户线程仍在运行,在此期间用户线程制造的垃圾就被称为 “浮动垃圾”,浮动垃圾本次 GC 无法清理,只能留到下次 GC 时再清理。

3、并发失败
由于浮动垃圾的存在,因此 CMS 必须预留一部分空间来装载这些新产生的垃圾。CMS 不能像 Serial Old 收集器那样,等到 Old 区填满了再来清理。在 JDK5 时,CMS 会在老年代使用了 68% 的空间时激活,预留了 32% 的空间来装载浮动垃圾,这是一个比较偏保守的配置。如果实际引用中,老年代增长的不是太快,可以通过-XX:CMSInitiatingOccupancyFraction参数适当调高这个值。到了 JDK6,触发的阈值就被提升至 92%,只预留了 8% 的空间来装载浮动垃圾。
如果 CMS 预留的内存无法容纳浮动垃圾,那么就会导致「并发失败」,这时 JVM 不得不触发预备方案,启用 Serial Old 收集器来回收 Old 区,这时停顿时间就变得更长了。

4、内存碎片
由于 CMS 采用的是「标记清除」算法,这就意味这清理完成后会在堆中产生大量的内存碎片。内存碎片过多会带来很多麻烦,其一就是很难为大对象分配内存。导致的后果就是:堆空间明明还有很多,但就是找不到一块连续的内存区域为大对象分配内存,而不得不触发一次 Full GC,这样 GC 的停顿时间又会变得更长。
针对这种情况,CMS 提供了一种备选方案,通过-XX:CMSFullGCsBeforeCompaction参数设置,当 CMS 由于内存碎片导致触发了 N 次 Full GC 后,下次进入 Full GC 前先整理内存碎片,不过这个参数在 JDK9 被弃用了。

三色标记算法

介绍完 CMS 垃圾收集器后,我们有必要了解一下,为什么 CMS 的 GC 线程可以和用户线程一起工作。

JVM 判断对象是否可以被回收,绝大多数采用的都是「可达性分析」算法,关于这个算法,可以查看笔者以前的文章:

大白话理解可达性分析算法_程序员小潘 - CSDN 博客​javap.blog.csdn.net/article/details/109253339

从 GC Roots 开始遍历,可达的就是存活,不可达的就回收。

CMS 将对象标记为三种颜色:

标记的过程大致如下:

  1. 刚开始,所有的对象都是白色,没有被访问。
  2. 将 GC Roots 直接关联的对象置为灰色。
  3. 遍历灰色对象的所有引用,灰色对象本身置为黑色,引用置为灰色。
  4. 重复步骤 3,直到没有灰色对象为止。
  5. 结束时,黑色对象存活,白色对象回收。

这个过程正确执行的前提是没有其他线程改变对象间的引用关系,然而,并发标记的过程中,用户线程仍在运行,因此就会产生漏标和错标的情况。

漏标

假设 GC 已经在遍历对象 B 了,而此时用户线程执行了A.B=null的操作,切断了 A 到 B 的引用。\


本来执行了A.B=null之后,B、D、E 都可以被回收了,但是由于 B 已经变为灰色,它仍会被当做存活对象,继续遍历下去。
最终的结果就是本轮 GC 不会回收 B、D、E,留到下次 GC 时回收,也算是浮动垃圾的一部分。

实际上,这个问题依然可以通过「写屏障」来解决,只要在 A 写 B 的时候加入写屏障,记录下 B 被切断的记录,重新标记时可以再把他们标为白色即可。

错标

假设 GC 线程已经遍历到 B 了,此时用户线程执行了以下操作:

B.D=null;//B到D的引用被切断
A.xx=D;//A到D的引用被建立

B 到 D 的引用被切断,且 A 到 D 的引用被建立。
此时 GC 线程继续工作,由于 B 不再引用 D 了,尽管 A 又引用了 D,但是因为 A 已经标记为黑色,GC 不会再遍历 A 了,所以 D 会被标记为白色,最后被当做垃圾回收。
可以看到错标的结果比漏表严重的多,浮动垃圾可以下次 GC 清理,而把不该回收的对象回收掉,将会造成程序运行错误。

错标只有在满足下面两种情况下才会发生:

只要打破任一条件,就可以解决错标的问题。

原始快照和增量更新

原始快照打破的是第一个条件:当灰色对象指向白色对象的引用被断开时,就将这条引用关系记录下来。当扫描结束后,再以这些灰色对象为根,重新扫描一次。相当于无论引用关系是否删除,都会按照刚开始扫描时那一瞬间的对象图快照来扫描。

增量更新打破的是第二个条件:当黑色指向白色的引用被建立时,就将这个新的引用关系记录下来,等扫描结束后,再以这些记录中的黑色对象为根,重新扫描一次。相当于黑色对象一旦建立了指向白色对象的引用,就会变为灰色对象。

写屏障

这个写屏障指的可不是并发编程里的写屏障哦!这里的写屏障指的是属性赋值的前后加入一些处理,类似于 AOP。

CMS 采用的方案就是:写屏障 + 增量更新来实现的,打破的是第二个条件。

当黑色指向白色的引用被建立时,通过写屏障来记录引用关系,等扫描结束后,再以引用关系里的黑色对象为根重新扫描一次即可。

伪代码大致如下:

class A{
	private D d;

	public void setD(D d) {
		writeBarrier(d);// 插入一条写屏障
		this.d = d;
	}

	private void writeBarrier(D d){
		// 将A -> D的引用关系记录下来,后续重新扫描
	}
}

尾巴

CMS 为了让 GC 线程和用户线程一起工作,回收的算法和过程比以前旧的收集器要复杂很多。究其原因,就是因为 GC 标记对象的同时,用户线程还在修改对象的引用关系。因此 CMS 引入了三色算法,将对象标记为黑、灰、白三种颜色的对象,并通过「写屏障」技术将用户线程修改的引用关系记录下来,以便在「重新标记」阶段可以修正对象的引用。

虽然 CMS 从来没有被 JDK 当做默认的垃圾收集器,存在很多的缺点,但是它开启了「GC 并发收集」的先河,为后面的收集器提供了思路,光凭这一点,就依然值得记录下来。