本文是书籍《垃圾回收的算法与实现》的摘抄,不涉及算法源码及步骤讲解模块。
预备
对象由头(header)和域(field)构成。
头:对象中保存对象本身信息的部分,主要含有以下信息:对象的大小和种类。
域:对象使用者在对象中可访问的部分,数据类型有指针、非指针;指针是指向内存空间中某块区域的值;非指针指的是在编程中直接使用值本身。数值、字符以及真假值都是非指针。
通过GC,对象会被销毁或保留,起关键作用的就是指针。GC是根据对象的指针指向去搜寻其他对象的。GC对非指针不进行任何操作。
一个GC算法的首要前提是能判别指针和非指针。
其次,要知道指针要指向对象的哪个部分。指针如果指向对象首地址以外的部分,GC就会变得非常复杂。好在,大多数语言中,指针都默认指向对象首地址。
mutator:改变GC对象间的引用关系,实际进行的操作有:生成对象,更新指针。
堆:执行程序时用于动态存放对象的内存空间。
GC是管理堆中已分配对象的机制。在开始执行mutator前,GC要分配用于堆的内存空间。一旦开始执行mutator,程序就会按照mutator的要求在堆中存放对象。等到堆被对象占满后,GC就会启动,从而分配可用空间。如果不能分配足够的可用空间,一般情况下就要扩大堆。
把$heap_start
定为指向堆首地址的指针,把$heap_end
定为指向堆末尾下一个地址的指针。即,$heap_end
等于$heap_start + HEAP_SIZE
,HEAP_SIZE表示堆的大小。
活动对象:将分配到内存空间中的对象中那些能通过mutator引用的对象;
非活动对象:把分配到堆中那些不能通过程序引用的对象,死掉的对象,即垃圾。
分配:allocation,指的是在内存空间中分配对象。当mutator需要新对象时,就会向分配器(allocator)申请一个大小合适的空间。
分块:chunk,指为利用对象而事先准备出来的空间。内存里的各个区块都重复着分块->活动对象->垃圾(非活动对象)->分块->…这样的过程。
根:root,指向对象的指针的起点部分。根,可以通过mutator直接引用。几种根:调用栈(Call Stack)、寄存器以及全局变量空间。
GC在一般情况下无法严谨地判断寄存器和调用栈中的值是指针还是非指针。
评价标准:
- 吞吐量:throughput,在单位时间内的处理能力。即便是同一GC算法,其吞吐量也是受mutator的动作左右的。评价GC算法的吞吐量时,有必要把mutator的动作也考虑在内。
- 最大暂停时间:因执行GC而暂停执行mutator的最长时间。高的吞吐量和短的最大暂停时间,两者不可兼得
- 堆使用效率:左右堆使用效率的因素有两个:头的大小、堆的用法。
在堆中堆放的信息越多,GC的效率也就越高,吞吐量也就随之得到改善。但毋庸置疑,头越小越好。因此为了执行GC,需要把在头中堆放的信息控制在最小限度。
可用的堆越大,GC运行越快;相反,越想有效地利用有限的堆,GC花费的时间就越长。 - 访问局部性:具有引用关系的对象之间通常很可能存在连续访问的情况;因此,把具有引用关系的对象安排在堆中较近的位置,就能提高在缓存中读取到想利用的数据的概率,令mutator高速运行。PC上有4种存储器,分别是寄存器、缓存、内存、辅助存储器。
标记清除
Mark Sweep GC,两个阶段,标记阶段把所有活动对象都做上标记。清除阶段把那些没有标记的对象,即非活动对象进行回收。
注意要避免重复进行标记处理。
标记所花费的时间是与活动对象的总数成正比的。
深度优先搜索比广度优先搜索更能压低内存使用量。因此在标记阶段经常用到深度优先搜索。
回收对象就是把对象作为分块,连接到被称为空闲链表
的单向链表。在之后进行分配时只要遍历这个空闲链表,就可以找到可用的分块。
在清除阶段,程序会遍历所有堆,进行垃圾回收。所花费时间与堆大小成正比。堆越大,清除阶段所花费的时间就会越长。
分配:搜索空闲链表并寻找大小合适的分块。
分配策略:
- First-fit:在pickup_chunk()函数中,最初发现大于等于size的分块时就会立即返回该分块;
- Best-fit:遍历空闲链表,返回大于等于size的最小分块;
- Worst-fit:找出空闲链表中最大的分块,将其分割成mutator申请的大小和分割后剩余的大小,目的是将分割后剩余的分块最大化。很容易生成大量小的分块,不推荐。
分配所需的时间短,可以选择使用First-fit更为明智。
合并:coalescing,在清除阶段进行,把所有的连续的小分块连在一起形成一个大分块。不连续的不能合并?
优点:
- 实现简单;
- 兼容保守式GC算法:不会移动对象,搭配保守式GC算法(对象不能被移动)。
缺点:
- 碎片化:
- 分配速度
- 与写时复制技术不兼容:
多个空闲链表
BiBOP法
BiBOP,Big Bag Of Pages,将大小相近的对象整理成固定大小的块进行管理的做法。试图解决GC标记-清除算法的碎片化问题:把堆分割成固定大小的块,让每个块只能配置同样大小的对象。
位图标记
对此我们有个方法,那就是只收集各个对象的标志位并表格化,不跟对象一起管理。在标记的时候,不在对象的头里置位,而是在这个表格中的特定场所置位。像这样集合了用于标记的位的表格称为“位图表格”(bitmap table),利用这个表格进行标记的行为称为“位图标记”。
散列表和树形结构
延迟清除法
清除操作所花费的时间与堆大小成正比。即待处理的堆越大,GC标记-清除算法所花费的时间就越长,结果就会妨碍到mutator的处理。
Lazy Sweep,缩减因清除操作而导致的mutator最大暂停时间的方法。
延迟清除的效果是不均衡的。程序在清除垃圾较多的部分时能马上获得分块,所以能减少mutator的暂停时间。然而一旦程序开始清除活动对象周围,就怎么也无法获得分块,会增加mutator的暂停时间。
引用计数法
Reference Counting,计数器是无符号的整数,用于计数器的位数根据算法和实现而有所不同。引用计数法中的对象如图所示:
优点:
- 可即刻回收垃圾:当被引用数的值为0时,对象马上就会把自己作为空闲空间连接到空闲链表;
- 最大暂停时间短:只有当通过mutator更新指针时程序才会执行垃圾回收。即,每次通过执行mutator生成垃圾时这部分垃圾都会被回收,因而大幅度地削减mutator的最大暂停时间;
- 没有必要沿指针查找:在各个计算节点内GC时使用GC标记-清除算法,在考虑到节点间的引用关系时则采用引用计数法。
缺点:
- 计数器值的增减处理繁重:对有根指针而言,根可以通过mutator直接被引用,指针更新时,计数器的值都得随之更新;
- 计数器需要占用很多位:用于引用计数的计数器最大必须能数完堆中所有对象的引用数,极端情况下,所有对象同时引用一个对象,大大降低内存空间使用效率;
- 实现烦琐复杂:实现不当,会产生Bug;
- 循环引用无法回收:两个对象互相引用,所以各对象的计数器的值都是1。但是这些对象组并没有被其他任何对象引用。
延迟引用计数法
计数器值增减处理繁重的原因之一是从根的引用变化频繁。
Deferred Reference Counting,让从根引用的指针的变化不反映在计数器上。
ZCT,Zero Count Table,会事先记录下计数器值在dec_ref_cnt()
函数的作用下变为0的对象。
优点:延迟根引用的计数,将垃圾批量回收,而不是有一个就回收一个。
缺点:
- 不能即刻回收垃圾;
-
scan_zct()
函数导致最大暂停时间延长。执行scan_zct()
函数所花费的时间与$zct
大小成正比。$zct
越大,要搜索的对象就越多,妨碍mutator运作的时间也就越长。要想缩减因scan_zct()
函数而导致的暂停时间,就要缩小$zct
。但是这样一来调用scan_zct()
函数的频率就得增加,降低吞吐量。
Sticky引用计数法
1位引用计数法
1bit Reference Counting,标签引用计数法,Sticky引用计数法的一个极端。
一个观察结论:几乎没有对象是被共有的,所有对象都能被马上回收。
计数器只有1位,0表示被引用数为1,状态用UNIQUE表示,1表示被引用数≥2,状态用MULTIPLE表示。
具体实现,因为指针通常默认为4字节对齐,所以没法利用低2位。
优点:
- 能利用高速缓存:不需要在更新计数器(标签)时读取要引用的对象;
- 节省内存消耗量。
缺点:
部分标记-清除算法
Partial Mark & Sweep,只对可能有循环引用的对象群使用GC标记-清除算法,对其他对象进行内存管理时使用引用计数法。执行一般的GC标记-清除算法的目的是查找活动对象,而执行部分标记-清除算法的目的则是查找非活动对象。
对象会被涂成4种不同的颜色来进行管理:
- BLACK:黑,绝对不是垃圾的对象,对象产生时的初始颜色;
- WHITE:白,绝对是垃圾的对象;
- GRAY:灰,搜索完毕的对象;
- HATCH:阴影,可能是循环垃圾的对象。
在对象头中分配2位空间,用00~11对应4个颜色。
部分标记-清除算法的特征就是要涂色的对象和要进行计数器减量的对象不是同一对象,据此就可以很顺利地回收循环垃圾。
局限性:从队列搜索对象所付出的成本太大,被队列记录的对象是候选垃圾,需要查找3次对象,即分别执行1次mark_gray()、scan_gray()及collect_white()方法,增加最大暂停时间。
复制
Copying GC,把某个空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉。将复制活动对象的原空间称为From空间,将粘贴活动对象的新空间称为To空间。From空间和To空间大小必须一致。
如果分块的大小不够,即分块小于所申请的大小时,比起启动GC,首先应分配足够大的分块。不然一旦分块大小不够,分配就会失败。
优点:
- 吞吐量高:GC标记-清除算法消耗的吞吐量是搜索活动对象(标记阶段)所花费的时间和搜索整体堆(清除阶段)所花费的时间之和。而GC复制算法只搜索并复制活动对象;
- 可实现高速分配:不使用空闲链表;比起GC标记-清除算法和引用计数法等使用空闲链表的分配,GC复制算法明显快得多;
- 没有碎片化:复制的操作可看做是压缩,会整理碎片;
- 缓存兼容:有引用关系的对象会被安排在堆里离彼此较近的位置,mutator执行速度极快。
缺点:
- 堆使用效率低下:把堆二等分,使用率只有一半;
- 不兼容保守式GC:必须移动对象重写指针,因此和保守式GC算法不相容;
- 递归调用函数:复制某个对象时要递归复制它的子对象,效率不如迭代好;每次递归调用时都会消耗栈,存在栈溢出的可能。
这里讲的是Fenichel和Yochelson的复制GC,为了方便,简称为FY复制GC。
Cheney复制GC
Cheney复制GC中没有用到COPIED标签,使用forwarding指针来判断对象是否复制完毕。
GC没复制的对象时,其指针指向From空间某处的对象。如果某个对象有着指向To空间某处的指针,则说明这个对象已复制完毕。即,obj.field1
指向To空间时,对象obj已复制完毕。
对比:
- FY复制是递归地复制,Cheney复制是迭代地复制;
- FY复制GC采用的是深度优先搜索,Cheney复制GC则是广度优先搜索;
优点:
- 迭代算法,可抑制调用函数的额外负担和栈的消耗;
- 拿堆用作队列,省去用于搜索的内存空间。
缺点:在搜索对象上使用广度优先搜索,不兼容缓存,即不能使用缓存。
近似深度优先搜索方法
4个重要的变量:
-
$page
:将堆分割成一个个页面的数组。$page[i]
指向第i
个页面的开头; -
$local_scan
:将每个页面中搜索用的指针作为元素的数组,$local_scan[i]
指向第i
个页面中下一个应该搜索的位置; -
$major_scan
:指向搜索尚未完成的页面开头的指针; -
$free
:指向分块开头的指针。
在搜索一开始把有引用关系的对象安排在同一个页面中。不管在哪一个页面里,对象间都存在着引用关系。因为采用的不是完整的广度优先搜索,而是在每个页面上分别进行广度优先搜索。
疑问:在每个页面上分别进行广度优先搜索,最后再整体使用深度优先搜索吗?
多空间复制算法
GC复制算法最大的缺点是只能利用半个堆。多空间复制算法,把堆N等分,对其中2块空间执行GC复制算法,对剩下的(N-2)块空间执行GC标记-清除算法,2种算法的组合。
优点:更有效利用堆空间。
缺点:即标记-清除算法的缺点,分配耗费时间、分块碎片化等。
标记压缩
Mark Compact GC,标记-清除+复制。
压缩阶段通过数次搜索堆来重新装填活动对象,跟GC复制不同,不用牺牲半个堆。
优点:有效利用堆,没有碎片化问题。
缺点:压缩花费计算成本,Lisp2算法的压缩阶段,必须对整个堆进行3次搜索。
Two-Finger算法
有一个很大的制约条件:必须将所有对象整理成大小一致。和Lisp2算法不同,没有必要为forwarding指针准备空间,只需要在原空间对象的域中设定forwarding指针即可。
2个步骤:移动对象、更新指针。
优点:Lisp2算法要事先确保每个对象都留有1个字用于forwarding指针。而Two-Finger算法能把forwarding指针设置在移动前的对象的域里,所以不需要额外的内存空间以用于forwarding指针,内存使用效率更高。吞吐量更高。
缺点:所有对象的大小必须一致;无法利用缓存。
使用BiBOP法,只要把同一大小的对象安排在同一个分块里,就能对每个分块应用Two-Finger算法。
将具有引用关系的对象安排在堆中较近的位置,就能够通过缓存来提高访问速度。
表格算法
用表格来压缩的方法,执行2次压缩操作。
2个步骤:移动对象(群)以及构筑间隙表格、更新指针。
间隙表格:Break Table,按照一个个活动对象群记录下压缩所需信息的表格。
优点:
- 没必要为压缩准备出多余的空间:利用分块,保留更换指针所必需的信息;
- 压缩前后保留对象顺序,可利用缓存。
缺点:
- 维持间隙表格需付出很高的代价:每次移动活动对象群都要进行表格的移动和更新;
- 在更新指针时也不能忽略搜索表格所带来的消耗。在更新指针前,如果先将表格排序,则表格的搜索就能高速化。不过排序表格也需要相应的消耗,并不能从根本上解决问题。
ImmixGC算法
3个步骤:选定备用的From块、搜索阶段、清除阶段。
根据对象大小分成3类:
- 小型对象:线以下大小;
- 中型对象:比线大,不到8K字节;
- 大型对象:大于等于8K字节,Immix GC不予管理,Jikes的MMTk来处理。
优点:
缺点:
保守与准确GC
保守GC
Conservative GC,不能识别指针和非指针的GC。
不明确的根,Ambiguous Roots
准确GC
Exact GC,能正确识别指针和非指针的GC。创建正确的根的方法有很多,共通点就是需要语言处理程序的支援。
打标签
不把寄存器和栈等当作根
间接引用
MostlyCopyingGC
前提条件:
- 根是不明确的根
- 没有不明确的数据结构
- 对象大小随意
- CPU是32位
黑名单
保守式GC的缺点之一:指针的错误识别,即本来应该被看作垃圾的对象却被保留下来。
分代垃圾回收
Generational GC,对象引入年龄概念。
Minor GC,对新生代对象的GC。新生代GC后存活一定次数的新生代对象晋升(promotion)为老年代对象。Major GC,对老年代对象的GC。
Ungar分代垃圾回收
堆结构图,用$new_start
、$survivor1_start
、$survivor2_start
、$old_start
4个变量指向3类空间的开头,
对象的头部中除包含对象的种类和大小外,还有以下这3条信息:
- age:对象年龄,对象从新生代GC中存活下来的次数,超过可配置的一定次数,晋升为老年代;
- forwarded:已经复制完毕的标志,用于防止重复复制相同对象;
- remembered:已经向记录集记录完毕的标志,用来防止向记录集中重复记录的标志,老年代使用,前两个是新生代使用。
优点:提高吞吐量。
缺点:
记录各代之间的引用的方法
Ungar的分代垃圾回收使用记录集来记录各代间的引用,为了直接记录发出引用的对象,对应每个发出引用的对象各花费1个字节。如果各代之间的引用很多,还会出现记录集溢出。
卡片标记
卡片标记:Card Marking,首先把老年代空间按照相等的大小分割开来,分割出来 的一个个空间就称为卡片,还要为每个卡片准备一个与其对应的标志位,并将这个位作为标记表格(Mark Table)进行管理。GC时会寻找位图表格。当找到设置标志位的卡片时,就会从卡片开头开始寻找指向新生代空间的引用。
当因为改写指针而产生从老年代对象到新生代对象的引用时,要事先对被改写的域所属的卡片设置标志位,可通过写入屏障来实现。即使对象跨两张卡片,也不会有什么问题。
优点:
- 能有效利用内存空间。每个128字节(1024位)的新生代空间都只需要一个位来用作标记,所以整个位表(Bit Table)只需要老年代空间的1/1024的空间。
- 无论从老年代空间指向新生代空间的引用怎么增加,都不会发生像记录集那样溢出的情况。
缺点:如果在标记表格里设置很多位,在搜索卡片上可能花费大量时间。因此只有在局部存在从老年代空间指向新生代空间的引用时,卡片标记才能发挥作用。
页面标记
页面标记:Page Marking,是考虑到多数OS以页面为单位来管理内存空间。
一旦mutator对堆内的某一个页面进行写入操作,OS就会设置跟这个页面对应的位,把这个位叫作页面重写标志位(Dirty Bit)。卡片标记中是搜索标记表格,页面标记则是搜索这个页面重写标志位。
为不能利用页面重写标志位的OS准备一种方法,即利用内存保护功能:在mutator执行过程中保护老年代空间不被写入,当mutator写入老年代空间时,通过异常处理来检测出这项操作。在异常处理函数的内部,和卡片标记一样,事先设置与发生写入的页面对应的位。
页面标记只适用于能利用页面重写标志位或能利用内存保护功能的环境。另外,因为检测出所有对页面进行的写入操作,所以除了在生成从老年代空间指向新生代空间的引用时,在其他情况下也需要搜索页面。
多代垃圾回收
分代GC有两个年代。Multi Generational GC,多代垃圾回收,有3个年代以上的GC。比如4代GC示意图:
除最老那一代外,每代都有一个记录集。X代的记录集只记录来自比X老的其他代的引用。分代数量越多,对象变成垃圾的机会也就越大,所以这个方法确实能减少活到最老代的对象。
当然,不能过度增加分代数量。分代数量越多,每代能使用的空间也就相应变小,各代之间的引用变多,各代中垃圾回收花费的时间也就越来越长。
列车垃圾回收
Train GC,为了在分代垃圾回收中利用老年代GC而采用的算法,可控制老年代GC中暂停时间的增长。
增量垃圾回收
Incremental GC,一种通过逐渐推进垃圾回收来控制mutator最大暂停时间的方法。
Steele算法
使用的写入屏障要比Dijkstra的写入屏障条件更严格,减少GC中错误标记的对象。
汤浅算法
比较各个写入屏障
RC Immix算法
Reference Counting Immix,将引用计数法吞吐量低这一缺点改善到实用级别。
合并型引用计数法
在引用计数法中,每当对象间的引用关系发生变化,都要增减该对象的计数器,以保证其数值一直是正确的。这是通过写入屏障实现的。如果计数器频繁发生增减,则写入屏障的执行频率就会增加,处理就会变得繁重。
一个事实:如果对同一个对象分别进行一次增量和减量操作,两者会互相抵消,最终计数器的值并没有变化。
Coalesced Reference Counting,考虑选定一个时间区间,在该期间内不进行计数器的增减。指针改动时的信息(对象和其所有子对象)会被注册到更改缓冲区(Modified Buffer),这项操作是通过写入屏障来执行的。等到更改缓冲区满了,就要运行GC。如果在对象间的引用关系发生变化时,计数器没有增减的话,就会导致有一段时间计数器值是错误的。
优点:增加吞吐量。它不是逐次进行计数器的增减处理,而是在某种程度上一并执行,所以能无视增量和减量相抵消的部分。尤其在一个对象频繁更新指针的情况下,合并型引用计数法能起到很大的作用。
缺点:增加mutator的暂停时间,在查找更改缓冲区的过程中需要让mutator暂停。减小更改缓冲区的大小,能相应缩短暂停时间,不过吞吐量就会降低。需要加以权衡。
合并型引用计数法+Immix
优缺点
优点:通过RC Immix,吞吐量低得到大幅度的改善,原因有2点:
- 导入合并型引用计数法:没有通过写入屏障来执行计数器的增减操作,所以即使对象间的引用关系频繁发生变化,吞吐量也不会下降太多;
- 撤除空闲链表:通过以线为单位来管理分块,在线内移动指针就可进行分配。还省去把分块重新连接到空闲链表的处理。
缺点:
- 会增加暂停时间:可以通过调整更改缓冲区的大小来缩短暂停时间;
- 只要线内还有一个非垃圾对象,就无法将其回收。在线的计数器是1,线内还有一个活动对象的情况下,会白白消耗大部分线。