Prechádzať zdrojové kódy

feat: 添加标记-清除算法

usuifohe 2 rokov pred
rodič
commit
6bf207ea8a

BIN
垃圾回收/Image/004.png


BIN
垃圾回收/Image/005.png


BIN
垃圾回收/Image/006.png


BIN
垃圾回收/Image/007.png


BIN
垃圾回收/Image/008.png


BIN
垃圾回收/Image/009.png


BIN
垃圾回收/Image/010.png


+ 283 - 1
垃圾回收/README.md

@@ -93,4 +93,286 @@ GC 是管理堆中已分配对象的机制。在开始执行 mutator 前, GC 
 
 GC 会保留活动对象,销毁非活动对象。当销毁非活动对象时,其原本占据的内存空间会得到解放,供下一个要分配的新对象使用
 
-![](Image/003.png)
+![](Image/003.png)
+
+### 分配
+
+分配( `allocation` )指的是在内存空间中分配对象, 当 mutator 需要新对象时, 就会向分配器( `allocator` ) 申请一个大小合适的空间。分配器则在堆堆可用空间中寻找满足要求的空间,返回给 mutator
+
+当堆被所有活动对象占满时,就算运行 GC 也无法分配可用空间。这个时候有两种选择
+
+1. 销毁至今为止所有的计算结果,输出错误信息
+2. 扩大堆,分配可用空间
+
+现实的执行环境中选择第二项的更实际一点。因为我们必须尽可能地避免因内存不足造成的程序停止。在内存空间大小没有特殊限制的情况下,应该扩大堆
+
+### 分块
+
+分块(`Chunk`)在 GC 的世界里指的是为利用对象而事先准备出来的空间
+
+分块分配的主要目的是减少内存碎片化和提高内存分配速度。当程序运行时,对象的创建和销毁可能会导致堆内存的频繁分配和释放。如果堆内存被细分为小块,那么在分配对象时,可以更容易找到足够大小的块来满足对象的需求,而不会产生大量的内存碎片
+
+1. 初始分块:在垃圾回收器初始化时,会为堆内存分配一些初始的块。这些块可以是固定大小的,也可以是可变大小的。
+
+2. 内存分配:当程序请求内存分配时,垃圾回收器会在已分配的块中搜索合适大小的空闲块。如果找到足够的空闲块,就将其分配给对象,并在块中记录对象的元数据和引用信息。
+
+3. 空闲块管理:分配后,垃圾回收器会维护一个空闲块列表,记录可用的空闲块以供下次内存分配使用。当一个对象被释放时,垃圾回收器将释放的块标记为空闲状态,以便将来重新分配给其他对象。
+
+4. 内存回收:定期或在需要时,垃圾回收器会执行垃圾回收操作,检查不再被程序引用的对象并回收其所占用的内存。垃圾回收器可以通过标记-清除、标记-整理等算法来回收未被引用的对象,并将回收的块标记为可用的空闲块
+5. 
+### 根
+
+根(`root`)这个词的意思是根据、根底。在 GC 的世界中,根是指向对象的指针的**起点**部分
+
+![](Image/004.png)
+
+GC 把上图中可以直接或间接从全局变量中引用的对象视为活动对象
+
+### 评价标准
+
+评价 GC 算法算法的标准有4个标准
+
+1. 吞吐量
+2. 最大暂停时间
+3. 堆使用效率
+4. 访问的局部性
+
+- 吞吐量:单位时间内的处理能力
+
+![](Image/005.png)
+
+以上图来看整个 `mutator` 的执行过程触发了三次 GC, 分别花费了 A、B、C 的时间。另一方面 GC 对大小为 `HEAP_SIZE` 的堆进行内存管理, 所以 GC 的吞吐量计算为 $\frac{HEAP_SIZE}{(A+B+C)}$ 
+
+但是吞吐量的好坏不能一概而论的。比如 **标记-清除算法** 和 **复制算法** 相比,活动对象越少**复制算法**的吞吐量越高,这是因为 **GC复制算法** 只检查活动对象,而 **GC标记-清除算法** 则会检查所有的活动和非活动对象。但是随着活动对象的增加,各个 GC算法表现出来的吞吐量也会出现变化。极端情况下,设置会出现**GC标记-清除算法**比**GC复制算法**表现的吞吐量高的情况
+
+- 最大暂停时间:因执行 GC 而暂停执行 mutato 的最长时间
+
+![](Image/005.png)
+
+还是用上图为例,最大暂停时间就是 A、B、C 中的最大值
+
+在大多数实现中,当垃圾回收(GC)机制触发时,它会导致程序的暂停运行,通常被称为"停顿"(Pause)。这是因为在垃圾回收的过程中,垃圾回收器需要检查和回收不再被程序使用的内存块,这可能涉及到遍历对象图、标记存活对象、清理未使用的对象等操作,这些操作可能涉及到整个堆内存的扫描和处理。
+
+为了确保在垃圾回收过程中对象的一致性和完整性,垃圾回收器需要停止程序的执行,这就导致了程序的暂停。这种暂停时间取决于垃圾回收算法、垃圾回收器的实现,以及堆内存的大小等因素。较长的停顿时间可能会影响程序的性能和响应性能,因此垃圾回收器的设计和实现通常会努力减少停顿时间,并在可能的情况下进行并发或增量式的垃圾回收
+
+并非所有的垃圾回收机制都会导致明显的停顿。一些现代的垃圾回收器实现采用了更加高级的技术,如并发垃圾回收、增量垃圾回收等,它们在一定程度上减少了停顿时间,以提供更好的用户体验。但无论如何,垃圾回收过程必然会对程序的运行产生某种程度的影响
+
+- 堆使用效率:影响堆使用效率的原因一般就是头的大小和堆的用法
+
+头在堆中存放堆信息越多,GC的效率越高,吞吐量也能随意改善,但是毋庸置疑的是头越小越好
+
+堆的用法不同,堆的使用效率也会出现巨大的差异。比如 GC复制算法 中,将堆一分为二,每次只使用一半,交替进行,因此总是只能利用堆的一半
+
+GC 是自动内存管理功能,所以过量占用堆就成了本末倒置
+
+- 访问的局部性
+
+![](Image/006.png)
+
+于是高速的存储容器其容量越小,当 CPU 访问数据时,仅把要使用的数据从此难过内存读取到缓存中;与此同时还将需要的数据附近的数据也读取到缓存中,从而压缩读取数据所需要的时间
+
+所以具有引用关系的对象之间很痛很可能存在连续访问的情况,一般称之为 **局部访问性**
+
+考虑到访问的局部性,把具有饮用关系的对象安排在堆中较近的位置,就能提高在缓存中读取到想利用的数据的概率,令 mutator 告诉运行
+
+## GC标记-清除算法
+
+### 基本流程
+
+GC标记-清除算法由标记阶段和清除阶段构成的
+
+- 标记阶段是把所有活动对象都做上标记
+- 清除阶段是把那些没有被标记的对象,也就是非活动对象回收的阶段
+
+- **标记阶段**
+
+```cpp
+mark_phase() {
+    for(r: $root) {
+        mark(*r)
+    }
+}
+
+mark(obj) {
+    if(obj.mark == false) {
+        obj.mark = true;
+        for(child: children(ob)) {
+            mark(*child)
+        }
+    }
+}
+```
+
+上述代码,简单明了。在标记阶段,会从 根root 开始为堆里的所有活动对象打上标记。于是我们首先要标记通过根直接引用的堆对象,然后递归的标记通过指针数组能够访问到的对象
+
+![](Image/008.png)
+
+于是最开始的内存结构变成了上图的效果,活动对象被打上标记
+
+> 遍历对象的方法有**广度优先搜索**和**深度优先搜索**,但是**深度优先搜索**比**广度优先搜索**能压低内存使用量,因此在标记阶段通常使用**深度优先搜索**
+
+- **清除阶段**
+
+```cpp
+sweep_phase() {
+    sweeping = $heap_start
+    while(sweeping < $heap_end) {
+        if(sweeping.mark = true) {
+            sweeping.mark = false;
+        } else {
+            sweeping.next = $free_list
+            $free_list = sweeping
+        }
+        sweeping += sweeping.size
+    }
+}
+```
+
+> 这里的 `sweeping.size` 中的 `size` 是对象的大小(字节数),可以事先在对象的头中定义
+
+在清除阶段,使用 `sweeping` 变量来遍历堆,具体来说就是从堆首地址 `$head_start 开始`,按顺序一个个遍历对象的标志位
+
+设置了标志位就说明是活动对象,不需要回收,就取消其标记位为下次 GC 做准备;没有设置标志位就说明需要被回收
+
+回收对象就是把对象作为分块,连接到被称为 **空闲连表** 的单向链表中。在之后进行分配时只要遍历这个空闲链表就可以找到分块了
+
+> 回收到空闲链表对象上面代码的 7、8 行,就是将 `sweeping` 对应的块插入到 空闲链表 的头上
+
+![](Image/009.png)
+
+通过上述操作,就将代码堆中的可以销毁的片段回收到 空闲链表 中
+
+在清除阶段,程序会便利所有堆,进行垃圾回收。也就是说,所花费时间与堆大小成正比。堆越大,清除阶段所花费的时间就越长
+
+### 分配与合并
+
+- **分配**
+
+分配是指将回收的垃圾进行再利用
+
+这里要讨论的问题是:怎样才能把大小合适的分块分配给 `mutator`
+
+在清除阶段已经把垃圾对象连接到空闲链表了,搜索空闲链表并寻找大小合适的分块,这项操作就叫做分配
+
+```cpp
+new_obj(size) {
+    chunk = picckup_chunk(size, $fee_list)
+    if (chunk != nullptr) {
+        return chunk;
+    } else {
+        allocation_fail()
+    }
+}
+```
+
+`picckup_chunk` 函数用于遍历 `$free_list` 寻找大于等于 `size` 的分块。它不光会返回和 size 大小相同的分块,还会返回比 size 大的分块
+
+如果找到了 size 大小相同的分块则直接返回该分块;如果找到比 size 大的分块,则会将其分割成 size 大小的分块和去掉 size 后剩余大小的分块,并将剩余的分块返回空闲链表
+
+这里关于内存块的分配策略有三种:First-Fit、Best-Fit、Worst-Fit
+
+First-Fit(首次适应)
+
+First-Fit算法是一种简单的分配策略,它从内存的起始位置开始查找第一个能满足请求大小的空闲块,并将该块分配给请求的对象。这样可以快速找到合适的内存块,但可能会导致内存碎片化问题。由于不一定选择最适合大小的块,后续较大的内存请求可能无法被完全满足,留下一些较小的零散空闲块。
+
+Best-Fit(最佳适应)
+
+Best-Fit算法会遍历所有的空闲内存块,选择大小最接近请求大小的空闲块,并将其分配给请求的对象。这样可以尽量减少内存碎片化,但需要额外的搜索时间来找到最合适的块。Best-Fit可以有效地利用内存空间,但在频繁分配和释放对象的情况下,可能会导致内存分配效率降低。
+
+Worst-Fit(最差适应)
+
+Worst-Fit算法正好与Best-Fit相反,它选择能够完全满足请求大小并且剩余空间最大的空闲块。这样可能会留下大量碎片,导致内存空间的浪费。Worst-Fit在一些特殊情况下可能会比较有效,但通常不是首选的分配策略。
+
+- **合并**
+
+根据分配策略的不同可能会产生大量的小分块,如果这些小分块是连续的,那么就可以合起来形成一个大分块,这种 **连接连续分块** 的操作就叫做 **合并**
+
+```cpp
+sweep_phase() {
+    sweeping = $heap_start
+    while(sweeping < $heap_end) {
+        if(sweeping.mark = true) {
+            sweeping.mark = false
+        } else {
+            if(sweeping == $free_list + $free_list.size) {
+                $free_list.size += sweeping.size
+            }
+            else {
+                sweeping.next = $free_list
+                $free_list = sweeping
+            }
+        }
+        sweeping += sweeping.size
+    }
+}
+```
+
+添加了一个判断:当前分块和上个分块是否连续,如果连续则合并
+
+### 优缺点
+
+- 优点
+  - 算法简单,容易实现
+  - 与保守式GC算法兼容
+
+- 缺点
+  - 碎片化:无法避免的产生内存碎片,影响 mutator 的运行。可以使用 **BBOP法** 来解决内存碎片
+  - 分配速度:最差的情况是每次都要便利整个空闲链表。可使用**多空闲连表**和**BBOP法**来解决
+  - 与写时复制技术不兼容
+
+针对写时复制技术,当 fork 进程时,并不是想原本进程的内存完全复制一份给新的进程,而是两个进程公用同一份内存空间,当新进程要修改某些数据的时候才会将那部分数据放到新建的内存空间中。通过这种方式可以减少内存空间和数据复制所用的时间,只在需要的时候才会新建所需的内存和复制所用的数据
+
+但是当使用 GC标记-清除算法时,每次标记清楚都涉及到数据的修改,导致写时复制完全失效
+
+为了处理这个问题可以使用 **位图标记法**
+
+### 多个空闲表
+
+之前的标记-清除算法只用了一个空闲链表来处理内存块。这样一来每次分配的时候都要遍历一次空闲链表来寻找合适大小的分块,这样非常浪费时间
+
+因此,拓宽思路使用分块大小不同的空闲链表,即创建只连接大分块的空闲链表和只连接小分块的空闲链表
+
+![](Image/010.png)
+
+我们使用空闲链表数组来存储多个空闲链表的头,第一个元素是由两个字节的分块连接而成的空闲链表的头节点;第二个元素是由三个字节的分块连接而成的空闲链表的头节点……
+
+但是数组总需要一个上线,不能开一个无限长的数组。所以通常会给分块设定一个分块大小的上限,分块如果大于等于这个大小就统一设定到一个空闲链表上
+
+> 一般来说 mutator 很少会申请非常大的分块,为了应对这种极少出现的情况而大量制造空闲链表,会使得空闲链表的数组过于巨大
+
+### BBop法
+
+`BBOP` 就是 `Big Bag Of Pages` 的缩写
+
+BBoP算法的核心思想是将整个堆内存划分为一个巨大的连续内存块,称为 `Big Bag`,这个大内存块被分割成固定大小的页(Page),每个页的大小通常是2^N字节(如4KB或8KB)。对象的内存分配和回收都在这些页的级别上进行,而不是像传统的分代垃圾回收算法那样在对象级别上进行
+
+BBoP算法的主要特点包括:
+
+1. 大内存块:BBoP使用一个巨大的连续内存块作为堆,而不是将堆划分为多个不同大小的块。这样做可以减少内存管理的复杂性和内存碎片化
+
+2. 固定大小的页:堆内存被分割成固定大小的页,这些页可以被高效地管理和回收。当对象不再使用时,整个页可以被一次性地回收
+
+3. 简化垃圾回收:由于采用了大内存块和固定大小的页,垃圾回收过程更加简化,避免了在对象级别上进行复杂的标记-清除或标记-整理操作
+
+BBoP算法的设计目标是为了在大规模堆内存的情况下提供高效的内存管理。它通常用于处理内存占用较大的应用程序或数据密集型任务
+
+需要指出的是,BBoP算法并不是广泛应用的垃圾回收算法,它可能在某些特定的应用场景中发挥优势,但并不适用于所有情况。在实际应用中,垃圾回收算法的选择通常需要综合考虑应用程序的特点、内存使用情况、性能需求等因素
+
+### 位图标记
+
+在之前的 标记-清除算法 中,用于标记的位势分配到各个对象的头中,也就是说算法把对象和头一并处理了,但是这样与 写时复制 不兼容
+
+于是将 标记位 从对象的头中抽出,单独用表格管理。这样在标记的时候,不用修改对象的数据了。那么管理标记位的表格 就是位图
+
+位图标记算法的基本思想是使用位图(Bitmap)来记录堆中对象的标记信息。每个对象都对应位图中的一个标记位(通常用1或0表示),用于表示该对象是否被访问过,即是否为可达对象。在垃圾回收的标记阶段,算法会遍历堆中的对象图,并对可达对象进行标记,将对应的位图标记为1。标记过程通常由垃圾回收器从根对象(如栈中的引用对象)开始进行深度优先或广度优先遍历,依次标记所有可达对象
+
+标记阶段完成后,位图中标记为0的位表示不可达的对象,即垃圾对象。在清除阶段,垃圾回收器会遍历位图,将对应标记为0的对象回收并释放其占用的内存。这样,所有不可达的对象都会被回收,而可达的对象将被保留
+
+位图标记算法相对简单且高效,它不需要为每个对象额外分配标记字段,而是通过位图来记录标记信息,节省了空间。然而,它需要在标记和清除阶段都遍历所有的对象,这可能导致垃圾回收的停顿时间较长
+
+### 延迟清除法
+
+延迟清除法采取了一种更灵活的方式来处理垃圾回收。它允许垃圾回收器在进行标阶段时,只对某些部分进行扫描和回收,而不是整个堆。未被清除的部分将被延迟至稍后的时间再进行处理
+
+## 引用计数法
+