17G1垃圾回收器原理

G1垃圾回收有两种方式:

1、年轻代回收(Young GC)

2、混合回收(Mixed GC)

1.年轻代回收(Young GC)

年轻代回收只扫描年轻代对象(Eden + Survivor),所以从GC Root到年轻代的对象或者年轻代对象引用了其他年轻代的对象都很容易扫描出来。

这里就存在一个问题,年轻代回收只扫描年轻代对象(Eden + Survivor),如果有老年代中的对象引用了年轻代中的对象,我们又如何知道呢?

比如上图中,E对象被对象引用了,那么显然在垃圾回收时E对象是不应该被回收的。

方案1:从GC Root开始,扫描所有对象,如果年轻代对象在引用链上,就标记为存活。

重新扫描一遍GC Root关联的所有对象,包括老年代的。这个方案显然不可行,需要遍历引用链上所有对象,效率太低。

方案2:维护一个详细的表,记录哪个对象被哪个老年代引用了。在年轻代中被引用的对象,不进行回收。

如上图中,通过引用详情表记录F和E对象分别被A和B对象引用了。问题:如果对象太多这张表会占用很大的内存空间。存在错标的情况

方案2的第一次优化:只记录Region被哪些对象引用了。这种引用详情表称为记忆集 RememberedSet(简称RS或RSet):是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。扫描时将记忆集中的对象也加入到GC Root中,就可以根据引用链判断哪些对象需要回收了。

问题:如果区域中引用对象很多,还是占用很多内存。

方案2的第二次优化:将所有区域中的内存按一定大小划分成很多个块,每个块进行编号。记忆集中只记录对块的引用关系。如果一个块中有多个对象,只需要引用一次,减少了内存开销。

每一个Region都拥有一个自己的卡表,如果产生了跨代引用(老年代引用年轻代),此时这个Region对应的卡表上就会将字节内容进行修改,JDK8源码中0代表被引用了称为脏卡。这样就可以标记出当前Region被老年代中的哪些部分引用了。那么要生成记忆集就比较简单了,只需要遍历整个卡表,找到所有脏卡。

那么怎么样去维护这个卡表呢?或者说怎么知道A对F引用了?

JVM使用写屏障(Write Barrier)技术,在执行引用关系建立的代码时,可以在代码前和代码后插入一段指令,从而维护卡表。

记忆集中不会记录新生代到新生代的引用,同一个Region中的引用也不会记录。

记忆集的生成流程分为以下几个步骤:

1、通过写屏障获得引用变更的信息。

2、将引用关系记录到卡表中,并记录到一个脏卡队列中。

3、JVM中会由Refinement 线程定期从脏卡队列中获取数据,生成记忆集。不直接写入记忆集的原因是避免过多线程并发访问记忆集。

执行流程:

更详细的分析下年轻代回收的步骤,整个过程是STW的:

1、Root扫描,将所有的静态变量、局部变量扫描出来。

2、处理脏卡队列中的没有处理完的信息,更新记忆集的数据,此阶段完成后,记忆集中包含了所有老年代对当前Region的引用关系。

3、标记存活对象。记忆集中的对象会加入到GC Root对象集合中,在GC Root引用链上的对象也会被标记为存活对象。

4、根据设定的最大停顿时间,选择本次收集的区域,称之为回收集合Collection Set。

5、复制对象:将标记出来的对象复制到新的区中,将年龄加1,如果年龄到达15则晋升到老年代。老的区域内存直接清空。

6、处理软、弱、虚、终结器引用,以及JNI中的弱引用。

G1年轻代回收核心技术

1、卡表 Card Table

每一个Region都拥有一个自己的卡表,卡表是一个字节数组,如果产生了跨代引用(老年代引用年轻代),G1会将卡表上引用对象所在的位置字节内容进行修改为0, 称为脏卡。卡表的主要作用是生成记忆集。

卡表会占用一定的内存空间,堆大小是1G时,卡表大小为1G = 1024 MB / 512 = 2MB

2、记忆集 RememberedSet(简称RS或RSet)

每一个Region都拥有一个自己的记忆集,如果产生了跨代引用,记忆集中会记录引用对象所在的卡表位置。标记阶段将记忆集中的对象加入GC ROOT集合中一起扫描,就可以将被引用的对象标记为存活。

3、写屏障 Write Barrier

G1使用写屏障技术,在执行引用关系建立的代码执行后插入一段指令,完成卡表的维护工作。

会损失一部分的性能,大约在5%~10%之间。

2.混合回收(Mixed GC)

多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC。

混合回收会由年轻代回收之后或者大对象分配之后触发,混合回收会回收 整个年轻代 + 部分老年代。

