内存池实现:固定大小分配器、slab分配器、tcmalloc原理、jemalloc特点
说到内存管理,我猜你肯定遇到过这种情况:程序跑着跑着,突然卡一下,或者内存碎片多得吓人。嗯,这就是我们今天要聊的内存池要解决的问题。
我个人习惯把内存池看作「内存的批发市场」。你想想看,如果每次要一块小内存都去操作系统那里申请,就像每次买瓶水都跑一趟工厂,效率能高吗?内存池就是先批发一大块,然后零售给程序用。
固定大小分配器
这是最朴素的内存池思路。说白了,就是预先分配一大块内存,切成大小相同的块,用链表串起来。
核心思想:把内存切成等大的「槽位」,用空闲链表管理。
class FixedAllocator {
struct Node {
Node* next;
};
Node* freeList;
char* pool;
size_t blockSize;
size_t blockCount;
public:
FixedAllocator(size_t size, size_t count)
: blockSize(size), blockCount(count) {
pool = new char[blockSize * blockCount];
freeList = reinterpret_cast<Node*>(pool);
Node* cur = freeList;
for (size_t i = 0; i < blockCount - 1; ++i) {
cur->next = reinterpret_cast<Node*>(pool + (i+1)*blockSize);
cur = cur->next;
}
cur->next = nullptr;
}
void* allocate() {
if (!freeList) return nullptr;
Node* p = freeList;
freeList = freeList->next;
return p;
}
void deallocate(void* p) {
Node* n = static_cast<Node*>(p);
n->next = freeList;
freeList = n;
}
};
我在项目中遇到过一个问题:用固定大小分配器管理网络包缓冲区,结果发现不同大小的包混在一起,浪费严重。所以这个方案只适合「所有对象大小一致」的场景。
适用场景:线程池的任务对象、网络连接对象、日志条目等固定大小的对象。
Slab 分配器
Slab 分配器是 Linux 内核里的经典设计。它解决了固定大小分配器的痛点——不同大小的对象怎么办?
Slab 的思路是:准备多个「尺寸类别」,每个类别管理一种固定大小的对象。比如 8 字节、16 字节、32 字节……一直到几 KB。
Slab 的三层结构:
- Cache:管理一种特定大小的对象
- Slab:一大块连续内存,切成多个对象
- Object:实际分配出去的小块内存
每个 Cache 里有三个链表:
- 完全空闲的 Slab:随时可以切分
- 部分使用的 Slab:还有空位
- 完全满的 Slab:等释放
分配时,先从「部分使用」的链表里找,没有就从「完全空闲」的拿一个。释放时,如果 Slab 变空了,就放回空闲链表。如果整个 Cache 的空闲 Slab 太多,就还给操作系统。
我曾经在一个嵌入式项目里手撸过 Slab 分配器。当时内存只有 64MB,用 Slab 管理后,碎片率从 15% 降到了 3% 以下。嗯,这个数字我记得很清楚。
注意:Slab 分配器对多线程支持不太好。每个 Cache 需要加锁,高并发下锁竞争很严重。
TCMalloc 原理
TCMalloc 是 Google 的杰作。它全称 Thread-Caching Malloc,核心就一个字——「快」。
怎么做到的?说白了就是「每个线程自己管一小块内存,不用抢锁」。
TCMalloc 有三层结构:
| 层级 | 名称 | 作用 |
|---|---|---|
| 第一层 | Thread Cache | 每个线程独享,无锁分配小对象 |
| 第二层 | Central Cache | 全局共享,线程 Cache 不够时从这里拿 |
| 第三层 | Page Heap | 管理大块内存,按页分配 |
分配流程是这样的:
- 先看线程自己的 Cache 有没有空闲块
- 有就直接拿,无锁操作,快得很
- 没有就去 Central Cache 批量拿一批
- Central Cache 也没有,就去 Page Heap 申请新页
释放时也是先放回线程 Cache。如果线程 Cache 里的空闲块太多了,就批量还给 Central Cache。
我记得有一次优化一个高并发服务,把 glibc malloc 换成 TCMalloc,QPS 直接提升了 30%。原因很简单——锁竞争没了。
小技巧:TCMalloc 对小于 256KB 的对象优化特别好。大对象它会直接走 Page Heap,性能优势就不明显了。
Jemalloc 特点
Jemalloc 是 FreeBSD 和 Firefox 的标配。它和 TCMalloc 思路类似,但更激进。
Jemalloc 的核心特点:
- Arena 机制:每个 CPU 核心一个 Arena,减少锁冲突
- 多级缓存:线程本地缓存 + Arena 缓存 + 全局缓存
- 内存碎片控制:通过「红黑树」管理空闲块,合并相邻空闲块
- 可扩展性:支持 NUMA 架构,优先分配本地内存
Jemalloc 最让我佩服的是它的「内存压缩」能力。长时间运行的服务,用 Jemalloc 内存占用会比 TCMalloc 低 10%-20%。代价是分配速度稍慢一点。
为什么会这样?因为 Jemalloc 在释放时会花更多精力去合并碎片。TCMalloc 更激进地缓存空闲块,所以分配快但内存占用高。
选型建议:
- 追求极致性能 → TCMalloc
- 追求低内存占用 → Jemalloc
- 通用场景 → 两者都可以,建议 Jemalloc
三种分配器对比
| 特性 | 固定大小分配器 | Slab 分配器 | TCMalloc | Jemalloc |
|---|---|---|---|---|
| 适用场景 | 单一大小对象 | 多种大小对象 | 通用高性能 | 通用低内存 |
| 多线程性能 | 差(需加锁) | 一般 | 优秀 | 优秀 |
| 内存碎片 | 无(大小固定) | 低 | 中等 | 低 |
| 实现复杂度 | 简单 | 中等 | 复杂 | 复杂 |
最后说一句:别盲目追求高级分配器。如果你的程序只是单线程跑几个对象,手写一个固定大小分配器就够用了。我见过有人为了「炫技」上了 TCMalloc,结果反而因为内存占用过高出了问题。
嗯,选工具要合适,不是越高级越好。