原创

详解gc(垃圾回收)机制(一)

温馨提示:
本文最后更新于 2022年08月24日,已超过 582 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

虚拟内存

进程在运行时,所操作的内存就是虚拟内存,每个进程之间的虚拟内存互相独立,通过 MMU 内存管理技术再映射到物理内存中,同时,虚拟内存空间块分为:

仙士可博客

栈内存

栈内存在函数中定义的一些基本类型的变量和对象的引用变量都在函数的栈内存中分配。

在调用栈结束后将会自动回收

#include<stdio.h>
#include<stdlib.h>

void test();

int main() {
    test();
    test();
    return 0;
}

void test(){
    int a=0; //栈内存分配,test函数结束自动回收
    printf("a的指针地址是%p\n",&a);//a的指针地址是0x7ffee35ee90c
    int b=0;
    printf("b的指针地址是%p\n",&b);//b的指针地址是0x7ffee35ee908
}

输出:

仙士可博客

堆内存

在编译之后,程序运行时可能需要申请一些额外的变量,这些不确定的变量将分配到堆内存中,

由于是动态申请分配的,程序并不知道什么时候才能回收,所以需要手动回收,如果分配的变量没有回收,将会造成垃圾内存越来越多,造成内存泄漏 导致 内存溢出

垃圾回收

在c语言中,通过 malloc 等内存分配函数进行分配新的内存空间,定义新的变量

通过 free 等回收函数进行回收不需要的变量

代码示例:

#include<stdio.h>
#include<stdlib.h>

void test();

int main() {

    test();
    return 0;
}

void test(){
    int a=0; //栈内存分配,test函数结束自动回收
    printf("a的指针地址是%p\n",&a);//a的指针地址是0x7ffee8e0090c
    int b=0;
    printf("b的指针地址是%p\n",&b);//b的指针地址是0x7ffee8e00908
    char \*c = (char \*)malloc(100 * sizeof(char));
    printf("c的指针地址是%p\n",c);//c的指针地址是0x7f9726c059c0
    char \*d = (char \*)malloc(100 * sizeof(char));
    printf("d的指针地址是%p\n",d);//d的指针地址是0x7fec61405a30

}

free内存回收后:

#include<stdio.h>
#include<stdlib.h>

void test();

int main() {

    test();
    return 0;
}

void test(){
    int a=0; //栈内存分配,test函数结束自动回收
    printf("a的指针地址是%p\n",&a);//a的指针地址是0x7ffee381990c
    int b=0;
    printf("b的指针地址是%p\n",&b);//b的指针地址是0x7ffee3819908
    char \*c = (char \*)malloc(100 * sizeof(char));
    printf("c的指针地址是%p\n",c);//c的指针地址是0x7fa22ec059c0
    free(c);
    char \*d = (char \*)malloc(100 * sizeof(char));
    printf("d的指针地址是%p\n",d);//d的指针地址是0x7fa22ec059c0

}

可看到,c的地址释放后,d重新分配得到了原来c的地址

垃圾回收原理

所谓垃圾回收,就是找到 程序 运行之后,不需要的变量,将其回收掉,例如在上面的代码中,test函数执行完毕后,
所分配的栈内存(自动回收),手动分配的堆内存(手动回收) 变量已经不需要使用了,没有存储的意义,所以称之为 "垃圾变量"  
这些变量都可以进行回收,避免程序的内存越占越大,导致内存溢出

自动垃圾回收

可以看到,在c语言中,可以通过malloc进行分配内存,使用free回收,这样手动回收对开发者负荷过大,所以产生了其他的高级语言,使用了自身的一套内存管理机制进行自动回收

例如  php,java,golang等语言,都有着独特的垃圾回收机制,常见的自动回收机制有以下几种:

引用计数

四色标记

标记-清除

三色标记

分代收集

引用计数

引用计数: 将对每一个对象维护一个引用数,在对象被引用后,引用数+1,引用的对象删除后,引用数-1,如果引用数为0则回收该变量.

<?php
$a = new stdClass();
$b  = new stdClass();
$b->a = $a;

xdebug_debug_zval('a');
xdebug_debug_zval('b');
echo "脚本结束\n";

输出:

a: (refcount=2, is_ref=0)=class stdClass {  }
b: (refcount=1, is_ref=0)=class stdClass { public $a = (refcount=2, is_ref=0)=class stdClass {  } }

可以看到,在php中,变量a次数为2,原因是b变量引用过一次,同时由于class类型会一定存在1次,所以为2

引用计数特殊情况

<?php
$a = new stdClass();
$b  = new stdClass();
$b->a = $a;
$a->b = $b;
xdebug_debug_zval('a');
xdebug_debug_zval('b');
echo "脚本结束\n";

当a和b同时引用了对方,就会造成: a变量删除后,由于存在b引用,引用计数>0,导致a变量无法回收,b变量同理,此时a,b变量都无法正常回收