老年代很多时候会有大量对象,要标记出所有存活对象耗时较长,所以整个标记过程要尽量能做到和用户线程并行执行。

混合回收的步骤:

1、初始标记,STW,采用三色标记法标记从GC Root可直达的对象。

2、并发标记,并发执行,对存活对象进行标记。

3、最终标记,STW,处理SATB相关的对象标记。

4、清理,STW,如果区域中没有任何存活对象就直接清理。

5、转移,将存活对象复制到别的区域。

2.1初始标记

初始标记会暂停所有用户线程,只标记从GC Root可直达的对象,所以停顿时间不会太长。采用三色标记法进行标记,三色标记法在原有双色标记(黑也就是1代表存活,白0代表可回收)增加了一种灰色,采用队列的方式保存标记为灰色的对象。

黑色:存活,当前对象在GC Root引用链上,同时他引用的其他对象也都已经标记完成。

灰色:待处理,当前对象在GC Root引用链上,他引用的其他对象还未标记完成。

白色:可回收,不在GC Root引用链上。

初始所有对象都是默认为白色,初始值为0:

三色标记中的黑色和白色是使用位图(bitmap)来实现的,比如8个字节使用1个bit来标识标记的内容,黑色为1,白色为0,灰色不会体现在位图中,会单独放入一个队列中。如果对象超过8个字节,仅仅使用第一个bit位处理。

将GC Root可以直到的对象D标记,D没有其他引用对象,所以直接标记为为黑色:

接下来将B对象标记,由于B关联了A和C,而A和C没有标记完成,所以B是待处理状态,将B送入灰色队列。

2.2并发标记

接下来进入并发标记阶段,继续进行未完成的标记任务。此阶段和用户线程并发执行。

从灰色队列中获取尚未完成标记的对象B。标记B关联的A和C对象,由于A和C对象并未引用其他对象,可以直接标记成黑色,而B也完成了所有引用对象的标记,也标记为黑色。

最后从队列获取C对象,标记为黑色,E也标记为黑色。所以剩余对象F就是白色,可回收。

最后从队列获取C对象,标记为黑色,E也标记为黑色。所以剩余对象F就是白色,可回收。

三色标记存在一个比较严重的问题,由于用户线程可能同时在修改对象的引用关系,就会出现错标的情况,比如:

这个案例中正常情况下,B和C都会被标记成黑色。但是在BC标记前,用户线程执行了 B.c = null;将B到C的引用去除了。

同时执行了A.c = c; 添加了A到C的引用。此时会出现严重问题,C是白色可回收一旦回收代码中再去使用对象会造成重大问题。

如果接着处理B:

B在GC引用链上,没有引用任何对象,所以B标记为黑色:

这样C虽然在引用链上,但是被回收了。

G1为了解决这个问题,使用了SATB技术(Snapshot At The Beginning, 初始快照)。SATB技术是这样处理的:

1、标记开始时创建一个快照,记录当前所有对象,标记过程中新生成的对象直接标记为黑色。

2、采用前置写屏障技术,在引用赋值前比如B.c = null之前,将之前引用的对象c放入SATB待处理队列中。SATB队列每个线程都有一个,最终会汇总到一个大的SATB队列中。

最终队列处理完之后,C和F就可以完成标记了。

SATB的缺点是在本轮清理时可能会将不存活的对象标记成存活对象,产生了一些所谓的浮动垃圾,等到下一轮清理时才能回收。比如图中的E对象。

转移的步骤如下:

1、根据最终标记的结果,可以计算出每一个区域的垃圾对象占用内存大小,根据停顿时间,选择转移效率最高(垃圾对象最多)的几个区域。

2、转移时先转移GC Root直接引用的对象,然后再转移其他对象。

先转移A对象:

接下来转移B对象:

3、回收老的区域,如果外部有其他区域对象引用了转移对象,也需要重新设置引用关系。

多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC。

混合回收会由年轻代回收之后或者大对象分配之后触发,混合回收会回收 整个年轻代 + 部分老年代。

老年代很多时候会有大量对象,要标记出所有存活对象耗时较长,所以整个标记过程要尽量能做到和用户线程并行执行。

14 条评论

  1. Mark695 2025-11-30 19:31 回复
  2. Irene80 2025-12-02 10:22 回复
  3. Blanca4146 2025-12-17 15:20 回复
  4. Travis3516 2025-12-17 19:52 回复
  5. Jonathan2987 2026-01-01 21:28 回复
  6. Daphne4010 2026-01-09 17:59 回复
  7. Aniya4576 2026-01-12 12:15 回复

回复 Arlo130 取消回复

您的邮箱地址不会被公开。 必填项已用 * 标注