Writing a Memory Allocator
接下来介绍了我在实现内存分配管理工具的具体细节,其中包括内存分配、内存池、垃圾回收的实现。
其他文章请详见:
内存分配:添加链接描述
内存池:添加链接描述
垃圾回收:添加链接描述
项目代码:添加链接描述
Mutator、Allocator和Collector
垃圾收集程序由三个主要模块组成,即Mutator、Allocator和Collector。
Mutator是我们的用户程序,我们在其中为自己的目的创建对象。所有其他模块都应该尊重Mutator 在对象图上的视图。例如,在任何情况下,收集器都不能回收活动对象。
然而,Mutator 不会自己分配对象。相反,它将这个通用任务委托给Allocator模块——这正是我们今天主要讨论的主题。
让我们深入了解实现Memory Allocator的细节。
Memory block
通常我们在高级编程语言中处理类对象时会包括:structure, fields, methods, etc:
const rect = new Rectangle({width: 10, height: 20});
但是,从内存分配器的角度来看,它在较低级别工作,对象仅表示为内存块。众所周知,这个块有一定的大小,它的内容是不透明的,被视为原始字节序列。在运行时该内存块可以被浇铸到一个所需的类型,它的逻辑布局可能会因该铸造。
内存分配总是伴随着内存对齐和对象头。标头存储与每个对象相关的元信息,以及分配器和收集器用途的服务器。
我们的内存块将结合object header和payload pointer,后者指向用户数据的第一个字。这个指针在分配请求时返回给用户:
/**
* Machine word size. Depending on the architecture,
* can be 4 or 8 bytes.
*/
using word_t = intptr_t;
/**
* Allocated block of memory. Contains the object header structure,
* and the actual payload pointer.
*/
struct Block {
// -------------------------------------
// 1. Object header
/**
* Block size.
*/
size_t size;
/**
* Whether this block is currently used.
*/
bool used;
/**
* Next block in the list.
*/
Block *next;
// -------------------------------------
// 2. User data
/**
* Payload pointer.
*/
word_t data[1];
};
如您所见,标头跟踪size对象的 ,以及当前是否已分配此块–used标志。在分配时它被设置为true,并且在free操作时它被重置回false,因此可以在未来的请求中重用。此外,该next字段指向所有可用块的链表中的下一个块。
该data字段指向返回用户值的第一个单词。
这是块在内存中的外观图片:
对象A、 和C正在使用中,块B当前未使用。
由于这是一个链表,我们将跟踪堆的开始和结束:
/**
* Heap start. Initialized on first allocation.
*/
static Block *heapStart = nullptr;
/**
* Current top. Updated on each allocation.
*/
static auto top = heapStart;
Allocator interface
在分配内存时,我们不对对象的逻辑布局做出任何修改,而是直接使用块的大小。
模仿这个malloc函数,我们有以下接口(除了我们使用 typedword_t而不是void返回类型):
/**
* Allocates a block of memory of (at least) `size` bytes.
*/
word_t *alloc(size_t size) {
...
}
Memory alignment
为了更快地访问,内存块应该对齐,通常是机器字的大小。
这是带有对象标题的对齐块的图片:
让我们定义对齐函数:
/**
* Aligns the size by the machine word.
*/
inline size_t align(size_t n) {
return (n + sizeof(word_t) - 1) & ~(sizeof(word_t) - 1);
}
这意味着,如果用户请求分配,比如说6 bytes,我们实际上分配了8 bytes。分配 4 个字节可能导致 4 个字节(在 32 位架构上)或 8 个字节(在 x64 机器上)。
让我们做一些测试:
// Assuming x64 architecture:
align(3); // 8
align(8); // 8
align(12); // 16
align(16); // 16
...
// Assuming 32-bit architecture:
align(3); // 4
align(8); // 8
align(12); // 12
align(16); // 16
...
所以这是我们要对分配请求做的第一步:
word_t *alloc(size_t size) {
size = align(size);
...
}
Memory map
内存布局:
正如我们所看到的,堆向上增长,朝向更高的地址。而在区域之间的栈和堆是未映射区域。映射由控制位置的的程序中断(BRK)指针。
内存映射有几个系统调用:brk、sbrk和mmap。生产分配器通常使用它们的组合,但是为了简单起见,我们将仅使用sbrk调用。
具有当前堆的顶部,该sbrk函数增加程序中断在传递的字节数上的值。
以下是从操作系统请求内存的过程:
#include <unistd.h> // for sbrk
...
/**
* Returns total allocation size, reserving in addition the space for
* the Block structure (object header + first data word).
*
* Since the `word_t data[1]` already allocates one word inside the Block
* structure, we decrease it from the size request: if a user allocates
* only one word, it's fully in the Block struct.
*/
inline size_t allocSize(size_t size) {
return size + sizeof(Block) - sizeof(std::declval<Block>().data);
}
/**
* Requests (maps) memory from OS.
*/
Block *requestFromOS(size_t size) {
// Current heap break.
auto block = (Block *)sbrk(0); // (1)
// OOM.
if (sbrk(allocSize(size)) == (void *)-1) {
// (2)
return nullptr;
}
return block;
}
通过调用sbrk(0)-- (1),我们获得了指向当前堆中断的指针——这是新分配块的开始位置。
接下来在 (2) 中我们sbrk再次调用,但这次已经传递了我们应该增加中断位置的字节数。如果此调用结果为(void *)-1,此时说明内存已经被分配完了,则我们发出OOM(内存不足)信号,返回nullptr. 否则我们返回在(1)中获得的分配块的地址。
重申对allocSize函数的评论:除了实际请求的大小之外,我们还应该添加Block存储对象头的结构的大小。但是,由于用户数据的第一个字已经在字段中自动保留,我们减少它。
requestFromOS 只有在我们的块链表中没有可用块时,我们才会调用。否则,我们将重用一个空闲块。
在 Mac OSsbrk上已弃用,目前通过使用预先分配的内存区域进行模拟mmap。
要使用sbrk与clangMac OS上没有警告,添加:
好的,现在我们可以从操作系统请求内存:
/**
* Allocates a block of memory of (at least) `size` bytes.
*/
word_t *alloc(size_t size) {
size = align(size);
auto block = requestFromOS(size);
block->size = size;
block->used = true;
// Init heap.
if (heapStart == nullptr) {
heapStart = block;
}
// Chain the blocks.