垃圾回收

本文只是简单的介绍一下垃圾回收的一些内容,当然也会有适当的讨论,但不会太深入。如果有兴趣可以看这本书《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》这本书的名字,自动管理的艺术,里面会有很多经验性,工程性的东西。

相关推荐
吕彬-前端22 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(二)
前端·react.js·前端框架
小白小白从不日白43 分钟前
react hooks--useCallback
前端·react.js·前端框架
恩婧1 小时前
React项目中使用发布订阅模式
前端·react.js·前端框架·发布订阅模式
mez_Blog1 小时前
个人小结(2.0)
前端·javascript·vue.js·学习·typescript
珊珊而川1 小时前
【浏览器面试真题】sessionStorage和localStorage
前端·javascript·面试
森叶1 小时前
Electron 安装包 asar 解压定位问题实战
前端·javascript·electron
drebander1 小时前
ubuntu 安装 chrome 及 版本匹配的 chromedriver
前端·chrome
软件技术NINI1 小时前
html知识点框架
前端·html
深情废杨杨1 小时前
前端vue-插值表达式和v-html的区别
前端·javascript·vue.js
GHUIJS1 小时前
【vue3】vue3.3新特性真香
前端·javascript·vue.js