从零开始的 STL 实现记录:Allocator-3
前言
以STL的运用角度而言,空间配置器是最不需要介绍的东西,它总是隐藏在一切组件(更具体地说是指容器,container)的背后,默默工作,默默付出。但若以 STL 的实现角度而言,第一个需要介绍的就是空间配置器,因为整个STL 的操作对象(所有的数值)都存放在容器之内,而容器一定需要配置空间以置放资料。不先掌握空间配置器的原理,难免在阅读其它STL组件的实现时处处遇到挡路石。
———— 《STL源码剖析》侯捷
考虑到小型区块所可能造成的内存破碎问题,SGI 设计了双层级配置器,第一级配置器直接使用 malloc()
和 free()
,第二级配置器则视情况采用不同的策略:当配置区块超过 128 bytes 时,视之为“足够大”,便调用第一级配置器:当配置区块小于 128 bytes 时,视之为“过小”,为了降低额外负担 overhead,便采用复杂的 memory pool 整理方式,而不再求助于第一级配置器。
SGI 第二级配置器的做法是,如果区块够大,超过 128 bytes 时,就移交第一级配置器处理。当区块小于 128 bytes 时,则以内存池( memory pool)管理,此法又称为次层配置( sub-allocation) :每次配置一大块内存,并维护对应之自由链表( free-list )。下次若再有相同大小的内存需求,就直接从free-lists中拨出。如果客端释还小额区块,就由配置器回收到 free-ists 中。是的,配置器除了负责配置,也负责回收。为了方便管理,SGI第二级配置器会主动将任何小额区块的内存需求量上调至8的倍数(例如客端要求 30 bytes、就自动调整为32 bytes ),并维护 16 个 free-lists,各自管理大小分别为 8,16,24,32,40,48,56,64,72.80,88,96,104,112,120,128 bytes的小额区块。
SGI 特殊的空间配置器,std::alloc
重要函数的解释
-
ROUND_UP
1
2
3size_t alloc::ROUND_UP(size_t bytes) {
return ((bytes + __ALIGN - 1) & ~(__ALIGN - 1));
}ROUND_UP的作用是将指定的字节数
bytes
向上取整为一个对齐的值。具体解释如下:
__ALIGN
是一个常量,表示内存对齐的边界值,这里为 8。内存对齐是指要求分配的内存块的起始地址是特定字节数的倍数,以提高内存访问效率。- 函数将
bytes
加上__ALIGN - 1
,相当于向上取整。这是因为要满足对齐的要求,如果bytes
不是__ALIGN
的倍数,就需要向上调整到下一个__ALIGN
的倍数。 - 使用位运算
& ~(__ALIGN - 1)
,实际上是将结果与__ALIGN - 1
的二进制补码进行按位取反,再进行按位与操作。这个操作会将低位的非零位都置为 0,将高位保持不变。这样可以将结果向下取整到最接近的__ALIGN
的倍数。 - 最终返回的结果就是向上取整到对齐边界的值。
-
chunk_alloc()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68/// @brief 从内存池中取空间给 free-lists 使用
/// @param size 申请区块大小,假设 size 已经上调至 8 的倍数
/// @param nblock 申请到的区块数量
char* alloc::chunk_alloc(size_t size, size_t& nblock) {
char* result;
size_t total_bytes = size * nblock; // 需要申请的空间大小
size_t bytes_left = end_free - start_free; // 内存池剩余空间大小
// 如果内存池剩余空间足够,直接返回内存池的首地址
if (bytes_left >= total_bytes) {
result = start_free;
start_free += total_bytes;
return result;
}
// 如果内存池剩余空间不足,但是剩余空间足够一个区块,直接返回内存池的首地址
else if (bytes_left >= size) {
nblock = bytes_left / size;
total_bytes = size * nblock;
result = start_free;
start_free += total_bytes;
return result;
}
// 如果内存池剩余大小连一个区块都无法满足
else {
// 如果内存池还有剩余,把剩余的空间加入到 free-list 中
if (bytes_left > 0) {
FreeList** my_free_list = free_list + FREELIST_INDEX(bytes_left);
((FreeList*) start_free)->next = *my_free_list;
*my_free_list = (FreeList*)start_free;
}
// malloc申请 heap 中两倍 + 额外大小的内存
// 额外大小:取 heap_size 的 1/16,用来做额外的缓冲。
size_t bytes_to_get = (total_bytes << 1) + ROUND_UP(heap_size >> 4);
start_free = (char *)std::malloc(bytes_to_get); // 直接使用 malloc 申请内存
// 堆中空间不足,malloc 失败
if (start_free == 0) {
FreeList **my_free_list, *p;
// 在 free-list 中查找是否有尚未用过且足够大的区块
for (size_t i = size; i < __MAX_BYTES; i+= __ALIGN) {
my_free_list = free_list + FREELIST_INDEX(i);
p = *my_free_list;
// 尚有未用的区块
if (p != 0) {
*my_free_list = p->next;
start_free = (char*)p;
end_free = start_free + i; // 可用的地址范围
return chunk_alloc(size, nblock); // 递归调用
}
}
// 如果一点内存都没有了的话,就只有调用一级配置器来申请内存了,并且用户没有设置处理例程就抛出异常
// 这里并没有使用一级配置器或 std::malloc,而是直接抛出异常,因为实际上一级配置器也是使用 malloc()
// 配置内存,只是多了一个 out-of-memory handler 的处理,简单起见,malloc() 配置失败后直接抛出异常
std::printf("out of memory!");
end_free = nullptr;
throw std::bad_alloc(); // 这里直接抛出异常
}
// 申请内存成功后重新修改内存起始地址和结束地址, 重新调用chunk_alloc分配内存
heap_size += bytes_to_get; // 更新 heap_size 大小,随着申请的次数逐渐增加
end_free = start_free + bytes_to_get; // 更新内存池结束位置
return(chunk_alloc(size, nblock)); // 递归调用,修正 nblock 的值
}
}上述的
chunk_alloc()
函数以end_free - start_free
来判断内存池的水量。如果水量允足,就直接调出20个区块返回给free list。如果水量不足以提供 20 个区块,但还足够供应一个以上的区块,就拨出这不足 20 个区块的空间出去。这时候其 pass by reference 的 nblock 参数将被修改为实际能够供应的区块数。如果内存池连一个区块空间都无法供应,对客端显然无法交待,此时便需利用malloc()
从 heap 中配置内存,为内存池注入活水源头以应付需求。新水量的大小为需求量的两倍,再加上一个随着配置次数增加而愈来愈大的附加量。举个例子,见图 2-7,假设程序一开始,客端就调用
chunk_alloc(32, 20)
,于是malloc()
配置 40 个 32 bytes 区块,其中第 1 个交出,另 19 个交给free_list[3]
维护,余 20 个留给内存池。接下来客端调用chunk_alloc(64,20)
,此时free_list[7]
空空如也,必须向内存池要求支持。内存池只够供应(32 * 20) / 64 = 10
个 64 bytes 区块,就把这 10 个区块返回,第 1 个交给客端,余 9 个由free_list[7]
维护。此时内存池全空.接下来再调用chunk_alloc(96, 20)
,此时free_list [11]
空空如也,必须向内存池要求支持,而内存池此时也是空的,于是以malloc()
配置 40 + n(附加量) 个 96 bytes 区块,其中第 1 个交出,另 19 个交给free_list[11]
维护,余 20+n (附加量) 个区块留给内存池。万一山穷水尽,整个 system heap 空问都不够了(以至无法为内存池注入活水源头),
malloc()
行动失败,chunk_alloc()
就四处寻找有无 “尚有未用区块,且区块够大” 之 free lists。找到了就挖一块交出,找不到就调用第一级配置器。第一级配置器其实也是使用malloc()
来配置内存,但它有 out-of-memory 处理机制(类似 new-handler 机制),或许有机会释放其它的内存拿来此处使用。如果可以,就成功,否则发出 bad_alloc 异常。
完整实现
1 |
|
总结
总的来说,STL 的二级配置器 alloc 使用了 free-list 及内存池的方式解决了单独使用 malloc()
申请空间时存在的诸多问题。
首先是内存碎片问题:已分配的内存块和空闲的内存块交替排列,导致在总体上看,仍有足够的空闲内存,但无法找到足够大的连续内存块来满足分配请求。这会造成可用内存实际上被浪费了,因为无法有效地满足分配请求,即使总体剩余的空闲内存足够大。alloc 使用内存池技术,利用 chunk_alloc()
每次都申请一块较大内存并将其划分为小区块使用,减少了频繁的小块内存分配和释放导致的内存碎片问题。并且,一次性申请较大的内存也有助于降低申请内存时的额外开销。
其次,alloc 使用 free-list 管理小区块内存,小于 128 bytes 的小区块内存统一由 free-list 分配与回收,减少了 malloc()
及 free()
的使用次数,提高了效率。