CMS 垃圾收集器和 G1 垃圾收集器详解
不定期随便整理一些八股知识点。GC 感觉被问过好几次之前都没好好学,今天有机会就借着笔记的机会深入研究一下。
参考: G1 垃圾收集器架构和如何做到可预测的停顿(阿里) - aspirant - 博客园
深入理解 JVM 的垃圾收集器:CMS、G1、ZGC | 二哥的 Java 进阶之路
阿里二面:CMS、G1 垃圾回收器中的三色标记你了解吗?_哔哩哔哩_bilibili
Oracle G1 垃圾回收器入门
图解 Remembered Set、Card Table、Write Barrier - 知乎
G1收集器STAB详解_g1 stab-优快云博客
JVM GC 主要分为两大类:分代收集器和分区收集器。
分代收集器
我们先讲解一下分代收集器理念。分代收集器源自于 java 内存使用过程观察到的结论:90%的对象熬不过第一次垃圾回收,而经历过几次垃圾回收的老对象则有 98%概率会一直活下来。此外,跨代引用现象很少发生(老对象引用新对象的引用相对于同代引用来说占比很少)。
基于这个分代假设,分代垃圾回收器一般将内存分为如下三个部分:

Eden (E) 和 Survivor (S) 属于年轻代,Old (O) 属于老年代。新对象分配在 Eden 中,熬过一次 GC 的对象被移动到 Survivor 中,熬过数次 GC 的对象被移动到 Old 中。
Eden 是圣经中的伊甸园,人类起源的地方,所以在这里表示新对象被分配的地方。
此外,还有一个叫做永久代/元空间 (Permanent Generation / Metaspace)的存储区域,java7 之前叫做永久代,java8 之后被称作元空间。主要存储类的元数据(类结构、方法元数据、字段信息等),java7 之前还存静态变量和常量,7 之后这部分内容被移动到了堆中。其垃圾回收通常与老年代区域关联。
对于年轻代的对象,垃圾收集比较频繁,所以要采用执行时间段,效率高的回收算法。而对于老年代的对象,对象数量和垃圾收集次数都不多,所以可以采用比较高效的回收算法。
回收算法举例
比如新生代常用 复制算法(Copy Algorithm)进行垃圾回收。将内存分为两片区域(From Space 和 To Space),分配新对象的时候只在 From 区域进行,From Space 满的时候触发垃圾回收,标记其中所有存活的对象,连续地复制到 To Space 中,再清除 From Space 的整个区域,然后交换 From Space 和 To Space 的角色。复制算法分配回收很搞高效,但是浪费了很多内存。
具体在每次 Minor GC 时,Eden 和其中一个 Survivor 区作为 From 区,另一个 Survivor 区作为 To 区。GC 过程中幸存对象复制到 To 区,之后两个 Survivor 区角色互换。这两片区域只占新生代的很小一部分(20%左右),因为每次能活过一次 GC 的对象很少。每次复制完成后这两片 Survivor 区域角色互换。如果 Survivor 区域空间不足,或者对象年龄达到设定的阈值(-XX:MaxTenuringThreshold,默认 15)的时候则该对象晋升到老年代。
老年代则常用标记-清除算法和标记-整理算法。
标记清除算法:先遍历 GC Roots 标记所有可达(存活)对象,然后遍历堆内存回收未标记的对象空间,回收的空闲空间记录在空闲链表里面。没有对象移动开销,空间利用率高,整个堆都可以用来分配。但是会产生大量内存碎片,想要分配大对象的时候可能尽管总空闲空间很充足,仍然会触发不必要的垃圾回收来清理出大的连续空间。还有分配的时候要遍历空闲链表,回收的时候要遍历 GC Roots 和整个堆,效率低。
GC Roots:比如栈中的本地变量,本地方法栈中的变量,静态变量等都可以作为 GC Roots。其引用的变量就是可达的。堆中的对象不一定可达,要看有没有被 GC Roots 引用。
标记整理算法:类似标记回收算法,就是清除未标记对象之前先整体移动一下所有标记对象到内存空间的一侧。这样增加了对象移动开销和回收开销,但是消除了内存碎片问题,空间利用率变高了,而且分配速度也更快了(不用遍历整个空闲列表找合适大小的连续空闲空间)。
CMS 分代收集器
讲完了分代收集器的简要原理,接下来我们开始学习其中的 CMS 分代收集器。
CMS (Concurrent Mark Sweep) 是第一个关注 GC 停顿时间的垃圾收集器,之前的垃圾收集器要么是串行回收的,要么只关注系统吞吐量。不过现在已经逐渐被 G1 和 ZGC 取代。
之前的垃圾收集器,回收的时候都需要 STW(Stop The World),无法做到垃圾回收线程与用户线程的并发执行。
CMS 则解决了这一点,其运行过程包括:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记:扫描所有被 GC Roots 引用的对象,这个操作需要 STW 但是仅仅只是标记一下 GC Roots 能关联到的对象而不是对整个引用的扫描,很快就完成了。
并发标记:对初始标记中标记的对象进行整个引用链的扫描,不需要 STW 所以可以并发执行,降低垃圾回收的时间。不过,并发标记可能导致多标漏标,因为用户线程运行导致该被回收的垃圾没有被回收,或者不该被回收的垃圾被回收了。
重新标记:STW 校验一下并发标记中的多标漏标问题。
并发清除:开始清除标记为垃圾的对象,不需要 STW。
CMS 的这种标记方式又被称作三色标记法。如下图,黑色是当前已经被标记而且其引用对象也标记了的对象;灰色是已经被标记,但是其引用对象还没有被标记的对象;白色是还没有被标记的对象。
下图是一个漏标的案例。在时刻 1,GC 扫描了 AB 对象并标记了,但是还没有遍历扫描 B 的引用对象。结果到了时刻 2,C 改为被 A 对象引用而不是 B 对象引用了,这时候我们继续遍历 B 的引用对象,找不到 C,所以就把 C 当做一个不可达的对象了。这就是因为并发运行而引发的漏标问题。所以需要 STW 重新标记,发现 A 引用有变,将 A 对象重新置为灰色,扫描其引用对象。