出现 引用计数 循环引用 问题

引用计数可以很快的将变量进行回收,无需等待程序内存到达一个阈值再进行回收,

但是,因为引用计数方案 需要维护每一个对象的的引用计数,导致引用计数 代价过大,性能较低

四色标记法

该小节参考:回收周期(Collecting Cycles) ¶

我们先定义4个颜色

黑色:绝对不是垃圾的对象

紫色:可能是垃圾 (需要模拟确认)

蓝色:一定是垃圾

灰色:用于记录已经模拟

该方法 为了解决引用计数 循环引用  的问题而出现

步骤为:

1:当一个变量引用技术减少到0之后,将会直接回收,否则将标记为紫色,放入紫色区,代表该变量可能是垃圾

2:当紫色区容量满了之后,开始进行垃圾回收操作

3:模拟删除一个紫色变量,将会使得和该变量关联的变量都-1,如果变量引用计数成了0,则继续做一次模拟删除,此时将变量置为灰色,表示已经模拟删除过一次.   每个普通变量只能模拟删除一次

4:模拟恢复每一个灰色变量,当变量引用计数大于0时才进行恢复,标记为黑色,表示该紫色变量不能删除

5:当引用计数小于0时,将变量置为蓝色,可以直接删除

标记-清除法

gc步骤:

1:暂停程序业务逻辑,对所有对象进行标记分类

2:找出程序可达对象和不可达对象

3:删除不可达对象

标记清除算法就是如此简单明了,不会出现循环引用的问题,

但是标记清除算法需要暂停程序,会造成程序卡顿

同时每次标记都需要扫描整个堆内存空间

在go的1.3版本就是使用了标记-清除算法,每次都会暂停程序,执行标记-清除,最后恢复程序运行

三色标记法

该节参考:https://learnku.com/articles/68141

由于 标记-清除 法会暂停整个程序执行,所以go 在1.5版本使用了新的gc方案,  也就是 三色并发标记法

白色:对象创建时的默认颜色 (可能存在垃圾的对象)

灰色:当前需要遍历的对象  (一定不是垃圾,属于正在遍历的对象)

黑色:已经遍历过的对象 (一定不是垃圾)

步骤简单说明:

1:将每次创建的对象都标记为白色

2:扫描根节点(Root Set) 的对象,标记为灰色

3:遍历灰色节点的,将灰色节点改为黑色,将灰色节点引用的节点改为灰色

4:重复步骤3,直到没有灰色节点

5:此时只剩黑色和白色节点,白色表示不可达对象,可以直接回收

三色标记法问题

以上gc步骤,都是建立在程序暂停时候执行的,如果在程序运行的时候,就可能出现:

1:灰色节点一开始引用了一个对象A

2:黑色节点开始引用对象A

3:灰色节点删除了对象A的引用

在此时,由于灰色节点丢失了对象A的引用,导致无法扫描到对象A,对象A永远是白色

同时黑色节点已经扫描完毕,不会重新扫描,导致引用的对象A永远是白色

当gc结束后,白色的对象A被删除,就会导致黑色节点应用的数据异常

为了解决这个问题,我们需要额外增加 "屏障机制"

三色回收屏障机制

为了解决上面的问题,我们引入2种规则即可保证对象A不会被删除,这个规则就是: “强三色不变式” 和 “弱三色不变式”。

强三色不变式

不允许黑色对象引用白色对象

弱三色不变式

所有被黑色对象引用的白色对象都处于灰色保护状态。

为了遵循这2种规则,继而产生了2种 "屏障机制",也就是 "插入屏障"和"删除屏障"

插入屏障

在 A 对象引用 B 对象的时候,B 对象被标记为灰色。(将 B 挂在 A 下游,B 必须被标记为灰色)

由于栈空间容量小,响应速度快,函数调用弹出频繁,所以插入屏障在栈对象操作中不使用,仅在堆对象中使用

所以在回收完堆对象时,栈空间对象需要进行一次 停止程序运行,重新标记 黑白,再进行回收栈对象

删除屏障

在GC开始后,所有需要删除的 白色/灰色 对象都标记为灰色

通过插入屏障和删除屏障,解决了上面的引用删除问题

但是,删除屏障的回收精度低,只要是GC开始后,被删除的对象也依旧可以活过这一轮的GC,只能在下一轮GC的时候真正清理

混合写屏障

为了解决插入屏障时候需要暂停程序,和删除屏障的精度低问题,go在1.8版本引入了 混合写屏障 (hybrid write barrier)机制

混合写屏障规则:

1、GC 开始将栈上的可达对象全部扫描并标记为黑色 (之后不再进行第二次重复扫描,无需 STW)

2、GC 期间,任何在栈上创建的新对象,均为黑色。

3、被删除的对象标记为灰色。

4、被添加的对象标记为灰色。

分代收集法

下篇文章讲

正文到此结束
本文目录