垃圾回收

本文只是简单的介绍一下垃圾回收的一些内容,当然也会有适当的讨论,但不会太深入。如果有兴趣可以看这本书《THE GARBAGE COLLECTION HANDBOOK:The Art of Automatic Memory Management》。这本书大致过目了一下,前半部分写的相当有漫谈的感觉,也有可能是翻译的原因,需要一定的知识量。

显示内存释放

比如,C语言中的 free,C++中的delete操作符,需要手动的调用。

显示内存释放存在2个方面的问题:

  • 开发者可能过早的释放依然在引用的对象
  • 开发者可能忘记释放对象

这两种错误在并发编程的场景导致的问题更为严重。

但是,这是不得不面对的,因为对于C/C++这种语言来说,给你一个 uint64_t 类型的值,你根本无法判断这个值到底就只是一个单纯的 uint64_t 呢?还是一个指针?如果它是一个指针,那么它指向的内存块就不能被回收。如下:

arduino 复制代码
uint64_t create(size_t size)
{
 return (uint64_t) malloc(size);
}

代码中,我们分配了一块内存,但是将它的地址转成了一个 uint64_t 的类型返回出去了。对于这样的内存块,是没有一个"显示的指针"指向它的。所以,它能被回收吗?

像这种一个 uint64_t 打天下的代码而且还不少。

自动动态内存管理

GC 可以将未被任何可达对象引用的对象回收,它引入了一个明确的概念,就是垃圾。这种管理机制,可以解决大部分显示内存释放中出现的问题。

Mark-sweep garbage collection

标记-清扫,标记-复制,标记-整理,引用计数是4种最基本的垃圾回收策略。大多数回收器可能会将其进行缝合,例如,在堆中的某一区域使用一种回收策略,在另一个区域使用另一种回收策略。

在后面讨论这4种回收策略的时候,我们假设当gc线程运行的时候,其他线程都处于停止(stop the world)状态。

我们先来看标记-清扫算法。它分为两步,第一步是标记,标记并不是直接检测垃圾本身,而是先确定所有存活对象,然后反过来判定其他对象都是垃圾。

首先,我们需要了解一个概念,叫做赋值器。GC 我们知道是垃圾回收器,赋值器就是与之对应的,用来分配新对象,并且修改对象的引用关系,可以理解为 "=" 后面的幕后黑手。

有一些指针是赋值器可以直接访问的,我们称之为 GCRoots。GCRoot 指向堆区的对象,借助GCRoots 我们可以访问整个堆区分配的对象。

以 Linux x86-64 为例,看下面的图:

GCRoots 可能会出现在 .data 区域,stack 区域,还有就是 reg(寄存器中)。泛化一下,就是全局/静态存储,线程本地储存(线程栈)等。

有了 GCRoots,我们就可以画下面这张图了:

可以看到,GCRoots 与 Heap 中的对象构成了一张有向图,对于图的遍历,我们有两种算法,一种是DFS,另一种是BFS。我们采用 DFS 来遍历图。

首先,定义两个类:

csharp 复制代码
// GcRoot 指向堆里面对象的引用
class GcRoot {

    private RefObj ref;

    public RefObj getRef() {
        return ref;
    }

}

// 堆对象里面有指向其他堆对象的引用
class RefObj {

    private List<RefObj> refs;

    private boolean marked;

    public List<RefObj> getRefs() {
        return refs;
    }

    public void mark() {
        marked = true;
    }

    public boolean isMarked() {
        return marked;
    }

}

然后,我们定义一个 GC 类,里面有一个 gcRoots 变量已经收集了所有的 gcRoot:

scss 复制代码
class GC {

    private List<GcRoot> gcRoots;

    private void dfs(RefObj obj) {
        if(obj.isMarked()) {
            return;
        }

        for (int i = 0; i < obj.getRefs().size(); i++) {
            dfs(obj.getRefs().get(i));
        }

        obj.mark();
    }

    public void markFromRoots() {
        for(int i=0; i<gcRoots.size(); ++i) {
            RefObj refObj = gcRoots.get(i).getRef();
            if(!refObj.isMarked()) {
                dfs(refObj);
            }
        }
    }

}

上面的 markFromRoots 算法其实很简单,真正需要了解的是 obj.mark() 方法里面到底做了什么。

在之前的文章里面。我们做完了 csapp 的 malloc lab,实现了一个简易的 malloc 与 free 方法。回忆一下一个最简单的 heap block 的结构:

由于 block size 是8字节对齐,所以 Header 的最后3位永远为0。最后一位用来标记该 block 是 free 的还是 allocated 的,还有2个 bit,我们可以将其中的一个 bit 用来标记该 block 是不是被 mark 过的了。当然也可以采用其他的方式,比如,额外的使用一张表来记录。

mark 过程结束之后,每个可达对象就已经被标记了,其他的都是垃圾。举个例子,假设我们分配了 5个 heap block,如下:

mark 完成后,只有 1 3 5 得到了标记:

sweep 的工作就是将 heap block 2 与 5 释放掉(将 allocated 标记改为 free),最后得到:

