为什么不对主内存使用日志结构的分配器

问题描述

我刚刚学习了日志结构文件系统。我很困惑,为什么不使用日志结构作为主内存分配器。可显着减少碎片。

解决方法

日志结构的文件系统使用对循环日志的顺序写入来持久化文件系统数据,并且通常以相同的方式处理更新。在某些时候,日志结构的文件系统必须回收未使用的空间(日志中的过时条目)以使其可用于将来的写入。在一个简单的实现中,只要文件系统磁盘空间不足,就可以通过重写日志并跳过进程中未使用的条目来回收未使用的条目。

本机代码的内存分配器可以做类似的事情。一个简单的实现将需要一大块内存和一个需要为分配过程递增的下一个指针。释放需要将条目标记为已释放(可以是条目中的标志或专用空闲列表)或需要以其他方式限制释放(按先进先出顺序释放,仅整个分配空间可以解除分配)。 事实上,这种类型的分配器被称为“线性分配器”,并且今天仍在使用。一个优点是分配性能,另一个是如果它以 FIFO 顺序发生或影响整个分配空间,则重新分配的简单性和效率。堆栈分配是一个突出的例子。 JVM 通常使用线性分配器进行对象分配。 Apache 网络服务器使用它的一个变体来处理每个请求的内存分配。

使用线性分配器作为通用分配器问题更多,因为空间回收困难。为了回收空间,将条目标记为空闲是不够的,因为这可能导致高度碎片化并破坏线性分配的优势(只需为实际分配任务增加一个指针)。因此,与文件系统类似,必须压缩分配空间,使其仅包含已分配的条目,而空闲空间可用于线性分配。压缩需要移动分配的条目——这个过程会改变它们之前已知的地址并使它们无效。在本机代码中,分配器不知道对已分配条目(存储为指针的地址)的引用位置。必须修补现有的引用以使分配器操作对调用者透明,这对于像 malloc 这样的通用分配器是不可行的。

为什么不可行?更新现有引用需要以下步骤:

  • 暂停所有线程以停止所有分配入口修改器(除非采用所谓的“写入屏障”)
  • 扫描寄存器、堆栈和堆以获取指向移动对象的指针
    • 指针的确切位置是未知的,匹配引用的数据可能会被误认为是引用(例如,在像 Boehm 这样的保守 GC 的情况下这不是问题,它不执行复制。误报只会延迟收集) ,因此内存可能会被破坏
    • 指针可能未对齐,因此扫描必须在每个字节的基础上推进一个指针大小的窗口
    • 由于指针标记等策略和异或链表等数据结构,指针可能会被混淆
    • 代码可能依赖于前一个指针值(与托管代码相比,可以读取引用的值)
  • 恢复之前暂停的所有线程

使用 RTTI,可以提供所需的元数据,但是将指针传递到您无法控制的库(例如 glibc)仍然是一个问题。因此,在有限的范围内,这可以实现。通用分配器必须可用于所有本机代码场景 - 对于需要移动分配条目的线性分配器,有太多限制使其不可行。最重要的是,用于停止线程和建立写屏障的低级机制可能会干扰分配器用户(例如 JVM)使用的类似机制。

然而,在托管代码的情况下,这是常见的做法。例如,复制垃圾收集器维护两个分配空间——起始空间和目标空间——来处理压缩。在垃圾回收期间,只有引用的分配条目被复制到其他分配空间。完成后,可以再次以线性方式处理分配。

在特定场景中,已经使用了使用日志结构文件系统策略的分配器。对于与本机代码相关的通用内存分配,移动分配条目不可行的事实意味着线性分配器无法替代更传统的内存分配策略。

不是采用线性分配路线来减少碎片,而是使用池分配器,它为固定大小的分配条目提供分配空间。通过以这种方式限制分配空间,可以减少碎片。许多通用分配器使用池分配器进行小规模分配。在某些情况下,这些应用程序空间以每个线程为基础存在,以消除锁定需求并提高 CPU 缓存利用率。