文章目录
- C++STL之空间配置器
-
- 什么是空间配置器
- [为什么需要 STL 空间配置器](#为什么需要 STL 空间配置器)
-
- [直接使用 new/malloc 管理容器空间的问题](#直接使用 new/malloc 管理容器空间的问题)
- [空间配置器的定位与 128 字节分界](#空间配置器的定位与 128 字节分界)
- [SGI-STL 一级空间配置器](#SGI-STL 一级空间配置器)
-
- [malloc/free 封装与 oom_malloc](#malloc/free 封装与 oom_malloc)
- [set_new_handler 思想的使用方式](#set_new_handler 思想的使用方式)
- [SGI-STL 二级空间配置器](#SGI-STL 二级空间配置器)
-
- 内存池设计
- [哈希桶空闲链表与 8 字节对齐](#哈希桶空闲链表与 8 字节对齐)
- [allocate 申请流程](#allocate 申请流程)
- [refill 批量填充](#refill 批量填充)
- [chunk_alloc 向内存池索要空间](#chunk_alloc 向内存池索要空间)
- [deallocate 回收流程](#deallocate 回收流程)
- 默认选择、再次封装与对象生命周期
-
- [USE_MALLOC 宏与默认选择](#USE_MALLOC 宏与默认选择)
- [simple_alloc 再次封装](#simple_alloc 再次封装)
- [construct/destroy 与对象生命周期分离](#construct/destroy 与对象生命周期分离)
- [与容器结合(以 list 为例)](#与容器结合(以 list 为例))
-
- [list 节点空间管理](#list 节点空间管理)
C++STL之空间配置器
什么是空间配置器
空间配置器,顾名思义就是为各个容器高效的管理空间(空间的申请与回收)的,在默默地工作。虽然在常规使用STL时,可能用不到它,但站在学习研究的角度,学习它的实现原理对我们有很大的帮助。
为什么需要 STL 空间配置器
直接使用 new/malloc 管理容器空间的问题
STL 容器运行中会频繁申请/释放小块内存(list 插节点、vector 扩容、map 插红黑树节点等)。若都直接 new/malloc 向系统申请,虽功能正确,但带来效率、碎片、额外开销、异常处理和代码复用等问题。空间配置器的目标就是把"空间申请/回收"从容器业务逻辑中抽离,形成统一高效的内存管理机制。它不负责对象构造与析构,只管空间本身------这是理解 SGI-STL 设计的关键。
cpp
#include <iostream>
#include <vector>
struct Node {
int value;
Node* next;
};
int main() {
// 模拟容器频繁申请、释放小块节点
for (int round = 0; round < 3; ++round) {
std::vector<Node*> nodes;
for (int i = 0; i < 5; ++i) {
nodes.push_back(new Node{i, nullptr}); // 每次 new 都是一次小块内存申请
}
for (Node* p : nodes) {
delete p; // 每次 delete 都是一次小块内存释放
}
}
std::cout << "频繁 new/delete 小对象完成" << std::endl;
return 0;
}
为什么C++要对小块内存进行优化处理?
- 因为当我们多次申请和释放小块内存时会发生内存碎片问题,次数多了原本不需要向系统去申请因为内存碎片的原因,
new/malloc就不得不去向系统去申请内存。
空间配置器的定位与 128 字节分界
空间配置器是容器的"幕后内存管家",负责按字节申请和回收空间,容器再在这块空间上构造对象。SGI-STL 以 128 字节作为小块内存与大块内存的分界线:大于 128 字节交给一级空间配置器,小于等于 128 字节交给二级空间配置器。这样设计的原因是:大块内存直接 malloc/free 管理成本可接受;小块内存则用内存池和哈希桶批量管理,减少系统调用和碎片。注意 128 是 SGI-STL 的实现选择,不是 C++ 标准规定。
cpp
constexpr size_t MAX_BYTES = 128; // SGI-STL 小块内存分界线
void* dispatch_allocate(size_t n) {
if (n > MAX_BYTES) {
// 大块内存:交给一级空间配置器,本质是 malloc/free 封装
return std::malloc(n);
}
// 小块内存:交给二级空间配置器,内部使用内存池 + 哈希桶
return nullptr; // 这里仅示意分支,真实实现见后文
}
注意:128 不是标准值,现代标准 std::allocator 的实现与 SGI-STL 二级配置器并不完全相同。
SGI-STL 一级空间配置器
malloc/free 封装与 oom_malloc
一级空间配置器处理大块内存,原理非常简单:对 malloc 和 free 做封装。申请成功直接返回;申请失败时调用 oom_malloc,它模拟 C++ set_new_handler 的思想:如果用户设置了"内存不足处理函数",就反复调用该函数并重试 malloc;如果没有设置,则抛出 std::bad_alloc。这种设计把"内存不足时怎么办"的决定权交给用户,同时保持接口简单。
cpp
#include <cstdlib>
#include <new>
#include <iostream>
template<int inst>
class MallocAllocTemplate {
public:
static void* allocate(size_t n) {
void* result = std::malloc(n);
if (result == nullptr) {
result = oom_malloc(n); // 申请失败交给 oom_malloc 处理
}
return result;
}
static void deallocate(void* p, size_t /*n*/) {
std::free(p);
}
// 设置内存不足处理函数,返回旧的处理函数
static void (*set_malloc_handler(void (*f)()))() {
void (*old)() = handler_;
handler_ = f;
return old;
}
private:
static void* oom_malloc(size_t n) {
for (;;) {
void (*my_handler)() = handler_;
if (my_handler == nullptr) {
throw std::bad_alloc(); // 没有设置处理函数,按 new 的方式抛异常
}
my_handler(); // 执行用户提供的应对措施
void* result = std::malloc(n);
if (result != nullptr) {
return result; // 重试成功则返回
}
}
}
static void (*handler_)();
};
template<int inst>
void (*MallocAllocTemplate<inst>::handler_)() = nullptr;
using MallocAlloc = MallocAllocTemplate<0>;
int main() {
void* p = MallocAlloc::allocate(64);
MallocAlloc::deallocate(p, 64);
std::cout << "一级空间配置器申请/释放完成" << std::endl;
return 0;
}
set_new_handler 思想的使用方式
一级配置器的"处理函数"机制允许用户在内存不足时释放紧急预留内存、记录日志或终止程序。设置处理函数后,后续大块内存申请失败会进入 oom_malloc 并调用它。使用完应恢复旧处理函数,避免影响其他模块。注意处理函数本身不能依赖大量新内存,否则容易递归失败。
cpp
void my_oom_handler() {
std::cerr << "内存不足:尝试释放全局预留内存或记录日志" << std::endl;
// 真实项目中可释放一块预留 buffer,然后返回让 oom_malloc 重试 malloc
}
int main() {
auto old_handler = MallocAlloc::set_malloc_handler(my_oom_handler);
void* p = MallocAlloc::allocate(1024);
MallocAlloc::deallocate(p, 1024);
MallocAlloc::set_malloc_handler(old_handler); // 恢复旧处理函数
return 0;
}
SGI-STL 二级空间配置器
内存池设计
二级空间配置器负责小于等于 128 字节的小块内存。它先向系统申请一块较大的内存作为"内存池",用 start 和 finish 标记池中剩余空间的起止位置。用户申请时从池中切割;池不够时再向系统补充。用户归还的小块不会直接 free 给系统,而是挂回哈希桶空闲链表,等待复用。这样避免频繁系统调用,也减少额外头部浪费。

cpp
struct MemoryPoolState {
static char* start_free; // 内存池剩余空间起始位置
static char* end_free; // 内存池剩余空间结束位置
static size_t heap_size; // 已向系统索要的总量,用于增长策略
};
char* MemoryPoolState::start_free = nullptr;
char* MemoryPoolState::end_free = nullptr;
size_t MemoryPoolState::heap_size = 0;
void* try_take_from_pool(size_t bytes) {
size_t bytes_left = (MemoryPoolState::start_free && MemoryPoolState::end_free)
? static_cast<size_t>(MemoryPoolState::end_free - MemoryPoolState::start_free)
: 0;
if (bytes_left >= bytes) {
void* result = MemoryPoolState::start_free;
MemoryPoolState::start_free += bytes;
return result;
}
return nullptr; // 池中不够,需要补充
}
- 内存池中的空间必须按 8 字节对齐切割;否则返回给用户的地址可能不满足对齐要求。
- 归还的空间不能简单"拼接回大块前面",因为不知道相邻块是否已分配、没有边界标记,直接拼接会破坏已分配块。
- 不断切割会让池中剩余空间变成很多不连续的小块;当需要较大块时可能无法满足,需要向系统补充或从更大的哈希桶中找。
哈希桶空闲链表与 8 字节对齐
SGI-STL 用 16 个哈希桶管理 8、16、24、...、128 字节的空闲块,而不是 128 个桶。用户申请大小向上对齐到 8 的整数倍,FREELIST_INDEX 计算桶号。每个空闲块用 union obj 复用内存:当块空闲时存 free_list_link 指针,当块被用户使用时作为 client_data。用 union 而不是结构体,是为了空闲块不额外增加指针字段,节省空间。

cpp
#include <cstddef>
class DefaultAllocTemplate {
private:
enum { ALIGN = 8, MAX_BYTES = 128, NFREELISTS = MAX_BYTES / ALIGN };
// 空闲块联合体:空闲时存链表指针,使用时作为用户数据
union obj {
union obj* free_list_link;
char client_data[1];
};
static obj* free_list[NFREELISTS]; // 16 个哈希桶
static char* start_free;
static char* end_free;
static size_t heap_size;
// 向上对齐到 8 的整数倍
static size_t ROUND_UP(size_t bytes) {
return (bytes + ALIGN - 1) & ~(ALIGN - 1);
}
// 根据字节数计算桶号,8 字节对应 0 号桶,128 字节对应 15 号桶
static size_t FREELIST_INDEX(size_t bytes) {
return (bytes + ALIGN - 1) / ALIGN - 1;
}
public:
static void* allocate(size_t n);
static void deallocate(void* p, size_t n);
private:
static void* refill(size_t n);
static char* chunk_alloc(size_t size, int& nobjs);
};
DefaultAllocTemplate::obj* DefaultAllocTemplate::free_list[DefaultAllocTemplate::NFREELISTS] = {nullptr};
char* DefaultAllocTemplate::start_free = nullptr;
char* DefaultAllocTemplate::end_free = nullptr;
size_t DefaultAllocTemplate::heap_size = 0;
为什么向上对齐到 8 而不是 4?
- 因为8 字节对齐能容纳常见指针和 8 字节类型,且 128/8=16 个桶,管理开销较小;若按 4 对齐会变成 32 个桶,桶更多且不一定满足 8 字节对齐类型。
注意:union obj 中 client_data[1] 只是占位,实际块至少 8 字节,所以能放下一个指针。
allocate 申请流程
allocate(n) 先判断是否大于 128:大于则交给一级空间配置器;否则计算桶号,若桶非空,直接取桶头节点并更新链表;若桶为空,调用 refill 向内存池批量补充。这个流程把"大块直接系统分配"和"小块桶内快速取用"分开,是二级配置器的入口。

cpp
void* DefaultAllocTemplate::allocate(size_t n) {
if (n == 0) {
return nullptr; // 简化处理,标准 allocator 对 0 可有不同策略
}
if (n > MAX_BYTES) {
return MallocAlloc::allocate(n); // 大块交给一级空间配置器
}
obj** my_free_list = free_list + FREELIST_INDEX(n);
obj* result = *my_free_list;
if (result == nullptr) {
// 桶为空:向桶中填充小块内存
return refill(ROUND_UP(n));
}
// 桶非空:取第一个小块,并更新桶头
*my_free_list = result->free_list_link;
return result;
}
必须使用 ROUND_UP(n) 后再 refill,保证桶中块大小是 8 的倍数;否则桶索引和链表步长会错。
allocate 中桶为空为什么要 refill,而不是直接向系统 malloc 一块?
- 因为二级配置器的目标是批量管理小块内存;refill 一次向内存池要多个块,减少系统调用,并把剩余块挂桶复用。
refill 批量填充
refill(n) 负责向内存池一次性索要 20 个 n 字节的小块。如果 chunk_alloc 只能提供 1 块,就直接返回给用户;如果提供多块,第一块返回给用户,其余 nobjs-1 块串成链表挂到对应哈希桶。批量申请是二级配置器提升效率的关键:一次系统补充可以服务多次用户申请。

cpp
void* DefaultAllocTemplate::refill(size_t n) {
int nobjs = 20; // 一次向内存池索要 20 个小块
char* chunk = chunk_alloc(n, nobjs);
if (nobjs == 1) {
return chunk; // 只拿到一块,直接给用户
}
obj** my_free_list = free_list + FREELIST_INDEX(n);
obj* result = reinterpret_cast<obj*>(chunk);
obj* current_obj;
obj* next_obj = reinterpret_cast<obj*>(chunk + n); // 第二块
*my_free_list = next_obj; // 桶头指向第二块
for (int i = 1; ; ++i) {
current_obj = next_obj;
next_obj = reinterpret_cast<obj*>(reinterpret_cast<char*>(next_obj) + n);
if (nobjs - 1 == i) {
current_obj->free_list_link = nullptr; // 最后一块链表尾
break;
} else {
current_obj->free_list_link = next_obj;
}
}
return result; // 第一块返回给用户
}
第一块返回给用户,不挂桶;剩余块才挂桶,否则用户拿到的块会被链表指针覆盖。
chunk_alloc 向内存池索要空间
chunk_alloc(size, nobjs) 是内存池分配核心:先计算 nobjs 个块的总字节数和池中剩余字节数。如果池中够 20 块,直接切割返回;如果不够 20 但至少够 1 块,就把 nobjs 调整为实际可提供块数并返回;如果一块都不够,先把池中剩余空间挂到对应哈希桶,再向系统 malloc 补充,补充成功后递归继续分配;如果系统补充失败,就从更大的哈希桶中找空闲块放入内存池;最后才向一级空间配置器求助。

cpp
char* DefaultAllocTemplate::chunk_alloc(size_t size, int& nobjs) {
char* result;
size_t total_bytes = size * nobjs;
size_t bytes_left = (start_free && end_free)
? static_cast<size_t>(end_free - start_free)
: 0;
if (bytes_left >= total_bytes) {
// 池中够 nobjs 块
result = start_free;
start_free += total_bytes;
return result;
} else if (bytes_left >= size) {
// 不够 nobjs 块,但至少够 1 块
nobjs = static_cast<int>(bytes_left / size);
total_bytes = size * nobjs;
result = start_free;
start_free += total_bytes;
return result;
} else {
// 一块都不够:先把池中剩余空间挂到对应桶
if (bytes_left > 0) {
obj** my_free_list = free_list + FREELIST_INDEX(bytes_left);
reinterpret_cast<obj*>(start_free)->free_list_link = *my_free_list;
*my_free_list = reinterpret_cast<obj*>(start_free);
}
// 向系统堆补充内存,SGI-STL 常用增长策略
size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);
start_free = static_cast<char*>(std::malloc(bytes_to_get));
if (start_free == nullptr) {
// 系统补充失败:从更大的哈希桶中找空闲块
for (int i = static_cast<int>(size); i <= MAX_BYTES; i += ALIGN) {
obj** my_free_list = free_list + FREELIST_INDEX(i);
obj* p = *my_free_list;
if (p != nullptr) {
*my_free_list = p->free_list_link;
start_free = reinterpret_cast<char*>(p);
end_free = start_free + i;
return chunk_alloc(size, nobjs); // 递归继续分配
}
}
// 山穷水尽:向一级空间配置器求助
end_free = nullptr; // 防止一级配置器抛异常后状态不一致
start_free = static_cast<char*>(MallocAlloc::allocate(bytes_to_get));
}
heap_size += bytes_to_get;
end_free = start_free + bytes_to_get;
return chunk_alloc(size, nobjs); // 补充成功后递归分配
}
}
deallocate 回收流程
deallocate(p, n) 负责回收空间。若 n > 128,交给一级空间配置器 free;否则找到对应哈希桶,把用户块头插到桶链表中。注意二级配置器不会把小块直接还给系统,而是保留在桶中复用。这样下次同大小申请可以直接 O(1) 取到,避免频繁系统调用。

cpp
void DefaultAllocTemplate::deallocate(void* p, size_t n) {
if (n == 0 || p == nullptr) {
return;
}
if (n > MAX_BYTES) {
MallocAlloc::deallocate(p, n); // 大块还给一级空间配置器
return;
}
obj** my_free_list = free_list + FREELIST_INDEX(n);
obj* q = reinterpret_cast<obj*>(p);
q->free_list_link = *my_free_list; // 头插
*my_free_list = q;
}
为什么deallocate这里不直接 free?
- 因为小块内存直接
free会重新陷入频繁系统调用和碎片问题;挂回哈希桶可复用,提升后续申请效率。
我们发现 allocate 、refill、chunk_alloc、deallocate都没加锁,我们在了解C++11的线程库时就了解过这个线程安全问题,那么为什么SGI_STL不加锁呢?
- 时代原因,SGI-STL 诞生于上世纪 90 年代,当时多核 CPU 还远未普及,多线程编程也不是 C++ 标准的一部分(C++11 才正式引入多线程内存模型)。
- STL 的设计哲学是"极致追求效率"。对于单线程程序(当时的主流),加锁会带来完全不必要的性能损耗。
- 早期的 STL 认为,"容器管理内存"和"多线程并发安全"是两个独立的问题。当时的共识是:如果你要在多线程环境使用 STL,你应该自己在外面加锁,或者使用平台特定的线程安全分配器。
那么如何让SGI_STL线程安全呢?
- 普遍做法是在
allocate和deallocate人口处加锁,这样就可以做到线程安全。
默认选择、再次封装与对象生命周期
USE_MALLOC 宏与默认选择
SGI-STL 通过 __USE_MALLOC 宏控制默认使用一级还是二级空间配置器。如果定义了该宏,alloc 就是一级 malloc_alloc;如果没有定义,则使用二级空间配置器。SGI-STL 中该宏通常没有定义,因此默认使用二级配置器。这样大多数容器的小块节点都能享受内存池和哈希桶带来的性能收益。
cpp
#ifdef __USE_MALLOC
typedef MallocAlloc alloc;
typedef MallocAlloc single_client_alloc;
#else
typedef DefaultAllocTemplate alloc; // 默认:二级空间配置器
typedef DefaultAllocTemplate single_client_alloc;
#endif
simple_alloc 再次封装
容器需要的是"n 个 T 类型对象"的空间,而不是裸字节数。simple_alloc<T, Alloc> 把空间配置器再封装一层,自动计算 n * sizeof(T),提供申请一个对象、申请 n 个对象、释放一个对象、释放 n 个对象的接口。它只负责空间申请与归还,不负责对象构造和析构。这样容器代码更清晰,避免每次手写字节数计算。
cpp
template<class T, class Alloc>
class SimpleAlloc {
public:
// 申请 n 个 T 类型对象大小的空间
static T* allocate(size_t n) {
return n == 0 ? nullptr : static_cast<T*>(Alloc::allocate(n * sizeof(T)));
}
// 申请一个 T 类型对象大小的空间
static T* allocate() {
return static_cast<T*>(Alloc::allocate(sizeof(T)));
}
// 释放 n 个 T 类型对象大小的空间
static void deallocate(T* p, size_t n) {
if (n != 0) {
Alloc::deallocate(p, n * sizeof(T));
}
}
// 释放一个 T 类型对象大小的空间
static void deallocate(T* p) {
Alloc::deallocate(p, sizeof(T));
}
};
construct/destroy 与对象生命周期分离
SGI-STL 把"空间申请释放"和"对象构造析构"分成两个阶段:先用配置器申请空间,再用 placement new 在空间上构造对象;销毁时先显式调用析构函数清理对象资源,再把空间还给配置器。分离的原因是:有些对象构造/析构不需要复杂操作,分离后可以针对 POD 类型做优化,提高性能。
cpp
template <class T1, class T2>
inline void construct(T1* p, const T2& value) {
new (p) T1(value); // placement new
}
// 销毁对象:显式调用析构函数
template <class T>
inline void destroy(T* p) {
p->~T();
}
注意:
- 在释放对象时,需要根据对象的类型确定是否调用析构函数(类型萃取)
- 对象的类型可以通过迭代器获萃取到
以上两步在实现时稍微有点复杂,有兴趣话可参考STL源码。
与容器结合(以 list 为例)
list 节点空间管理
容器通过 simple_alloc 申请节点空间,再在节点空间上构造 data。list 的 get_node/put_node 只负责节点空间申请与归还;create_node/destroy_node 负责对象构造与析构。插入时先创建节点再接链;删除时先摘链、析构数据、归还节点空间。这样容器业务逻辑与空间管理解耦,也保证异常安全:如果构造 data 抛异常,要归还已申请节点空间。
cpp
template<class T, class Alloc>
class List {
struct Node {
Node* prev;
Node* next;
T data;
};
using NodeAlloc = SimpleAlloc<Node, Alloc>;
Node* get_node() {
return NodeAlloc::allocate(); // 只申请节点空间
}
void put_node(Node* p) {
NodeAlloc::deallocate(p); // 只归还节点空间
}
Node* create_node(const T& x) {
Node* p = get_node();
try {
construct(&p->data, x); // 在节点空间上构造 data
} catch (...) {
put_node(p); // 构造失败,归还空间,避免泄漏
throw;
}
return p;
}
void destroy_node(Node* p) {
destroy(&p->data); // 先析构 data
put_node(p); // 再归还节点空间
}
public:
// 简化 insert:先创建节点,再接入链表
Node* insert_after(Node* pos, const T& x) {
Node* tmp = create_node(x);
tmp->next = pos->next;
tmp->prev = pos;
if (pos->next) {
pos->next->prev = tmp;
}
pos->next = tmp;
return tmp;
}
// 简化 erase:先摘链,再销毁节点
Node* erase(Node* pos) {
Node* next_node = pos->next;
Node* prev_node = pos->prev;
if (prev_node) {
prev_node->next = next_node;
}
if (next_node) {
next_node->prev = prev_node;
}
destroy_node(pos);
return next_node;
}
};
完