可以看出,原来完整的 heap ,多出了很多洞,这个叫做内存碎片,这就给分配器带来了很大的考验。

有一个3色抽象的概念。这个玩意在我们现在的例子中说出来觉得是废话,但是它在 gc 线程与用户线程并发执行时十分有用。所以这里不介绍。

Mark-compact garbage collection

内存碎片化是非移动式回收器无法解决的问题之一。它面临的一个较为尴尬的问题就是,尽管有可用空间,但是却无法找到一个连续的内存用来分配给一个较大的对象。

所以,我们需要对堆进行整理,我们先讨论原地整理策略。

标记-整理也是分为两步,第一步是标记,这个步骤上面我们已经讨论过了,我们讨论整理阶段。

整理有很多种,现在的 gc 都是使用的滑动顺序,也就是保持原有的对象顺序不变,以免打破其局部性。实现滑动顺序也有很多种算法,我们只介绍一个就是 Lisp 2算法。

算法很简单,就是使用2个指针,一个指针 free 指向空闲位置,一个指针 scan 指向存活的对象。有点类似 leetcode 里面的一个题目,就是在一次遍历中去除数组中的重复元素。

算法过程描述如下:

首先,scan 与 free 指针都指向 block 1,它是被 mark 了的。所以我们将 free 与 scan 指针的往后移动 sizeof(block) 的大小。

此时,scan 与 free 指针指向 block 2,它是没有被 mark,说明这块地方可以放东西,那么我们只将 scan 指针移动 sizeof(block) 的大小。

此时,scan 指向 block 3,free 指向 block 2,block 3 被 mark 了,由于 scan 与 free 不相等,那么我们就将 scan 处的 block copy 到 free 指向的位置,然后将 free 往后移动 copy 的 block 的大小。以次类推。

但是移动对象,并不仅仅是 copy bytes 这么简单,举个例子:

csharp 复制代码
void test() {
 A a = new A();

 // do some work with a
}

假设我们需要移动上面代码中分配的对象 a,移动后,a变量还是指向原来的地址,那么后面对 a 的使用就会出错,所以我们需要更新栈上的引用(实际上 JVM 有更好的处理)。第二个问题就是对象 A 里面还可能有对象 B 的引用,如果对象 B 也移动了,那么也要更新这个引用:

所以,我们在整理heap的时候,还需要更新对象里面的引用

更新对象的引用需要一个辅助结构,我们给对象增加一个 forwardingAddress 字段(下图红色块),这个对象储存了它将会被移动到的地址。

上面图的描述没有考虑到 forwardingAddress 字段的更新,要更新 forwardingAddress 字段,需要遍历 heap 堆3次。

第一次,记录所有mark对象的 forwardingAddress 字段。

第二次,更新 mark 对象的引用值。

第三次,移动 mark 对象。

写一个伪代码:

scss 复制代码
public void compact() {
    computeLocations(start, end, start);
    updateReferences(start, end);
    relocate(start, end);
}

public void computeLocations(Address start,  Address end, Address copyTo) {
    Address scan = start;
    Address free = copyTo;

    while(scan < end) {
        if(isMarked(scan)) {
       RefObj obj = convert(scan);
       obj.forwardingAddress = free;
            free += size(scan);
        }
        scan += size(scan);
    }
}

public void updateReferences(Address start, Address end) {
    while(scan < end) {
        if(isMarked(scan)){
            RefObj obj = convert(scan);
            List<RefObj> refs = obj.getRefs();
            for (int i = 0; i <refs.size() ; i++) {
                RefObj ref = refs.get(i);
                obj.setRefValue(ref, ref.forwardingAddress);
            }
        }
    }
}

public void relocate(Address start, Address end) {
    Address scan = start;
    while(scan < end) {
        if(isMarked(scan)) {
            Address dest = scan.forwardingAddress;
            move(scan, dest);
            unsetMarked(dest);
        }
        scan = scan + size(scan);
    }
}

标记-整理算法需要多次遍历堆,所以其吞吐量较差,但是可以进行算法优化。所有有一种缝合方案,就是先使用标记-清扫算法,当内存碎片化达到一定程度的时候,切换为标记-整理算法。

该算法还有一个蛋疼的问题,就是对于长寿对象的处理,这里我们后面再讨论。

Copying garbage collection

基本的复制式回收会将堆划分为两个大小相等的半区。一个叫做 fromspace,一个叫 tospace。

我们使用一个 free 指针来描述新对象应该在哪里分配空间,所以当一个对象创建时,我们只需要往后移动 free 指针即可。当 free 指针的大小大于可用空间的大小时,就需要进行 gc 了。

如下图:

当第 6 个对象要分配的时候,发现 free + size(6) > end,这个时候就要进行 gc。

gc 的过程就是先 mark,再 copy,如下图:

我们将 mark 的对象,从 fromspace 拷贝到 tospace ,再互换 fromspace 与 tospace 的角色,然后就可以再次尝试分配对象 6 了。

标记-复制其实也是一种整理。只不过有一点不一样的就是,它不需要 forwardingAddress 字段,而且只需要对heap遍历一次。

