GC,Garbage Collection,垃圾回收
功能
如果没有GC,程序员需要手动进行内存管理,开发麻烦,容易引发内存泄漏、野指针,而且因此导致的BUG很难被定位,修复麻烦
如果有GC,就可以避免这些问题
这里的对象并不是OOP里的Object,而是被应用程序使用的数据的集合,对象由头和域构成
对象分为活动对象和非活动对象,GC会保留活动对象,销毁非活动对象
可以简单理解为应用程序,在程序运行过程中,会分配/调用/改变对象(只能操作活动对象),伴随着mutator运行,会产生垃圾
学这一节之前想想操作系统里文件系统
该算法分为两步
void mark(obj){
if(!obj.mark)
obj.mark = true;
for(child: obj.children)
mark(*child);
}
遍历堆,回收所有没有被标记的对象,并将其放入空闲链表,以备分配
在创建新对象 obj 时,遍历空闲链表,寻找合适的块,这里使用First-fit算法(因为快)
分配和回收会导致大量小的分块,于是需要合并,在这里直接遍历堆,将连续的空闲块合并
优点
缺点
使用多个空闲链表,分别只连接不同大小的分块,分配时先找所对应的区间,可以提高性能
将大小相近的对象整理成固定大小的块进行管理
不直接堆对象进行标记,而是记录对象的标志位,存储在一个表格中
学这一节前,想想智能指针
引用计数法中,对象会记录自己被引用次数,主要分为两个阶段
//更新指针ptr,让其指向obj
void update_ptr(ptr, obj){
inc_ref_cnt(obj); //obj要被ptr引用了,所以obj计数值++
dec_ref_cnt(*ptr); //ptr之前引用的东西不再被引用
*ptr = obj;
}
void dec_ref_cnt(obj){
obj.ref_cnt--; //obj不再被引用,所以计数值--
if(obj.ref_cnt == 0){ //如果obj没人用了,obj就要被清除
for(child: obj.children){ //obj被清除了,那obj引用的对象,被引用次数要--
dec_ref_cnt(*child);
}
reclaim(obj); //执行回收
}
}
优点:
缺点
解决频繁操作
void dec_ref_cnt(obj){
obj.ref_cnt--;
if(obj.ref_cnt == 0){ //计数值变为0,可能会变成垃圾
if(is_full($zct)){
scan_zct(); //如果zct表满了,就扫描zct,并回收
}
push($zct, obj); //将obj放入zct表
}
}
void scane_zct(){
fot(r: $root){
(*r).ref_cnt++; //根直接引用的对象的计数器++,直接引用计数时,根会极频繁改动
}
for(obj: $zct){
if(obj.ref_cnt == 0){
remove($zct, obj);
delete(obj);
}
}
fot(r: $root){
(*r).ref_cnt--;
}
}
解决空间浪费
32位电脑意味着可以有 $2^{32}$ 个对象,这些对象可能都会引用 obj ,所以 obj 的计数位应当可以记录被引用$2^{32}$次,所以计数位要有32位
如果计数位太少,一些比较大的对象就无法正常表示(直接做饱和运算,汽车速度超过速度计的最大值会停在最大值处),一旦出现这种情况,我们可以
//标记
void mark(){
for(r: $roots){
push(*r, $stack); //将所有根直接引用对象入栈
}
while(!$stack.empty()){
obj = pop($stack);
obj.ref_cnt++;
if(obj.ref_cnt == 1){ //这说明obj只进栈一次
for(child: obj.children){
push(*child, $stack);
}
}
}
}
//清除
void sweep(){
index = $heap_top;
while(index < $heap_end){ //遍历整个堆
if(index.ref_cnt == 0){
reclaim(index); //回收计数值为0的对象
}
index += index.size;
}
}
是 Sticky 的极端,计数位只有一位(两个 tag,一个 MULTIPLE,一个 UNIQUE),而且将计数位放在指针上,而非放在对象上

