Skip to content

Commit

Permalink
Add G1
Browse files Browse the repository at this point in the history
  • Loading branch information
flycash committed Aug 16, 2021
1 parent cff84ef commit 422b3c6
Show file tree
Hide file tree
Showing 2 changed files with 169 additions and 1 deletion.
168 changes: 168 additions & 0 deletions gc/g1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# G1 垃圾回收器

分析:CMS 和 G1 都可以被认为是近年面试考察的高频考点。G1 的复习也类似于 CMS 的复习,重点在于捋清楚其中的步骤。而后为了刷出亮点,可以尝试在部分细节上下功夫。

G1 的几个基本概念要捋清楚:
1. Region。这个可以说是和 CMS 根源上不同设计理念的体现。总体来说,虽然 CMS 曾经也是支持增量式回收的,但是做得不如 G1 彻底。G1是彻底的增量式回收,原因就在于,它不是每次都回收全部的内存,而是挑一部分 Region 出来。之所以只挑选一部分出来,核心也就是为了控制停顿时间。
2. Garbage First:也就是 G1 名字的由来。是指,每次回收的时候,回收器会从 Region 里面挑出一些比较脏的来回收。注意这里面有两个,**挑出一些****比较脏**。这揭示了两个问题:第一个,G1是增量式回收的;第二,G1 优先挑选垃圾最多的。

这里给出一个理解 G1 算法的思路:

G1 的目标是控制住停顿时间。那么我们怎么控制停顿时间?一种比较好的思路就是,我每次回收只回收一小部分内存。例如说我有一个 32G 的堆,我每次只回收 4 个G。那么如果原来你停顿时间是32秒,回收 4G 就只需要5秒。

进一步你就会想,如果是我来设计这个 G1,我要想做到这一步,我该怎么搞?我能不能先把堆分成四部分,每次回收其中的一部分?

答案是可以的。然后你就又会遇到问题,有些人可能想回收三分之一的堆,那你怎么办?加个参数控制?比如说启动的时候让用户指定把堆分成多少分?

那么问题又来了,用户也不知道该分成多少份才能恰好满足自己希望的停顿时间。

这个时候你就会考虑,能不能让用户把他希望的停顿时间告诉你,你自己来决定回收多大的一块。

到了这一步,你又会发现一个问题,即便用户告诉你期望停顿时间要控制在一秒内,于是你提前把堆分成了三十二份,但是因为应用的负载不是静态的,导致你每次回收一份,也会经常超出期望。

这个时候,你就会想,我这提前划分好感觉不太靠谱,能不能动态划分呢?

所以问题的根源就是怎么做到动态划分,比如说一会分成三十二份,一会分成六十四份。这个问题难在哪里?难在怎么知道不回收的部分,有哪些引用指向了被回收部分。如果直接动态划分,就没法子维护整个信息。

那么,你就会想到,我能不能先把堆划分得很细碎,比如说,我直接把堆分成1024份,每一份自己维护一下别人引用自己内部对象的信息?然后当回收的时候,我就从里面挑。比如说这次回收,预计一秒内只能回收128份,那我就挑128份出来回收掉;下一次能更惨,只能回收64份,所以我就挑64份来回收。

这就是 G1 的基本思想。

这就是抓住 G1 的核心。G1 的后面的一切,都是因为分成了那么多小份,然后每一次要挑出来一部分回收。

然后我们从这一点出发,看一下 G1 的各种奇技淫巧。

首先 Region 我们已经说过了,就是为了能够保证 GC 期间灵活选择而不得不划分的。

那么 RSet(记忆集)又是拿来干啥?用来记录别的 Region 引用本 Region 内部对象的结构体。为什么要记录?不记录的话不知道这个 Region 内部的对象是不是活着。

那么怎么理解 G1 的两种模式?

我们再考虑一下,我想要挑出来一部分 Region 来回收,我是随机挑吗?当然不是,我希望尽可能回收脏的 Region。那么什么 Region 比较脏?

显然是放着年轻代对象的 Region 比较脏。因为对象朝生夕死,所以想当然的我们会说我们优先挑年轻代的 Region 就可以了。

