【BogGC 设计FL 的高效非移动垃圾回收器

2023年 9月 2日 35.5k 0

【Bog-GC 设计】FL 的高效非移动垃圾回收器

摘要

目的是为了无缝和 C 进行交互,因此设计了本内存管理系统。它具备了高效、不移动垃圾的回收算法,基于 bitmap 标记,且实现了性能评估。

heap, sub-heap

这篇论文里设计了一个内存管理策略,其中内存分配是按指数级增长的大小进行的,并且有一个专门用于非常大对象的特殊子堆。这种方法的一个优点可能是它可以高效地处理各种大小的内存请求。

  • 堆和子堆:
    当我们谈论“堆”时,通常指的是一个内存区域,用于动态分配内存块。在这里,“堆”是由许多“子堆”(sub-heaps)组成的。

  • 子堆的定义:
    子堆的集合表示为 {( H_i | c \leq i \leq B )} 。这是集合的数学表示,意思是该集合包含所有满足条件“( c \leq i \leq B )”的 ( H_i ) 。这里,cB是整数,代表了子堆集合中子堆的索引的最小值和最大值。

  • 指数级增长的分配大小:
    每个子堆 ( H_i ) 负责分配大小为 ( 2^i ) 字节的内存块。这意味着每一个子堆的分配大小都是前一个子堆分配大小的两倍。例如,如果 ( H_3 ) 代表分配大小为 ( 2^3 ) 或 8 字节的子堆,那么 ( H_4 ) 就代表分配大小为 ( 2^4 ) 或 16 字节的子堆。

  • 特殊子堆:
    还有一个特殊的子堆,用于存储非常大的对象。这意味着,当需要分配一个大于 ( H_B ) (即大小为 ( 2^B ) 字节)的内存块时,它会被放在这个特殊的子堆中。

  • Allocation for sub-heap

    子堆如何从一个由固定大小的段组成的资源池中动态分配和回收空间。这种策略可以提供更高的灵活性,并可能提高内存使用的效率。

  • Actual space for each sub-heap:
    这指的是每个子堆真正使用的内存空间。尽管子堆可能负责处理特定大小的内存块(如前面提到的指数增长的分配大小),但每个子堆实际使用的空间可能会随时间变化,因为它会根据需求动态地分配和释放内存。

  • dynamically allocated and reclaimed:
    这说明子堆的空间不是静态分配的。相反,当需要更多空间时,子堆可以从某个资源池中获取更多的空间;同样地,当子堆中的空间不再需要时,这些空间可以被归还。

  • pool of fixed size allocation segments:
    这是子堆从中请求和返回空间的资源池。这个池由许多固定大小的分配段组成。这意味着,无论子堆请求多少空间,它都会以这些固定大小的“段”为单位来请求。例如,如果池中的分配段大小为 1MB,那么一个子堆可能会请求 1MB、2MB、3MB 等大小的空间,而不是请求 1.5MB 或 2.5MB 这样的非整数倍的大小。

  • Allocation segments

    在每个分配段中,算法使用一个位图来跟踪哪些对象是“活的”。如果一个对象是活的,其对应的位会被设置为 1(或者相反,具体取决于实现),否则,它会被设置为 0。通过这种方式,当进行垃圾收集时,算法可以快速地查找哪些对象是死的,从而回收它们的内存。这种位图技术提供了一种高效的方式来跟踪大量对象的生存状态。

  • allocation segment:
    分配段通常是一块连续的内存区域,用于存储分配的对象。它的大小是固定的,以便于管理。

  • bitmap:
    位图是一个数据结构,通常由一系列的位(0 或 1)组成。在此上下文中,每一个位通常代表分配段中的一个对象或一块特定大小的内存。

  • representing the set of live objects:
    在垃圾收集的上下文中,“live objects”是指那些仍然被程序使用的对象,它们不应该被回收。相对的,那些不再被任何部分程序引用的对象被视为“死”对象,可以被回收并重新利用其内存。

  • Free bit in bitmap

    当系统需要为新对象分配内存时,它会查找位图中的下一个空闲位。在这里,一个"free bit"或"空闲位"通常指的是一个值为 0 的位(或有时可能是 1,取决于如何定义位图中的"空闲"和"占用")。这个位表示内存分配段中对应的内存块是未被使用的。

    举一个简化的例子:

    假设位图为:11001001。

    其中,1 代表占用的内存块,而 0 代表空闲的内存块。当需要为一个新对象分配内存时,算法会从位图的左边开始,搜索第一个 0。在这个例子中,第三个位是 0,所以第三块内存会被分配出去。分配之后,位图会被更新为 11101001。

    这种方法的好处是可以快速地找到可用的内存块,特别是当内存碎片化时。而不需要遍历整个内存区域或维护复杂的数据结构。

    Meta-level bitmap and current bit position

    “元级位图”或“meta-level bitmaps”。这是一个更高级别的位图,用于汇总原始位图的内容。这种层次化的结构类似于文件系统中的 inode 映射或多级页表在计算机内存管理中的使用。

    例如,考虑一个简单的位图:1100 1100。一个元级位图可能表示每 4 位中有多少个空闲块。在这种情况下,元级位图可能是 1021(表示第一个 4 位中有 1 个空闲块,第二个 4 位中有 2 个空闲块,以此类推)。

    系统不仅仅是盲目地从位图的开始处查找空闲位,而是记住上一次查找到的位置。这样,下次查找可以从这个位置开始,进一步加速查找过程。

    粗浅来看,由于这种层次化的结构和记住的位置,大多数情况下查找下一个空闲位的操作可以在小的常数时间内完成。这意味着无论内存大小如何,找到下一个空闲位所需的时间都大致相同,这是一个非常高效的性能特性。

    如果是最坏的情况呢?

    文章中以 32 位架构来设计。32 位架构意味着计算机的指令集和数据路径设计为处理 32 位宽的数据单元。因此,当操作 32 位数据单元(例如一个整数或位图的一部分)时,这样的架构通常可以一次性处理所有 32 位。这导致以 32 为底的对数操作,因为对于较大的数据段(如一个位图),操作可能需要按 32 位的块/段进行。查找时间与 segmentSize 的大小成对数关系。

    例如,如果一个位图有 320 位,那么在 32 位架构上,最坏的情况可能需要检查 10 个 32 位块才能找到一个空闲位。这可以通过 log32(320)来表示,结果是 10。

    还不理解?用数学来描述一下,

    假设这个 segmentSize 的大小是 N, 每次读取的大小是 M, 需要 t 次能够读取完整个 segmentSize 的大小,那么有:( N = M^t )。

    我们想要获取的是 t 的值,所以对上式取对数,得到 ( t = \log_M N )。代入 32 的具体数值,则有 ( t = \log_{32} (SegmentSize) )。

    更准确来形容一下——

  • 元级位图:这些位图在更高的级别概括了内容,形成了一个层次结构。具体来说,如果你可以快速查看一个元级位图并确定一大块连续的位都被设置或都被清除,那么你就不需要查看这些具体的位了。

  • 常规位图:这是一个详细的、表示所有对象是否被分配的位图。

  • 元级位图是对主位图的概括或摘要。它可能将主位图的一大块连续位简化为更高级别的一位,表示这一块是否完全占用或完全空闲。如果元级位图的某个位表示它所代表的主位图区域是完全被占用的,那么在这个区域中是找不到空闲位的。同样,如果元级位图的某个位表示它所代表的主位图区域是完全空闲的,那么这个区域中的任何位都可以是空闲位。

    • 在大多数情况下,元级位图的优化允许我们非常快速地确定下一个空闲位的位置,这是一个常数时间的操作。

    在大多数情况下,元级位图可能会很快地指向一个含有空闲位的区域,这样我们可以在主位图中直接找到一个空闲位,而不需要进一步搜索。

    然而,在最坏的情况下:

    元级位图可能指示许多区域都是部分占用的,这意味着我们需要进入这些区域在主位图中进一步搜索,这会消耗更多的时间。

    另外,可能存在某些情况,元级位图的结构需要多次查询才能找到一个空闲位,例如,当大部分的内存都被占用,每个区域都只有少数的空闲位。

    由于元级位图是分层的,所以我们可能需要从最顶层开始,逐层深入,直到找到一个空闲位。每一层的深入可能需要一次 ( \log_{32} segmentSize ) 的操作。这就是为什么在最坏的情况下,即使有了元级位图的优化,查找时间仍然是 ( \log_{32} segmentSize ) 。

    Generational GC

    "generational GC"(代际垃圾收集),这是一种常见的垃圾收集技术,它将对象基于其生命周期的长度分为几个"代"或"世代"。新创建的对象位于一代,而长时间存在的对象移动到另一代。不同代之间的垃圾收集频率可能会有所不同。通过为同一个堆空间维护多个位图,该算法可以扩展到代际垃圾收集。这意味着,每个"代"或"世代"可以有自己的位图,该位图记录那个代内的对象是否仍然活动。这样的设计可以提高垃圾收集的效率,因为它允许算法更有针对性地清理那些更可能是"垃圾"的新对象,同时不频繁地打扰那些已经存在了很长时间并且很可能仍然是活动的老对象。

    Generation

    在代际垃圾收集(Generational Garbage Collection)中,"代"或"世代"并不是指位图。它们实际上是指内存中的一部分,用于存储对象。基于对象的生存周期,GC 把它们分为不同的代。这种策略背后的基本思想是,新创建的对象很快就会变为垃圾,而旧对象则可能存活得更久。

    一般来说,在代际GC中,有两个主要的代:

  • 新生代(Young Generation):新创建的对象首先被放置在这里。新生代空间通常较小,并且经常进行垃圾收集。

  • 老年代(Old or Tenured Generation):当对象在新生代中存活了足够长的时间并经历了多次垃圾收集后,它们会被移动到老年代。老年代空间通常比新生代大,并且垃圾收集的频率较低,因为预期老年代中的对象会有更长的生命周期。

  • 位图在这里是一个工具,用于跟踪和管理每一代中哪些对象是活跃的(即仍在使用中)和哪些是垃圾。当算法扩展为代际GC时,可以为同一个堆空间的不同代维护多个位图。这样,每个代的活动和非活动对象都可以被单独地跟踪。

    “为新生代维护一个位图,为老年代维护另一个位图”。这使得在进行垃圾收集时,我们可以单独地考虑每一个代,从而优化垃圾收集的效率和性能。

    Compaction and move objects

    论文里设计的算法不需要进行压缩操作,对象在内存中的位置也不会移动。

    • 在传统的垃圾收集策略中,压缩是一种常用的技术,它会将活跃的对象移到内存的一个连续区域中,从而释放出未使用的内存。换言之,就是整理内存碎片。

    对于一个函数式语言来说,不需要进行压缩和对象移动这一特性是非常重要,尤其是当这个语言需要与C语言进行互操作时。这是因为在C语言中,指针的值(即对象的内存地址)是固定的,如果这些对象被移动,那么C语言中的指针可能会失效或出错。

    不需要移动对象的这一特性对于支持多原生线程也是有益的。在多线程环境中,如果对象在内存中的位置不断移动,那么线程之间的协调和同步将变得更加复杂。因此,避免对象移动可以简化多线程编程。

    为什么不移动,对多原生线程是有益的?

    对于多原生线程的支持这一特性有益的原因如下:

  • 锁的简化: 在一个多线程环境中,如果对象需要移动(例如,在垃圾收集的压缩阶段),那么我们需要确保其他线程在对象移动时不能访问这个对象。这可能需要使用复杂的锁策略和同步机制。但是,如果对象永远不移动,我们就可以减少这种同步的需求,使得锁策略更简单。

  • 指针的稳定性: 在多线程程序中,线程之间可能会共享指向对象的指针或引用。如果对象在内存中移动了,那么所有共享该对象的线程都需要更新其指针或引用。这不仅会增加同步的复杂性,而且可能会引入错误,如野指针。如果对象不移动,这些指针就会始终有效。

  • 预测性和性能: 不需要移动对象意味着内存访问模式更加稳定和可预测。在多线程程序中,预测性是一个宝贵的特性,因为它可以减少线程之间的争用,从而提高程序的整体性能。

  • 减少暂停时间: 垃圾收集中的对象移动可能导致应用程序的明显暂停,因为必须暂停所有线程来安全地进行移动。在多线程环境中,这种暂停可能更加明显,因为有更多的线程可能正在活跃地使用对象。不移动对象可以减少这种暂停。

  • 与其他语言或系统的互操作: 如果您的多线程应用程序与其他语言(如C或C++)或系统进行互操作,那么对象的稳定位置将更加重要,因为外部代码可能依赖于对象不移动的事实。

  • Generational Copying Collector

    Generational Copying Collector(分代复制收集器): 是垃圾收集的一种常见方法,特别是在函数式编程语言中。它假设新创建的对象很快就会变得不可达(即“死亡”),而老的对象则更可能持续存在。因此,内存被分成两个或更多的“代”,新对象在“新生代”中创建,当它们存活足够长的时间时,它们会被移到“老生代”。

    深入探讨一下"generational copying collector designed for functional languages"这个概念。

    分代复制收集器为函数式编程语言提供了一个有效的方式来管理内存,特别是考虑到这些语言通常创建大量的短生命周期的对象。

    • Generational: 这意味着内存管理策略基于对象的“年龄”。内存被分为两部分(或更多),通常被称为新生代和老生代。新生代存储新创建的对象,而当这些对象生存了一段时间并经过了几次垃圾收集循环后,它们会被移到老生代。这种分代策略基于一个观察:新创建的对象往往很快就会死亡,而存活下来的对象可能会长时间存活。

    • Copying Collector: 这指的是当垃圾收集发生时,活动对象(即仍然被引用的对象)会被复制到一个新的位置,而非活动对象(即无法访问的对象)则被丢弃。这种方法的优势在于它可以有效地处理内存碎片,因为通过复制活动对象到新位置,内存会被连续地占用。

    • Designed for Functional Languages: 函数式语言通常创建大量短生命周期的对象,特别是因为它们倾向于不可变性(即一旦一个对象被创建,它就不能被修改)。这意味着对于函数式编程语言来说,一个针对其特性设计的收集器需要特别高效地处理新创建的对象。

    举例:

  • Haskell:Haskell是一个纯函数式编程语言,其中的对象是不可变的。这意味着在Haskell中,当你“修改”一个数据结构时,你实际上是在创建一个新的版本。这种行为导致大量短生命周期的对象被创建。因此,一个为Haskell设计的垃圾收集器可能会特别重视新生代的垃圾收集。

  • Erlang:Erlang是另一种函数式编程语言,它特别用于并发和系统编程。和Haskell一样,Erlang也创建了大量的短生命周期的对象。为了高效地处理这些对象,Erlang使用了分代的垃圾收集策略。

  • 在其他的场景中,比如Java的JVM,虽然Java不是一个纯函数式编程语言,但其垃圾收集器也使用了分代策略,因为在许多应用程序中,新创建的对象往往很快就会死亡。

  • Generational Copying Collector是会移动对象的吗?

    Generational Copying Collector会移动对象。

    “Copying Collector”中的“Copying”指的是在垃圾收集过程中,它会将活跃的(仍然被引用的)对象复制到一个新的内存区域,同时丢弃那些不再被引用的对象。在这个复制过程中,活跃的对象的内存地址确实发生了改变,也就是说,它们被“移动”到了新的位置。

    此外,这种方法还有助于解决内存碎片化问题。因为通过将活跃的对象复制到新的、连续的内存区域,可以确保这些对象在内存中紧密排列,避免了由于长期运行应用程序而产生的内存碎片。

    会压缩对象吗?

    Copying Collector 的工作原理实际上为内存提供了一种“压缩”效果,但这并不是通过减少对象的大小来实现的。它的“压缩”是指将活跃的对象连续地放置在内存中,减少了内存的碎片化。

    具体来说,当Copying Collector运行时:

  • 它首先分配两块大致相等的内存区域:一块用于当前的对象分配,另一块用于垃圾收集时的复制。
  • 当进行垃圾收集时,收集器会遍历所有活跃的(即仍然被引用的)对象,并将它们复制到另一块内存区域。在此过程中,因为只复制活跃对象,所以不活跃的对象(垃圾)会被自动“丢弃”。
  • 由于所有的活跃对象都被连续地复制到新的内存区域,所以这种方法有效地“压缩”了内存,减少了碎片化。
  • 一旦复制完成,原来的内存区域就可以被认为是空的,然后在接下来的对象分配中会使用这块内存。
  • 需要注意的是,无论是移动还是压缩,他们的副作用是对象的内存地址会在复制过程中发生改变。这也是为什么有些垃圾收集策略(如标记-清除)选择不移动对象,尤其是当与不能容忍对象地址变化的系统或语言(如 C/C++)交互时。

    Reference

  • An Efficient Non-Moving Garbage Collector for Functional Languages. Author: {Katsuhiro Ueno, Atsushi Ohori}[Research Institute of Electrical Communication]
  • 相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论