CMS 的优点:与用户线程并发执行,效率高。
缺点:
- 标记清除算法,会产生内存碎片。
- 对 CPU 资源敏感。CMS 默认会使用 (CPU 数量+3)/4 个线程,所以当 CPU 数量少的时候,垃圾回收线程占比就很大,会严重影响系统吞吐量。
- 无法处理浮动垃圾,即清楚过程中的新垃圾。只能等到下一个 GC 周期再处理。
分区收集器
其实基本理念和分代收集器差不多,但是把老年代年轻代更加细化为许多小区域(Region),1M - 32M。此外还新增了 Humongous H 区,用于专门存放大对象(超过 Region 50% 的对象)。H 区被回收前位置固定不变,减少整理垃圾时移动大对象的移动开销。

G1 分区收集器
G1(Garbage First)分区收集器,java 7 的时候引入,在 java9 的时候正式取代 CMS 成为默认的垃圾回收器。
和 CMS 大致很像,区别在于:
-
可以并行垃圾回收,利用多 CPU 加速回收。
-
增量:可以逐步扫描标记清理而不是一次完成,STW 时间很短。
-
用了一个不同的机制来解决漏标错标问题:STAB (Snapshot-At-The-Beginning)。g1 分区收集器的理念是 Anything live at Initial Marking is considered live. 无论是初始标记时活着,但引用丢失的对象,还是标记过程中新增的对象,都会被标记为黑色,这些对象有可能是需要回收的垃圾也有可能不是,反正成为浮动垃圾,等待下一轮 GC 再回收,宁可多标也不肯错标。比如上一时刻是 A-> B,收集器标记了 A (变为灰色)之后下一时刻引用变为 A-> C,STAB 机制仍然会记录 B。
-
软实时:用户可以指定一个期望的最大 gc 停顿时间,g1 收集器会根据每块 region 历史收集所需时间预测选哪些 region 回收可以在期望停顿时间内回收更多垃圾腾出更多空间。软实时的意思是有可能超时,因为 g1 收集器只是通过模型预测回收哪些 region 需要多少时间的,所以有可能超过停顿时间。而硬实时则是指绝对不能超过最大停顿时间的应用场景,比如像航空航天等对实时性要求高的领域一般要求硬实时。
不过 g1 的高效垃圾回收带来的是更大的进程空间,还花了一些额外空间记录 Remember Set,Collection Set 等信息,这些我们马上详细讲解。
G1 中的记忆集
Collection Set
标注了哪些 Young Region 待回收。通常包括所有的 Eden 区域和部分或全部 from survivor 区域。
Remember Set
这个集合用于记录“老年代对象->年轻代对象”这样的引用场景。G1 中有专门的年轻代垃圾回收,只专注回收年轻代对象。如果没有 RSet,就可能出现下面这种情况:

E,G 这些新生代对象没有被 GC Roots 引用,所以他们会在 Young GC 阶段被判断为不可达而被回收。但是其实他们被 H J 老年代对象引用了,回收后H J 无法访问这两个引用对象就会出现问题。
Remember Set 的一种具体实现方法叫做 Card Table:
Card Table 是一个字节数组,里面每个 Table 项对应了老年代中的一片地址,也叫一个 Card Page 卡页。如果其标记为 0 说明这一页中的老年代对象没有引用新生代对象,反之如果标记为1(dirty),说明这一页中的老年代对象有引用新生代对象。下图中就说明,L M N 这三个老年代对象有引用新生代对象的情况,而 POHIJK 都没有。