那么问题来了,你不能一直挑年轻代,你总要挑老年代的,不然老年代岂不是永远不回收了?

所以我们会想到,启动一个后台线程,扫描这些老年代的 Region,看看脏不脏。要是很多已经很脏了,我们就把这部分老年代的 Region 回收掉。

这就是 G1 的 Young GC、Mixed GC 和全局并发标记循环的来源了。

这里面还有几个细节,我们沿着思路继续思考下去。

首先一个,并发标记循环,意味着应用也在运行,如果我在标记过程中,引用被修改了,怎么办?这就是引入了 SATB( snapshot at the beginning)。这个名字就有点让人误解,会以为 G1 真的记了一个快照,其实不是的。简单来说,可以理解为这个机制记录了在并发期间引用发生过修改的对象。在最终标记阶段,会处理这些变更。

其次,如果我要是 Mixed GC 太慢,还没来得及回收老年代也满了,怎么办?这个问题和 CMS 是一样。那么 CMS 是怎么解决的?退化为 Serial Old GC。很显然,G1 也是一样处理的。(从这个角度来说,可以理解 Serial Old 是一个后备回收器,只要你 CMS 或者 G1 崩了,那就是它顶上)

前面我们还提到,就是要挑出脏的,那么什么才是脏的,那就是要算一下,里边活着的对象还有多少。要是一个活着的对象都没了,这个 Region 是不是可以直接回收了?都不用复制存活对象了。这就是并发循环标记最后一步,把发现的一个存活对象都没了的 Region,脏得彻底的 Region 直接收回。

还有一个点,其实算是优化,而不算是本质。就是并发标记循环会复用 Young GC 的结果。在 Young GC 的初始标记完成后,一边是 Young GC 继续下去,一边是并发循环标记。

接下来我们想,每次挑出来多少个才是合适呢?之前我们已经揭露了,静态划分是不行的,因为要根据程序动态运行来决定挑多大一块内存来回收。因此我们肯定不能用参数或者直接写死,而是要实时计算。

那么怎么计算呢?这个细节,面试基本不会考。大概的原理是考察最近的几次 G1 GC 的情况,大概推断这一次 G1 至多回收多少块。有点像是根据最近几次 GC 的情况,来猜测这一次 GC 回收每一块 Region 需要多长时间,然后算出来。核心在于,根据最近几次情况来推断。

G1 的面试总体上来说不如 CMS 常见。原因很简单,对于大多数应用来说,4G 的堆就足够了。在这个规模上,G1 是并不比 CMS 优秀的。而且 CMS 因为应用得多,所以懂得原理调优的人比 G1 多。

## 面试问题

### 什么是 Region?

分析:基本概念题,可以从为什么需要 Region 的角度来作为亮点。

答案:G1 将整个内存划分成了一个个块,通过这种块,可以控制每次回收的时候只回收一定数量的块,来控制整个停顿时间(这就是引入Region的目标)。

有三类 Region:
1. 年轻代 Region;
2. 老年代 Region;
3. Humongous Region,用于存放大对象(这是一个不同于 CMS 的地方。CMS 是使用老年代来存放大对象的);

