「垃圾回收的算法和实现」 第五章-标记压缩算法

Posted by Dawn-K's Blog on January 23, 2022

标记压缩算法

思路

标记压缩算法也是移动对象进行垃圾回收的算法。不过和 gc 复制不同的是,它自始至终只使用一个空间。每次复制的时候,都是向堆的低地址端进行复制,就像把所有活动对象一口气挤到了最左端,把垃圾挤没了一样。

算法实际上是不可能一遍就直接把对象给挪到合适的位置的。因为如果直接挪动的话,会无法修正其指向子对象的指针。

所以需要两次扫描,一次先设定好这个对象要去的位置,把指针也修正好。第二次真正的开始进行挪移。

最开始的方案可以理解为要保证对象在内存中的相对顺序,所以采用了三遍的方式。在遍历之前先进行标记,然后第一遍是设定位置,第二遍是修正指针,第三遍是挪动对象。这种算法我们称为 Lisp2 算法。

代码

$scan 表示正在下一个要扫描的对象的首地址 $free 表示如果挪动的话空闲位置的首地址 $startStart 堆的首地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//  在标记后调用
compactionPhase(){
    setForwardPtr();
    adjustPtr();
    moveObj();
}

// 设置每个对象的新位置
setForwardPtr(){
    scan = $free = $heapStart;
    while(scan < $heapStart + $heapSize){
        // 引用
        obj = *scan;
        if(obj.mark == True){

            obj.forward = $free;
            $free += obj.size;
        }
        scan += obj.size;
    }
}

// 修正指针
adjustPtr(){
    for(r : $roots){
        r = r.forward;
    }

    scan = $heapStart;
    while(scan < $heapStart + $heapSize){
        // 引用
        obj = *scan;
        if(obj.mark == True){
            for(child : children(obj)){
                child = child.forward;
            }
        }
        scan += obj.size;
    }
}

moveObj(){
    for(r : $roots){
        r = r.forward;
    }
    // 这里顺带把 free 也给维护了,方便之后使用
    scan = $free =  $heapStart;
    while(scan < $heapStart + $heapSize){
        obj = *scan;
        if(obj.mark == True){
            // 同理此处还是当做引用
            newObj = *obj.forward;
            copyData(obj,newObj);
            newObj.forward= NULL;
            newObj.mark = False;
            $free += obj.size;
        }
        scan+=obj.size;
    }
}

优点

显然标记压缩算法获得了接近两倍于 gc 复制算法的堆的利用效率。只需要额外记录 forward 和 mark 即可。

相比于标记清扫算法,能够更有效的利用堆。

缺点

  1. STW 时间长,正比于堆中所有对象的数量。
  2. 搜索三次,导致每次搜索时间都会很长。

优化

Two-Finger 算法

TF 算法的思想是指经历两次扫描来进行优化。但是它有个严重的制约条件: 所有对象大小必须一致

第一次扫描,使用双指针,将所有右侧的对象都填到左边的空位中。左侧的指针叫 $free , 右侧的叫 $live

第二次扫描,修正所有子对象的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 移动对象
moveObj(){
    $free = $heapStart;
    $live = $heapStart+$heapSize;
    while($free<$live){
        while($free<$live && $live.mark == False){
            // 事实上,所有对象的 size 都是一样的
            $live-=$live.size;
        }

        while($free<$live && $free.mark == True){
            $free+=$free.size;
        }

        if($free<$live){
            copyData($live,$free);
            $live.mark=False;
            $live.forward = $free;
            $free.mark=True;
        }
    }
}

adjustPtr(){
    for(r : $roots){
        // 这里算是个优化,只需要动 $free 后面的对象即可,前面的原有的对象不会动的
        if(r >=$free){
            r = r.forward; 
        }
    }

    scan = $heapStart
    while(scan < $free){
        scan.mark = False;
        for(child : children(scan)){
            // 同上文
            if(child >= $free){
                child = child.forward;
            }
        }
        scan += scan.size;
    }
}

这里的 forward 指针不必放在头中,可以放在第一个域里面。只需要两次扫描。

但是缺点也很明显,对象大小一致这个太过苛刻。可以尝试与前文的 BiBop 方法结合。另外此算法也对缓存不友好。

表格算法

表格算法也是两次扫描,不过保证了先后顺序。这个算法是一群一群的挪动,而且采用表格而不是 forward 来操控。

首先算法通过扫描,每当扫出一个连续的对象群,就会获得以下信息:

  1. 这个对象群之前的空闲位置的首地址
  2. 这个对象群的长度

我们就这样一个一个地将对象群挪放到其前面的空闲位置,也就是向左挤压,然后在他们后面放置一个表格用来记录这对象群的信息,以用来稍后修复指针。

表格可以理解成 vector<pair<int,int>> ,每个元素记录一个对象群的原起始位置以及滑动的距离(也就是新旧首地址之差)

先将对象群移动到左端,然后在原来它存在的区间最后一个位置的后一个位置(也就是存放区间的超尾)放一个表格

然后移动下一个对象群的时候,是很有可能覆盖掉前一个表对象群的表格的,先在超尾存放自己的表格额,然后把之前的表格都放到这个表格后面。所以表格的顺序并不一定是按照对象群首地址的位置排列的。

所以在之后的更新指针就需要做额外的工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
adjustPtr(){
    for(r : $roots){
        r = newAddress(r);
    }

    scan = $heapStart;
    while(scan < $heapStart + $heapSize){
        if(scan.mark == True){
            for(child : children(scan)){
                child = newAddress(child);
            }
        }
    }
}

// 寻找 obj 的新地址
newAddress(obj){
    // table 中是一个 pair
    bestSize,bestAddress = table[bestPos];
    // 寻找首地址小于等于 obj 的元素的最大值
    for(address,size : table){
        if(address <= obj && address > bestAddress){
            bestSize = size 
            bestAddress = address;
        }
    }
    return obj-size;
}

优点

优点是不需要多余的空间,还保持了所有对象的顺序,并且还不需要规定每个对象的大小

缺点

缺点也很明显,引入了维护表格的开销,虽然可以通过对表格排序来将查找过程变为二分查找,但是排序本身也有损耗。