这样,在进行 Young GC 的时候我们就不需要遍历所有老年代对象找他们对年轻代对象的引用情况了,只需要找到卡表中的脏页中的元素,查找其对年轻代的引用情况,避免回收这些被引用的年轻代即可。
如果在并发表及过程中 Young GC 导致出现了新的晋升和跨代引用问题(比如本来是 s1->s2,现在 s1晋升为o了,变为 o->s2引用),就更新卡表标记新的 dirty card。
STAB 机制
如何记录标记过程中的新引用对象?如下图。TAMS (top-at-mark-start) 表示开始标记的起始位置,一轮标记从 prevTAMS 开始到 NextTAMS 结束,其中的所有对象就是开始标记时就存在的对象。
Bottom 和 Top 代表对象的范围。
一轮标记的标记范围就是从 prevTAMS 到 NextTAMS,但是标记过程中由于用户线程新增对象,实际会新创建一些对象,导致 Top(也就是对象范围边界)和对应的对象位图索引不断变化。下图D阶段可以看出,当我们完成标记的时候 PrevTAMS 到 Top 范围内新增了这么多对象。这两个指针地址范围内的对象就被视作是新增对象,保存在该 GC 线程的队列中,在重标记阶段会被汇总并全部标记为黑色,等待下一轮再对其进行标记。同时 PrevTAMS = NextTAMS,这次标记的终点被视作下次标记的起点位置;NextTAMS=Top,新对象的位置被视作下次标记的终点位置。

如何处理对象引用被修改导致的漏标问题?类似的,如果出现某个对象引用被修改则旧的被引用对象也会被加入队列标为黑色,等待下次 GC 再判断是否要标记。
RSset 和 SATB 都属于写屏障,用于解决用户线程并发运行产生的新引用或旧引用丢失问题。之前的收集器基本都有写后屏障机制(即:标记过程中的新引用会被记录),但 G1 才开始引入写前屏障机制(标记前 SATB 记录快照,确保初始标记时活着的对象不会丢失)。RSet 卡表是写后屏障机制,SATB 是写前屏障机制。
G1 的几种垃圾回收方式
Young GC
专注回收 Eden 和 Survivor 区域的垃圾回收方式。我们之前讲过,大多数年轻对象会被回收,而老年代对象大多数能留存很久。所以 Young GC 和 Old GC 分离,只专注于 Young Region 的回收有助于提高效率。
Young GC 的回收范围是 Collection Set,对存活的年轻代对象进行移动和晋升(活过一次的 Eden 对象移动到 to Survivor 区域,活过一定阈值的 Survivor 对象移动到 Old 区域),移动完成后直接清空 Eden 和 from Survivor 区域,然后 from survivor 和 to survivor 区域角色互换。清理完就会变成类似下图这个样子,存活的年轻代都被整合到几个区域里了。

Young GC 是 STW 的,同时可以通过多个线程并行加速完成。
Mixed GC
相比 Young GC 还会选取部分回收价值高的老年代对象整理回收。将要回收的 old 对象移动到 Eden 或者 from survivor 区域(加入 CSet)后回收。
GC 流程
G1 分区收集器的步骤如下:
-
初始标记,标记 GC Roots 能直接关联的对象,STW. 同时,执行一次 Young GC,回收范围是 Collection Set (CSet) 。为什么这里会进行一次 Young GC?因为我们知道年轻代的存活时间短,引用长度变化大,占比也大,所以提前进行一次 Young GC 有助于稳定标记集,减轻后续扫描压力,同时这一步和初始标记一样 STW,就顺带着都一起做了,不用多次 STW。
-
根区间扫描,扫描 Survivor 区域找出其中对 Old 区域的对象的引用,防止这部分引用丢失。
-
并发标记,从 GC Roots 开始对堆中对象进行可达性分析,可以与用户线程并发执行,以及可以被 Young GC 打断。根区间扫描实际上和并发标记是交织的步骤,但是根区间扫描需要在下次 Young GC 前完成。根区间扫描完成前并发表及不会被 Young GC 打断。
根区间扫描在下一次 Young GC 前完成的原因:比如说,初始标记的时候有一个 GC Root -> S -> O 这样的引用。在并发标记到一半的时候(S 变为灰色),由于 Eden 区域满了所以 JVM 又启动了一次 Young GC,这次 Young GC 把这个现在没有被引用的 Survivor 给回收了。但是这个 Survivor 还引用了一个 Old 对象,因为这次 Young GC 的 STW 执行,导致这个 Survivor 对象被清理了,其到 Old 的引用还没有被记录下来,那个 Old 对象就被并发标记认为是没有引用的,就不会被标记了,也会在后续被回收。这样就违反了 G1 分区收集器的 Anything live at Initial Marking is considered live. 理念,因为在初始标记的时候明明这个 Old 是可达的,但是由于中途 Young GC 的打断使其变得不可达了而被“误杀”。所以在下一次 Young GC 之前一定要完成根区间扫描。
-
重新标记 Remarking,汇总 SATB 和 RSet 的队列,将这部分对象标记为黑色。
-
筛选回收。首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定 Mixed GC 回收计划, 并发性地回收没有存活对象的 Region 并加入可用 Region 队列,STW。其实这个阶段理论上也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
174万+

被折叠的 条评论
为什么被折叠?