同样的,我们写一个伪代码:

scss 复制代码
public void collect() {
      
    for (GcRoot gcRoot : gcRoots) {
        process(gcRoot, gcRoot.getRef());
        workList.add(gcRoot.getRef());
    }

    while(!workList.isEmpty()) {
        RefObj obj = remove(workList);
        for (int i = 0; i < obj.getRefs().size(); i++) {
            forward(obj, obj.getRefs().get(i));
        }
    }

    flip();
}

public void forward(RefObj ref, RefObj target) {
    if(target.forwardingAddress == null) {
        copy(ref, target);
        return;
    }

    ref.setRefValue(target, target.forwardingAddress);
}

public void copy(RefObj ref, RefObj target) {
    Address toRef = free;
    free += size(target);

    move(target, toRef);

    ref.setRefValue(target, toRef);
    workList.add(toRef);
}

Reference counting

引用计数算法之所以能够成为一种有竞争力的自动内存管理策略,有以下几个原因:

  • 内存管理开销分摊到了程序运行过程中,同时对象一旦成为垃圾会被立即回收
  • 算法的实现无须系统支持,特别是无需确定 gcRoots。

我们看一个代码:

less 复制代码
class A {}
class B extends A{}

A a = new A(); //
B b = new B();

a = b;

我们看 a =b 这个简单的复制语句,由于我们采用了引用计数法,那么这个复制语句就会产生两个副作用:

  • 指向对象 A 的指针减少了 1 (如果 A 里面还有别的引用,那么这些引用也要递归的减少 1)
  • 指向对象 B 的指针增加了 1

所以,引用计数给赋值器带来了额外的开销,它不适合大容量,高性能内存管理器。

写个伪代码:

csharp 复制代码
class RefCountObj {
    public int count;

    public void increase(){
        count++;
    }

    public void decrease() {

        this.count--;
        if(this.count == 0) {
            for (RefCountObj obj : this.getRefs()) {
                obj.decrease();
            }
        }
    }

    public List<RefCountObj> getRefs() {
        return null;
    }

}

引用计数的伪代码写起来非常的简单,但是它有一个非常蛋疼的缺点,就是无法处理环形引用,当然也有算法可以搞定这个问题,非常的复杂就不说了。

Generational garbage collection

垃圾回收的主要目的是找到已经死亡的对象并回收它们占用的空间。在对象数据量较少的情况下,整理/复制式垃圾回收器能够最高效的进行回收。但长寿对象的存在会影响回收效率,因为回收器不是反复的对其进行标记,就是反复的把它们从一个半区搬到另一个半区。

分代垃圾回收是对上述算法的进一步改进。分代垃圾回收算法依照对象的寿命将其划分为不同的分代。

对象刚开始创建时被分配到年轻代,当它存活的时间够长之后,会被提升到年老代。有些回收器会使用年龄记录的方式,比如经过一次gc后,存活下来的年龄加一,当年龄达到一定程度后,提升到老年代。

这样的好处,就是我们每次 gc 的时候,只需要处理年轻代的区域以提升效率。

看下面的图:

我们在对年轻代进行 gc 的时候,只需要考虑部分指向年轻代的 gcRoots。但是有一个问题,就如上图 S 指向 P 的指针,如果只考虑部分 gcRoots,那么 P 就被当作垃圾了,这肯定不行,所以我们还要维护一个集合,集合里面放的是分代之间的指针集合。我们把这个集合也当作 gcRoots,这样就可以正确的进行年轻代的 gc 了。

但是这种做法又会导致一个问题,就是如果年老代的对象出现了垃圾,只要年老代不触发 gc,那么我们就会一直维护着一些无用的分代指针,从而导致其指向对象也不会回收。但是其实也还好,毕竟年老代也会触发gc。

还有一个问题,就是大对象,对于大对象的处理,我们在年轻代里面复制起来需要花费较大的力气,仔细想一下,我们就不应该让大对象与小对象放在一起,所以,heap 可能分为 3 个区域:

年轻代与年老代可以采用 标记-整理 或者 标记-复制算法。大对象区域就需要采用 标记-清扫 或者引用计数法。

我们最后再额外讨论一个 native object 的问题。

比如,在 java 里面,当我们使用 jni 创建了对象:

如果,这段 heap 区域采用的是 mark-compact 算法,那么由于我们上面讲过垃圾回收器无法区分一个 uint64_t 类型是一个值还是一致指针,也就没法去改引用,所以 C/C++ 的内存我们不能移动它。这就导致了 heap 上会出现很多洞。

所以在实现这种 manage 与 unmanage 交互的垃圾回收器时会相当的蛋疼。

总结

垃圾回收器是一个非常复杂的东西,会面临很多问题,但是也很有意思,就像《THE GARBAGE COLLECTION HANDBOOK:The Art of Automatic Memory Management》这本书的名字,自动管理的艺术,里面会有很多经验性,工程性的东西。

相关推荐
y先森10 分钟前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy11 分钟前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu108301891114 分钟前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿1 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz3 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇3 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒3 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员3 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐3 小时前
前端图像处理(一)
前端