![Region](https://upload-images.jianshu.io/upload_images/2579123-b5f52615c38aa31b.png?imageMogr2/auto-orient/strip|imageView2/2/w/478/format/webp)

每一个 Region 归属哪一类并不是固定不变的(这是一个很容易让人误解的地方),也就是说,在某一个时间点,一个 Region 可能是放着年轻代对象,另一个时间点,可能放着老年代对象。

(我们稍微提及 Region 内部内存是如何分配的)为对象分配内存就比较简单了,Region 内部通过指针碰撞分配内存。为了减少并发,每一个线程,会从 Region 里面申请一大块内存,这块内存叫做 TLAB(thread local allocation buffer),每一个线程自己内部再分配。

#### 类似问题
- 年轻代的 Region 能不能给老年代用?能,在回收清空了这个 Region之后,就可以分配给老年代了
- Region 有哪几类?
- Region 怎么分配内存?
- 什么是 TLAB?有些面试官好像会把这个东西记成 TLB(thread local buffer)

### 什么是 CSet?Collection Set
分析:基本概念题。刷亮点落在一个很容易误解的地方
答案:在每一次 GC 的时候,G1 会挑选一部分的 Region 来回收,这部分 Region 就被称为 CSet。不过要注意的是,在 Young GC的时候,是选择全部年轻代的 Region 的,G1 是通过控制所能允许的 年轻代 Region 数量来控制Young GC 的停顿时间。

(后边这一点很容易让人误解,总以为是分配了一大堆年轻代 Region,然后 Young GC 只回收其中一部分,其实并不是,而是说,当 G1 觉得我只能一次性回收 50 个年轻代的 Region,那么当分配了 50 个年轻代 Region 之后,就会触发 Young GC)

### G1 的具体步骤

分析:基本考察。如果直接问步骤,那么大概率是问 MIXED GC。但是从回答的角度,要交代清楚 Young GC 和 Mixed GC。既然谈及了 Mixed GC,就要谈到并发标记循环。最后以 Mixed GC 失败,退化为Serial Old结束。

我们会把亮点放在与 CMS 横向比较上。G1 的很多步骤,都和 CMS 是类似的。通过这种比较,我们能够看到一些这一类并发 GC 在处理一些问题上的共同点。

所以接下来的回答,但凡是涉及到了和 CMS 的部分,都可以成为亮点。

答案:G1 的具体来说,可以分成 Young GC, Mixed GC 两个部分。

1. 初始标记,该步骤标记 GC root;(什么是 GC root 可以看 [GC 算法](./algorithm.md)
2. 根区间扫描阶段:该阶段简单理解,就要扫描 Young GC 之后的 Survivor Regions 的引用,和第一步骤的 GC root,合在一起作为扫描老年代 Regions 的根,这一个步骤,在 CMS 里面是没有的;
3. 并发标记阶段
4. 重新标记阶段
5. 清扫阶段:该阶段有一个很重要的地方,是会把完全没有存活对象的 Region 放回去,后边可以继续使用。清扫阶段也有一个及其短暂的 STW,而 CMS 这个步骤是完全并发的;

在标记阶段结束之后,G1 步入评估阶段,就是利用前面标记的结果,看看回收哪些 Region。G1 会根据近期的 GC 情况来判定要回收多少个 Region,在达到期望停顿时间的情况下,尽可能回收多的 Region。

而 G1 会优先挑选脏的 Region 来回收。

#### 类似问题
- 并发标记循环步骤
- Mixed GC 步骤

### G1 什么时候会触发 Full GC
分析:其实和 CMS 类似,核心都是在老年代尝试分配内存的时候,找不到足够的空间,就会退化为 Full GC。那么问题来了,什么时候会尝试分配对象到老年代?—— 年轻代对象晋升。这和 CMS 又不同,CMS 中还有可能是大对象直接分配到老年代。那么 G1 的大对象分配到哪里?分配到了 Huge Regions。那么万一 G1 也没有 Region 来容纳这个大对象,会不会也开始 Full GC?答案是会的。所以总结下来就是两个:
1. 分配对象到老年代的时候,老年代没有足够的内存。这基本上就是对象晋升失败;
2. 分配大对象失败;

答:主要是两个时机:
1. 对象晋升到老年代的时候,没有足够的空间来容纳,也就是并发模式失败,要进行 Full GC
2. 分配大对象的时候,没有足够的空间来容纳,也会触发 Full GC

(尝试回答如何解决 Full GC,作为一个亮点)对于前者来说,要避免这种情况, 就是要确保 Mixed GC 执行得足够快足够频繁。因此可以通过调整堆大小,提前启动 Mixed GC,或者调整并发线程数来加快 Mixed GC。至于后者,则没什么好办法,只能是加大堆,或者加大 Region 大小来避免。

(总结和 CMS 的相同点)基本上,G1 触发 Full GC 和 CMS 触发 Full GC 是类似的,核心都在于并发模式失败,老年代找不到空间。所不同的是 G1 有专门的存放大对象的 Region,所以这一点会稍微有点不同。

### CMS 和 G1 的区别

分析:这个问题就很宽泛,可以从多个角度去回答。
1. 从两者内存管理的角度去回答
2. 从适用场景去回答
3. 回收模式

也可以聊具体步骤上的差异。但是一般来说问这种区别,更加多是希望讨论一些特征上的差异。步骤上的差异虽然也算是差异,不过可能不太符合期望而已。

答案:CMS 和 G1 都是并发垃圾回收器,但是它们在内存管理,适用场景上都有很大的不同。
1. CMS 的内存整体上是分代的,使用了空闲链表来管理空闲内存;而 G1 也用了分代,但是内存还被划分成了一个个 Region,在 Region 内部,使用了指针碰撞来分配内存;
2. 在适用场景下,G1 在大堆的表现下比 CMS 更好。G1 采用的是启发式算法,能够有效控制 GC 的停顿时间;
3. 回收模式上,G1 有 Young GC 和 Mixed GC。Mixed GC 是同时回收老年代和年轻代;

#### 类似问题

- 为什么 G1 会比 CMS 更适合大堆?启发式算法能够比较准确控制停顿时间

### 在并发标记期间,G1 是怎么处理这阶段发生变化的引用?

分析:考察并发的固有问题,就是如果这个过程应用的引用发生了变化,G1 是如何处理的。在 CMS 里面,我们说了,CMS 是用卡表,也就是卡表 + 预清理 + 重标记来完成的,核心是利用写屏障来重新把卡表标记为脏,在预清理和重标记阶段重新处理脏卡。

G1 里面则不同,它用的是 SATB,但是也利用了写屏障。它的处理机制,可以归结为,当引用变更的时候,会把这个变更作为一条 log 写到一个 buffer 里面。在重标记阶段重新这些 log。

这个机制,亮点可以横向对比 CMS,也有一个比较出其不意的角度,就是横向对比 Redis 的 BG save。基本上都是一样的。

先是产生一个快照,然后再把并发期间的修改丢到日志里面,在最后重新处理一下日志。

答案:G1 采用了所谓的 SATB。G1 利用写屏障,会把并发标记期间被修改的引用都记录到一个 log buffer 里面,再重标记阶段重新处理这个 log。

(和 CMS 对比)这个机制和 CMS 是比较像的,差别在于 CMS 是把内存对应的卡表标记为脏,并且引入预清理阶段,在预清理和重标记阶段处理这些脏卡。

(和 Redis BG save 对比,抽象一下) 这种 snapshot + change log 的机制,在别的场景下也能看到。比如说在 Redis 的 BG Save 里面,Redis 在子进程处理快照的时候,主进程也会记录这期间的变更,存放在一个日志里面。后面再统一处理这些日志。

2 changes: 1 addition & 1 deletion gc/java_cms.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ CMS 的面试,大多数时候来说,就是面一下算法的大概流程,

### 什么时候会触发 Full GC?

分析:考察触发 Full GC的点。在 CMS的语境下,则是考察 CMS 触发的时机。一般人的回答都是从promotion failed这种角度,虽然也对,但是不够完整,严格意义上来说,CMS 的触发,有两种方式,一种是定时轮询,判断要不要 GC一种就是刚才提到的,`promotion failed` 这种不得不触发的时机。前者我们叫做主动触发,后者叫做被动触发。
分析:考察触发 Full GC的点。在 CMS的语境下,则是考察 CMS 触发的时机。一般人的回答都是从promotion failed这种角度,虽然也对,但是不够完整,严格意义上来说,CMS 的触发,有两种方式,一种是定时轮询,判断要不要 GC(这种其实严格来说,不是 Full GC,是 Major GC。不过国内的语境之下,经常会把 Major GC 和 Full GC 混为一谈);一种就是刚才提到的,`promotion failed` 这种不得不触发的时机。前者我们叫做主动触发,后者叫做被动触发。

主动触发的面试亮点在于,要阐述清楚,CMS 是如何轮询的,有什么参数可以控制;被动触发则是列举场景。

Expand Down

0 comments on commit 422b3c6

Please sign in to comment.