分代GC前置概念:Card Table 和 Remembered Set

分代GC的概念

所谓GC(Garbage Collection)指运行时对托管堆上的无用对象(垃圾内存)进行回收的操作。而托管堆上的对象的生命周期并不是完全一样的,有的对象生命周期很长,在相当长的一段时间内都不应该被回收,另一部分对象的生命周期则很短,在创建后很快就不再被使用,可以将其所占内存回收再分配。

分代GC的出现正是基于这样一个假设:大部分对象的生命周期都很短,只有一小部分对象需要长期存在。

分代GC的大体思想是:

  • 将堆内存分为初生代和旧生代两代。
  • 新创建的对象原则上都分配在初生代内存上,默认它们有可能很快就被回收。
  • 当对初生代进行过GC操作后(一次或多次),没有被回收掉的对象被认为是筛选出来的少量长生命周期的对象,转移到旧生代。

将GC分代的目的也很明显,既然有些对象的生命周期很长,那就没必要每次GC的时候都对全体对象进行扫描,所以干脆划出一块区域来,专门存放那些长生命周期的对象,也就是所谓的旧生代。这样,对于旧生代可以以更低的频率进行GC操作。换句话讲,大部分GC过程只需要扫描初生代内的对象,从而减少扫描对象的数量,以降低GC的耗时。

理论虽然很美好,但实际上经常会有从旧生代对象引用到初生代对象的跨代引用情况存在。这就导致为了保证能够正确标记初生代对象的可达性,不漏掉从旧生代产生的引用,就不得不在扫描初生代对象的时候同时也扫描整个旧生代对象。于是分代就分了个寂寞。

那有没有可能在对初生代进行GC的时候不用扫描整个旧生代对象呢?答案显然是肯定的。我们只要通过某种手段,记录下来旧生代中哪些内存中有对初生代对象的引用,在扫描时只扫描旧生代中这些指定的部分即可。

于是就有了卡表的概念。

卡表(Card Table)

在初始的分代GC当中,初生代和旧生代均被设计成连续的内存区域。而对于旧生代占领的这片区域,我们以固定的大小(比如512KB)将其划分成等大的小块,这每一个小块,被称为一个Card 或者 Card Page(卡页)。

现在我知道了整个旧生代区域的起始内存地址,又知道了每个Card的大小,只要给定一个Card的编号,就可以通过偏移算出指定Card的起始内存地址(同样也知道了结束内存地址)。

当初生代的对象被旧生代的对象引用到的时候,只需要记录下来那个旧生代对象所在的Card,在GC扫描的时候,除了从正常的Root出发的遍历,再把我们之前记录下来的Card也作为Root进行一次遍历,就可以收集到完整的引用关系,而不需要对整个旧生代都扫描一遍。

那这个所谓的记录,要记录在哪呢?我当然可以在每个初生代对象的身上都记录一份Card列表,表示有哪些Card引用到了这个对象,但这样做效率会比较低。比如Card1既引用了对象A又引用了对象B,那就需要在A和B中都记录一份,不光浪费了内存,再扫描的时候从A出发和从B出发,会分别找到Card1,从而对Card1做了两次扫描。所以,更好的做法是,用一个全局的列表记录所有Card的引用情况,这就是我们要说的Card Table——卡表。

卡表是一个Byte数组,数组中的每一个元素对应一个Card的引用情况,初始为0,当某个初生代对象被旧生代对象引用的时候,根据旧生代对象的内存地址,计算出其所属的Card,然后将卡表中对应下标的元素标记为1(标脏)。最后只要对标记为1的Card(Dirty Card)进行额外的扫描即可。这样即使Card1同时引用了对象A和对象B,在扫描时也只会扫描一次。

注意一点,Card不是一个对象,而是一个内存块,这里所说的对Card进行扫描,也是指对这一块内存中的所有对象进行扫描。

在这里插入图片描述

RSet(Remembered Set)

JVM的G1收集器提出了一种更为先进的内存管理方式。在这种内存管理方式中,初生代和旧生代不再要求是整块的连续内存,而是将整个堆都分割成固定大小的内存块,称为区域(Region),每个区域内还是按照上文说的对应了不同的Card。
在这里插入图片描述
当然G1也是分代的,不同的Region会有自己的身份标识,标记当前Region是用于初生代还是旧生代。不同于CMS这种对整个分代内存区域进行垃圾回收的做法,G1是以Region为单位进行回收,在大部分情况下,单个GC过程中只需要根据优先级对其中某些Region进行回收操作,也就是说,G1的思想是,我只要回收一部分Region,以一种有效率的方式持续释放出内存供程序使用即可。

既然如此,GC扫描时自然也希望只扫描这一小部分Region。但是想想也知道,虽然按照优先级只要对Region1进行回收,但是Region1中的对象A很可能被Region2中的某个对象B引用了。这种跨Region的引用问题跟上文提到的跨代引用是不是很像。为了避免由于跨Region引用导致的对所有Region都进行扫描,我们也可以像刚才说的那样,通过某种方式对这种跨Region的引用进行记录,同样在最后把记录的额外Region再扫描一遍。

这个用于记录跨Region引用的结构就是Remembered Set。与卡表是一个全局数据不同,每个Region都会持有一个自己的RSet,并且RSet是Point In的,即记录 “谁引用了我” 。当对象A(位于Region1)被对象B(位于Region2)引用时,由于对象B来自于另外的Region,于是会在被引用的一方,也就是对象A所在Region1的RSet中增加一条记录,记下“Region2引用了我”。

为什么RSet不设计成跟卡表一样的全局数据呢?我的理解是,像CMS这种GC方式由于是对整个代际内存都进行处理,因此需要查看所有的卡表记录,因而只要有一个全局卡表,整体扫一遍即可,而G1只需要对其中几个Region进行GC,只需要查看目标Region的RSet,单个Region进行记录更方便。

另外,刚才说的 “Region2引用了我” 只是一个很抽象的说法,实际上不同Region之间进行引用的情况非常常见,这就导致不同Region的RSet要记录的内容数量跨度很大,有的RSet只需要记录几条引用关系,有的则需要记录非常多。为了应对这种情况,RSet设计了一个三种粒度的存储方式。

  • 稀疏表——当引用数量不多的时候,通过一个哈希表进行记录,哈希表的Key为Region的Index,表明哪个Region引用了我,哈希表的Value为Card的Index(数组),表明具体是这个Region里的哪些Card中的对象引用了我。这种情况可以很快的找到要对哪些具体的Card进行扫描。
  • 细粒度——当Region中要记录的Card越来越多,达到一个阈值时(据说只有4),RSet就会改变粒度,用一个新的链表结构记录来源Region,链表中的每一项代表一个Region,其中通过一个位图记录该Region中的哪些Card对我有引用。这种情况通过遍历位图也可以较快的找到要扫描的Card。
  • 粗粒度——当对我进行引用的Region越来越多,多到一定数量后,RSet会再次改变粒度,只通过一个位图来记录有哪些Region引用了我,而不再具体记录Region内部的Card。这种情况就只能挨个Card查看了。