TLSF(Two-Level Segregated Fit)内存分配器深入解析
引言
TLSF(Two-Level Segregated Fit,两级隔离适应)是一种高效的内存分配算法,特别适用于实时系统,其最坏情况下的分配和释放操作的时间复杂度为O(1)。本文将深入解析TLSF的设计原理和实现细节,通过C++核心代码示例和设计思想,帮助读者理解以下内容:
- TLSF的核心数据结构:两级位图索引与空闲链表矩阵。
- 内存分配的精确步骤:索引计算、空闲块搜索与分割。
- 内存释放与相邻块合并:防止内存碎片。
- 性能优化技巧:位图操作加速、地址对齐、碎片控制。
- TLSF的局限性及适用场景。
通过本文,读者将能够掌握实现一个工业级TLSF内存分配器的关键技术,并了解其在高性能系统中的实际应用。
大纲
- TLSF内存分配器概述
- 核心数据结构解析
- 初始化内存池
- 索引计算:O(1)分配的关键
- 内存分配流程详解
- 内存释放与相邻块合并
- 性能优化技巧
- TLSF的局限性
- 总结与适用场景
1. TLSF内存分配器概述
TLSF是一种为实时系统设计的内存分配器,它的核心目标是在最坏情况下也能保证分配和释放操作在常数时间内完成(O(1))。这种特性使得TLSF非常适合嵌入式系统、音视频处理、游戏引擎等对延迟敏感的应用场景。
TLSF的设计基于两个核心思想:
- 两级隔离适应(Segregated Fit):内存块按大小分级,第一级(First Level)按2的幂次划分区间(例如:32字节、64字节等),第二级(Second Level)将每个第一级区间再细分为32个子区间,形成32×32的矩阵结构。
- 位图索引(Bitmap Index):使用两个位图(第一级位图
fl_bitmap
和第二级位图数组sl_bitmap[]
)快速标记非空链表,通过位操作快速定位可用内存块。
这种设计使得分配器能够在无需遍历链表的情况下,快速找到合适大小的空闲块。
2. 核心数据结构解析
TLSF的核心数据结构分为两部分:块头部(BlockHeader)和控制结构(TLSFAllocator)。
块头部(BlockHeader)
每个内存块都有一个8字节的头部(需要按照系统要求对齐),用于存储管理信息:
struct BlockHeader {size_t size; // 当前块大小(包括头部)BlockHeader* prev_phys; // 物理相邻的前一个块地址(双向链接物理相邻块)uint16_t fl_index; // First-Level 索引(所在的第一级区间)uint16_t sl_index; // Second-Level 索引(所在的第二级子区间)bool is_free; // 空闲标志
};
注意:这里没有使用显式双向链表(如prev_free
和next_free
)来链接空闲块,而是通过控制结构中的空闲链表矩阵来管理。在实现中,为了插入和删除空闲块的方便,通常在块头部中添加隐式的空闲链表指针,但上述代码中并未展示,我们可以在实际实现时添加:
struct BlockHeader {// ... 已有成员BlockHeader* prev_free; // 空闲链表前驱BlockHeader* next_free; // 空闲链表后继
};
控制结构(TLSFAllocator)
TLSFAllocator管理整个内存池和空闲块:
class TLSFAllocator {
private:uint32_t fl_bitmap = 0; // 第一级位图(32位,每一比特代表一个第一级区间是否非空)uint32_t sl_bitmap[32] = {0}; // 第二级位图数组,每个元素32位,对应第一级的每个区间内的32个子区间BlockHeader* free_blocks[32][32] = {nullptr}; // 空闲链表矩阵,第一级索引0~31,第二级索引0~31char* memory_pool = nullptr; // 预分配的内存池起始地址size_t pool_size = 0; // 内存池大小
};
数据结构示意图:
我们可以用Mermaid中的类图来表示:
关键点:
- 物理相邻链接:通过
prev_phys
字段,每个块都能找到其物理地址上的前一个块。物理后一个块的位置可以通过当前块地址加上当前块大小计算得出。 - 空闲链表矩阵:
free_blocks[fl][sl]
指向一个双向链表,包含所有属于该区间的空闲块。链表操作采用头插法,以实现O(1)插入和分配。
头插法在 TLSF 中的核心优势:
操作 | 时间复杂度 | 实现方式 | 性能影响 |
---|---|---|---|
空闲块插入 | O(1) | 直接修改链表头指针 | 极速完成碎片回收 |
空闲块分配 | O(1) | 直接取链表首节点 | 消除链表遍历开销 |
空闲块插入:
void TLSFAllocator::insert_into_free_list(BlockHeader* block) {// 计算块的 FL/SL 索引uint16_t fl, sl;get_indices(block->size, &fl, &sl);// ⭐⭐⭐ 头插法核心 ⭐⭐⭐// 将新块插入对应链表的头部block->prev_free = nullptr; // 新块无前驱block->next_free = free_blocks[fl][sl]; // 新块指向原首节点// 更新链表关系if (free_blocks[fl][sl] != nullptr) {free_blocks[fl][sl]->prev_free = block; // 原首节点的前驱指向新块}free_blocks[fl][sl] = block; // 更新链表头指针// 更新位图标记(略)
}
空闲块分配:
BlockHeader* TLSFAllocator::remove_from_free_list(uint16_t fl, uint16_t sl) {// ⭐⭐⭐ 直接访问链表头部 ⭐⭐⭐BlockHeader* block = free_blocks[fl][sl];if (!block) return nullptr;// 更新链表头指针free_blocks[fl][sl] = block->next_free;// 处理后继节点的链接if (block->next_free) {block->next_free->prev_free = nullptr;}return block;
}// 分配入口
void* TLSFAllocator::malloc(size_t size) {// ...BlockHeader* block = remove_from_free_list(fl, sl);// ...
}
3. 初始化内存池
在分配任何内存之前,需要初始化内存池。通常,TLSF会预分配一大块连续内存作为初始内存池。
void TLSFAllocator::initialize(size_t total_size) {// 对齐到页大小(例如4KB)const size_t PAGE_SIZE = 4096;pool_size = (total_size + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1);// 从系统申请内存(Linux平台)memory_pool = static_cast<char*>(mmap(nullptr, pool_size, PROT_READ | PROT_WRITE,MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));if (memory_pool == MAP_FAILED) {// 错误处理}// 初始化第一个块:占据整个内存池(减去头部)BlockHeader* first_block = reinterpret_cast<BlockHeader*>(memory_pool);first_block->size = pool_size - sizeof(BlockHeader);first_block->prev_phys = nullptr;first_block->is_free = true;// 设置索引(后面插入时会计算)// 插入空闲链表insert_into_free_list(first_block);
}
注意:这里使用mmap
分配内存,实际在跨平台项目中可能需要使用其他方式(如VirtualAlloc
、posix_memalign
等)。
4. 索引计算:O(1)分配的关键
TLSF的核心在于快速定位适合请求大小的空闲块所在区间。索引计算函数get_indices
将块大小映射到第一级(FL)和第二级(SL)索引。
inline void get_indices(size_t size, uint16_t* fl, uint16_t* sl) {if (size < 256) {*fl = 0; // 特殊处理小对象:第一级索引设为0*sl = size / 8; // 每8字节一个子区间:0~31(因为256/8=32)} else {// 计算不小于size的最小2的幂:30~0(size>=256 => 至少是8位,所以30位足够)*fl = 31 - __builtin_clz(size); // 等价于 floor(log2(size)),注意:__builtin_clz(0)未定义,需确保size>=1// 计算第二级索引:从第一级区间中划分32个子区间// 公式:sl = (size - 2^fl) / (2^fl / 32) = (size >> (fl-5)) & 0x1F*sl = (size >> (*fl - 5)) & 0x1F; // 取[fl-5]位开始的5个比特}
}
例如:
- 请求大小为100字节:
fl=0
,sl=100/8=12.5
-> 取整为12。 - 请求大小为300字节:
fl = floor(log2(300))=8
(因为28=256<=300<512=292^8=256<=300<512=2^928=256<=300<512=29),然后计算sl=(300>>(8-5)) & 0x1F = (300>>3) & 0x1F = 37.5->37 & 0x1F = 37 mod 32 -> 5
(因为37的二进制是100101100101100101,取低5位:001010010100101,即555)。
位图加速搜索:
TLSF使用位图快速定位非空链表:
fl_bitmap
:32位无符号整数,第i
位为1表示第一级索引i
对应的区间非空。sl_bitmap[i]
:32位无符号整数,第j
位为1表示第一级索引i
的第二级索引j
对应的链表非空。
在分配时,首先计算请求大小对应的fl
和sl
,然后检查对应的链表是否为空:
- 如果非空,直接分配头结点。
- 如果为空,则在当前第一级区间内查找更高第二级索引(
sl_bitmap[fl]
中大于当前sl
的最低比特位)。 - 如果当前第一级没有,则查找更高第一级索引(
fl_bitmap
中大于当前fl
的最低比特位)。
5. 内存分配流程详解
分配函数malloc
的流程:如果匹配的空闲块远大于需分配的块大小,执行块分割(例如:剩余空间大于头部+8字节)。
void* TLSFAllocator::malloc(size_t size) {// 1. 调整请求大小:加上头部,并做对齐(例如8字节对齐)size_t required = size + sizeof(BlockHeader);required = (required + 7) & ~0x07;// 2. 计算索引uint16_t fl, sl;get_indices(required, &fl, &sl);// 3. 搜索合适块BlockHeader* block = search_suitable_block(fl, sl);if (!block) {// 内存不足处理:尝试扩展内存池或返回nullptrreturn nullptr;}// 4. 若找到的块比需求大很多(至少超过所需大小+最小块大小),则分割块if (block->size > required + sizeof(BlockHeader) + 8) { // 最小分割阈值split_block(block, required);}// 5. 将块标记为已使用// 注意:在search_suitable_block中我们通常会移除块,因此这里只需标记block->is_free = false;// 返回数据区地址(块头部之后)return reinterpret_cast<void*>(block + 1);
}
搜索合适块(search_suitable_block)
首先精准匹配,再依次从第二级到第一级进行搜索。
BlockHeader* TLSFAllocator::search_suitable_block(uint16_t fl, uint16_t sl) {// 首先检查精确匹配的链表if (free_blocks[fl][sl]) {BlockHeader* block = free_blocks[fl][sl];// 将该块从空闲链表移除remove_from_free_list(block);return block;}// 在当前第一级区间内查找:在sl_bitmap[fl]中查找大于等于当前sl的最小非零位uint32_t sl_map = sl_bitmap[fl] & (0xFFFFFFFF << sl); // 屏蔽低sl位if (sl_map != 0) {uint16_t new_sl = __builtin_ctz(sl_map); // 计算最低位的1的位置BlockHeader* block = free_blocks[fl][new_sl];remove_from_free_list(block);return block;}// 查找更高第一级区间:在fl_bitmap中查找大于当前fl的最小非零位uint32_t fl_map = fl_bitmap & (0xFFFFFFFF << (fl + 1));if (fl_map == 0) {return nullptr; // 没有可用块}uint16_t new_fl = __builtin_ctz(fl_map); // 下一个非空的第一级索引uint16_t new_sl = __builtin_ctz(sl_bitmap[new_fl]); // 该第一级中的第一个非空第二级索引BlockHeader* block = free_blocks[new_fl][new_sl];remove_from_free_list(block);return block;
}
注意:remove_from_free_list
函数需要更新位图和链表指针,实现如下:
void TLSFAllocator::remove_from_free_list(BlockHeader* block) {uint16_t fl = block->fl_index;uint16_t sl = block->sl_index;// 更新链表指针if (block->prev_free) {block->prev_free->next_free = block->next_free;} else {// 它是链表头free_blocks[fl][sl] = block->next_free;}if (block->next_free) {block->next_free->prev_free = block->prev_free;}// 更新位图:如果该链表变为空,则清除位图相应位if (free_blocks[fl][sl] == nullptr) {sl_bitmap[fl] &= ~(1 << sl);if (sl_bitmap[fl] == 0) {fl_bitmap &= ~(1 << fl);}}// 清空块的链表指针(可选)block->prev_free = nullptr;block->next_free = nullptr;
}
块分割(split_block)
当空闲块远大于请求大小时,分割可以避免浪费:
void TLSFAllocator::split_block(BlockHeader* block, size_t size) {// 创建新块:位于当前块的数据区中偏移size处BlockHeader* new_block = reinterpret_cast<BlockHeader*>(reinterpret_cast<char*>(block) + size);// 初始化新块new_block->size = block->size - size;new_block->prev_phys = block;new_block->is_free = true;// 更新原块大小block->size = size;// 新块的物理后块:原块的物理后块BlockHeader* next = get_next_phys_block(block); // 通过物理位置计算下一块if (next) {next->prev_phys = new_block;}// 将新块插入空闲链表insert_into_free_list(new_block);
}// 根据当前块计算物理下一块(通过当前块地址+块大小)
inline BlockHeader* get_next_phys_block(BlockHeader* block) {BlockHeader* next = reinterpret_cast<BlockHeader*>(reinterpret_cast<char*>(block) + block->size);// 检查是否超出内存池范围if (reinterpret_cast<char*>(next) >= memory_pool + pool_size) {return nullptr; // 无下一块}return next;
}
6. 内存释放与相邻块合并
释放内存块时,需要将其标记为空闲,并尝试与物理相邻的空闲块合并,以减少碎片。
void TLSFAllocator::free(void* ptr) {if (ptr == nullptr) return;BlockHeader* block = reinterpret_cast<BlockHeader*>(ptr) - 1; // 获取块头部// 标记为空闲block->is_free = true;// 尝试与物理前一块合并BlockHeader* prev = block->prev_phys;if (prev && prev->is_free) {// 移除前一块(合并后前一块作为合并块)remove_from_free_list(prev);merge_blocks(prev, block); // 将block合并到prevblock = prev; // 现在block指向合并后的块}// 尝试与物理后一块合并BlockHeader* next = get_next_phys_block(block);if (next && next->is_free) {remove_from_free_list(next);merge_blocks(block, next);}// 将合并后的块插入空闲链表insert_into_free_list(block);
}// 合并相邻块(left和right物理相邻且左在前)
void TLSFAllocator::merge_blocks(BlockHeader* left, BlockHeader* right) {// 计算right块的大小(包括其头部)// 注意:在分割时,我们确保了每个块的大小包括头部left->size += right->size;// 更新right物理后块的prev_phys指针BlockHeader* next = get_next_phys_block(right);if (next) {next->prev_phys = left;}
}
7. 性能优化技巧
位图加速
使用编译器内置函数__builtin_ctz
(计算末尾0的个数)和__builtin_clz
(计算前导0的个数)快速查找位图中的第一个非零位。这些函数在硬件指令层面通常是O(1)操作。
// 使用CPU内置指令加速位图操作
uint16_t fl = __builtin_clz(size) ^ 31;
uint16_t sl = __builtin_ctz(sl_bitmap[fl]);
地址对齐
内存块地址需要对齐,通常是8字节对齐,以兼容各种系统要求:
size = (size + ALIGNMENT - 1) & ~(ALIGNMENT - 1); // ALIGNMENT一般为8
碎片控制
最小分割阈值:分割操作仅在剩余空间大于最小块大小(例如:头部+8字节)时才进行,避免产生无法使用的微小碎片。
最小分配单元:例如16字节,即使请求1字节,也分配16字节(包括头部),但这样做会增加内部碎片。
线程安全
线程局部存储(TLS):为每个线程创建一个独立的TLSFAllocator
实例,避免锁竞争:
static thread_local TLSFAllocator local_allocator;
如果必须支持跨线程释放,可以使用消息队列传递释放任务到分配线程。
8. TLSF的局限性
尽管TLSF具有出色的性能,但仍存在一些局限性:
- 跨线程释放困难:通常每个线程有独立的分配器,跨线程释放需要额外的同步机制,如引用计数或消息传递。
- 元数据开销:每个块至少8字节的头部,对于小对象分配(如大量16字节对象),元数据开销占比高达50%。
- 碎片问题:虽然合并机制减少了碎片,但在长期运行、内存分配大小变化大的场景中,仍可能出现碎片。极端情况下,可能需要碎片整理。
- 实时性保证:虽然TLSF设计为O(1),但合并操作可能需要遍历链表(实际上,合并操作只检查相邻块,所以仍是常数时间)。但在内存碎片极端严重时,合并后的块可能多次分裂和合并,导致时间波动。
9. 总结与适用场景
TLSF内存分配器凭借其常数时间操作和低碎片特性,成为实时系统的理想选择。完整实现大约500行C++代码,核心在于:
- 两级位图索引实现O(1)搜索。
- 块分割与合并减少碎片。
适用场景:
- 实时音视频处理(如FFmpeg中的内存管理)。
- 游戏引擎(需要稳定帧率)。
- 嵌入式系统(资源受限且需实时响应)。
- 任何对内存分配时间可预测性要求高的场景。
通过本文的详细解析和代码示例,读者可以自行实现TLSF内存分配器,并将其集成到自定义高性能系统中。
引用链接:
- TLSF开源实现(C语言)
- mmap(2) - Linux manual page
免责声明:本文代码示例基于Linux平台的内存映射(mmap),在其他平台需要调整。实际生产环境请充分测试。