void copy_ptr(dest_ptr, src_ptr){
delete_ptr(dest_ptr);
*dest_ptr = *src_ptr;
set_tag(dest_ptr, MULTIPLE);
if(src_ptr.tag == UNIQUE){
set_tag(src_ptr, MULTIPLE);
}
}
void delete_ptr(ptr){
if(ptr.tag == UNIQUE)
reclaim(*ptr); //如果对象以前只被引用一次,那么这次就要被回收
}
优点
缺点
解决循环引用
只对可能会有循环引用的对象使用标记清除法,其他对象使用引用计数法
每个对象会有两个状态位(于是就有四个状态),分别为
void dec_ref_cnt(obj){
obj.ref_cnt--;
if(obj.ref_cnt == 0){
delete(obj);
}
else if(obj.color != HATCH){
obj.color = HATCH;
queue.push(obj);
}
}
对放入队列的对象进行标记清除算法
Object new_obj(size){
obj = pickup_chunk(size); //分配内存
if(obj != null){ //如果分配成功
obj.color = BLACK;
obj.ref_cnt = 1;
return obj;
}
else if(!queue.empty()){ //说明现在空间不足,要回收垃圾,先看是否存在HATCH物体
scan_hatch_queue();
return new_obj(size); //回收queue内后重新尝试分配
}
else{
allocation_fall();
}
}
void scan_hatch_queue(){ //循环扫描队列,直至队列为空
obj = queue.pop();
if(obj.color == HATCH){
paint_gray(obj); //把obj和其孩子变为GRAY,孩子们引用值--
scan_gray(obj); //引用值>0涂黑,等于0涂白
collect_while(obj);
}
else if(!queue.empty()){
scane_hatch_queue();
}
}
void paint_gray(obj){
if(obj.color == (BLACK | HATCH)){
obj.color = GRAY;
for(child: obj.children){
(*child).ref_cnt--;
paint_gray(*child);
}
}
}
void scan_gray(obj){
if(obj.color == GRAY){
if(obj.ref_cnt > 0){
paint_black(obj);
}
else{
obj.color = WHITE;
for(child: children(obj)){
scan_gray(*child);
}
}
}
}
void paint_black(obj){
obj.color = BLACK;
for(child : children(obj)){
(*child).ref_cnt++
if((*child).color != BLACK){
paint_black(*child)
}
}
}
void collect_while(){
if(obj.color == WHILE){
obj.color = BLACK;
for(child: obj.children){
collect_while(*child);
}
reclaim(obj);
}
}
优点
缺点
想一下渲染中的双缓冲
先把整个空间分为等大的两部分,From和To,我们用From空间分配活动对象
GC时,把所有活动对象(递归)复制到其他空间(To空间),然后把原空间(From空间)所有对象回收,然后把空间互换
优点
缺点
从递归复制改为迭代复制(基于队列的广度优先搜索)
下图搜索顺序:A BC DEFG HIJKLMNO

优点
缺点
在页间做深度优先搜索,在页内做广度优先搜索
下图搜索顺序:ABC DHI EJK FLM GNO

把空间分成十份,一个From,一个To,八个标记清除法
结合了标记清除法的标记+GC复制法的压缩
类比原地删除数组中某个元素
标记方法和标记清除法一样,标记后遍历堆,将所有活动对象压缩到左侧



优点
缺点
这个算法优缺点很明显,所以先说优缺点,再谈实现
优点
缺点
在Lisp2算法中,可以说几乎每一个活动对象都需要移动,一般来说越靠近左边,移动的概率、距离越小,而越后面则越大(可以类比幽灵堵车)
我们能不能固定前面的对象,只移动后面的对象,从而实现压缩呢?(如果后面的对象和前面的对象大小一致,完全可以)

算法分为三部:移动对象群,构建间隙表格,更新指针


把疑似指针的一律视为指针
常见的根有
在c++等语言里, int 这种内置变量(非指针)和 void* 指针都存放在栈里,GC无法区分两者的区别,保守式GC就干脆不区分两者
存储着不确定对象的根,称为不明确的根,而基于这些根的GC算法,被称为保守式GC(ambiguous GC)
我们发现,有的时候我们会遇到一些格式与指针一致的非指针,我们称之为貌似指针的非指针(false pointer)
在标记清除算法中,遇到这种貌似指针的非指针,会胡乱找到一个对象,我们不知道这个对象是活动对象还是非活动对象,所以一律视为活动对象,进行标记
注意:保守GC是只有当判断不出来到底是不是指针时才将其视为指针,而不是把所有对象都视为指针(迫不得已,凑合凑合用)
优点
缺点
正确的根(exact roots)可以精确地识别指针和非指针
基于正确的根的GC被称为准确式GC
//打标签
int addTag(int a){
a = a << 1; //小心溢出,如果会溢出,就用一个更大的数据结构
a = a|1;
return a;
}
//去标签(在使用时需要恢复成正常数值),而且使用完后还有再次打标签
int getValue(int a){
a = a >> 1;
return a;
}
除了打标签,还可以专门构建一个只存放指针的根,比如一些使用虚拟机(VM)的语言
优点
缺点
为什么保守式GC不能移动对象?因为一个false pointer可能是一个非指针,移动会导致问题
如图,即使我们移动了堆中的对象,也却不会改变根内的内容

优点:
缺点:
保守式GC复制算法
有的false pointer即使是非指针,错把他当指针用也可以找到一个对象(尽管这个过程是未定义的),如果这个非指针可以做到这个事,拿我们就把这个非指针指向的地址存放到黑名单中
黑名单中存放的是地址,如果在这些地址中分配空间,可能会导致未定义事件
所以在这些地址上分配对象时要十分小心,比如只能分配小对象、只能分配没有子对象的小对象,因为这些对象比较小,即使变成垃圾,被错误识别了,危害也比较小
引入了年龄这一概念,优先回收那些容易成为垃圾的对象
我们在引用计数法中提到过,绝大多数的对象生成后立刻会被回收,而被引用次数多多对象很难变成垃圾
于是我们引入年龄这一概念,初始为0,每经历一次GC算法,如果没有被回收,则年龄+1
我们发现年龄越大,说明对象越重要,越不容易被回收,于是年龄越大,参与回收的频率就越低
优点
缺点
想一想单核CPU并行的本质,就是将线程切分,来回切换
通过逐步推进垃圾回收来控制最大暂停时间,不一次执行完GC,而是执行一会mutator,执行一会GC

优点
缺点
这是一个2013年的算法,听懂掌声
将合并型引用计数法和 Immix 组合到一起,改善了引用计数法的吞吐量

