一、面试让你手写内存池,到底要写什么
如果面试官突然问:
手写一个简单的内存池。
一般并不是让我们重新实现一套完整的:
malloc
free
jemalloc
tcmalloc
而是希望看到是否理解内存池最基本的思想。
普通内存申请:
需要内存
↓
malloc
↓
使用
↓
free
如果大量小对象频繁创建和销毁,就会不断进行:
malloc → free → malloc → free ...
内存池的思路则是:
先申请一大块内存
↓
切成很多小块
↓
使用链表管理空闲块
↓
申请时取出一块
↓
释放时重新放回来
因此面试中一个简化内存池至少需要解决三个问题:
1. 怎么把大块内存切成小块
2. 怎么知道哪些块现在是空闲的
3. 申请和释放时怎么快速找到这些块
最常见的答案就是:
固定大小内存块
+
空闲链表 Free List
例如一次申请:
128字节
然后按照:
16字节
切分:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│Block1│Block2│Block3│Block4│Block5│Block6│Block7│Block8│
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
然后使用链表连接这些空闲块:
Block1
↓
Block2
↓
Block3
↓
Block4
↓
...
申请的时候:
从链表头取一个
释放的时候:
重新插回链表头
这样申请和释放都可以做到非常简单。
二、先设计一个最简单的内存池
为了突出面试核心,我们先实现:
固定大小内存块
而不是一开始就处理:
8字节
16字节
32字节
64字节
多个档位。
先定义:
class MemoryPool {
private:
struct FreeNode {
FreeNode *next;
};
void *pool_; // 整块内存起始地址
FreeNode *free_list_; // 空闲链表头
size_t block_size_; // 每个内存块大小
size_t block_count_; // 内存块数量
public:
MemoryPool(size_t blockSize, size_t blockCount);
~MemoryPool();
void *allocate();
void deallocate(void *ptr);
};
这里真正关键的是:
struct FreeNode {
FreeNode *next;
};
为什么每个空闲块只需要保存:
next
?
因为当一块内存:
正在被用户使用
时,里面应该全部交给用户。
但当它:
处于空闲状态
时,这块内存暂时没人使用。
所以完全可以拿这块内存最前面的几个字节保存:
下一个空闲块地址
也就是:
空闲状态:
┌───────────────┐
│ next │
│ │
│ 空闲空间 │
│ │
└───────────────┘
而分配出去以后:
使用状态:
┌───────────────┐
│ 用户数据 │
│ 用户数据 │
│ 用户数据 │
│ 用户数据 │
└───────────────┘
这也是很多内存池代码中会看到:
FreeNode *
甚至:
void **
这种写法的原因。
本质都是:
利用空闲内存块本身保存链表指针。
三、构造函数:申请内存并建立空闲链表
接下来真正初始化内存池。
MemoryPool::MemoryPool(size_t blockSize, size_t blockCount)
: pool_(nullptr), free_list_(nullptr), block_size_(blockSize), block_count_(blockCount) {
// 内存块至少要能保存一个指针
if (block_size_ < sizeof(FreeNode)) block_size_ = sizeof(FreeNode);
// 一次申请整块内存
pool_ = std::malloc(block_size_ * block_count_);
if (!pool_) throw std::bad_alloc();
char *start = static_cast<char *>(pool_);
// 将所有内存块连接成空闲链表
for (size_t i = 0; i < block_count_; ++i) {
FreeNode *node = reinterpret_cast<FreeNode *>(start + i * block_size_);
node->next = free_list_;
free_list_ = node;
}
}
这里最重要的一步是:
start + i * block_size_
假设:
block_size = 16
那么每个块的地址就是:
第0块:start + 0
第1块:start + 16
第2块:start + 32
第3块:start + 48
于是原来的一整块:
pool_
被逻辑切成:
┌────────┬────────┬────────┬────────┐
│ Block0 │ Block1 │ Block2 │ Block3 │
└────────┴────────┴────────┴────────┘
然后:
node->next = free_list_;
free_list_ = node;
使用头插法建立链表。
最终得到:
free_list_
↓
Block3
↓
Block2
↓
Block1
↓
Block0
↓
nullptr
这里并没有真的为每个节点重新:
new FreeNode
因为:
每一个Block本身
就是一个 FreeNode。
例如:
FreeNode *node = reinterpret_cast<FreeNode *>(start + i * block_size_);
实际上就是告诉编译器:
从这个地址开始,我现在暂时把这块空闲内存当成一个 FreeNode 使用。
这是手写内存池中非常关键的一个思想。
四、allocate和deallocate怎么实现
链表建立完成以后,申请内存就非常简单了。
1. allocate
void *MemoryPool::allocate() {
// 内存池已经没有空闲块
if (!free_list_) return nullptr;
// 取出链表头
FreeNode *node = free_list_;
// free_list指向下一个空闲块
free_list_ = free_list_->next;
// 返回当前块
return node;
}
假设原来的链表:
free_list_
↓
Block3
↓
Block2
↓
Block1
↓
Block0
执行:
void *p = pool.allocate();
实际上就是取出:
Block3
然后:
free_list_
↓
Block2
↓
Block1
↓
Block0
所以申请一个内存块实际上只进行了:
读取链表头
+
修改链表头
时间复杂度:
O(1)
2. deallocate
释放同样非常简单:
void MemoryPool::deallocate(void *ptr) {
if (!ptr) return;
FreeNode *node = static_cast<FreeNode *>(ptr);
// 重新插回空闲链表头
node->next = free_list_;
free_list_ = node;
}
假设现在:
free_list_
↓
Block2
↓
Block1
↓
Block0
然后释放:
Block3
执行:
node->next = free_list_;
得到:
Block3
↓
Block2
然后:
free_list_ = node;
最终:
free_list_
↓
Block3
↓
Block2
↓
Block1
↓
Block0
所以释放操作同样是:
O(1)
最后析构时统一释放整块内存:
MemoryPool::~MemoryPool() {
std::free(pool_);
}
完整代码整理如下:
#include <cstdlib>
#include <iostream>
#include <new>
class MemoryPool {
private:
struct FreeNode {
FreeNode *next;
};
void *pool_;
FreeNode *free_list_;
size_t block_size_;
size_t block_count_;
public:
MemoryPool(size_t blockSize, size_t blockCount)
: pool_(nullptr), free_list_(nullptr), block_size_(blockSize), block_count_(blockCount) {
// 每个空闲块至少要能够保存一个指针
if (block_size_ < sizeof(FreeNode)) block_size_ = sizeof(FreeNode);
// 一次申请整块内存
pool_ = std::malloc(block_size_ * block_count_);
if (!pool_) throw std::bad_alloc();
char *start = static_cast<char *>(pool_);
// 将每一个块加入空闲链表
for (size_t i = 0; i < block_count_; ++i) {
FreeNode *node = reinterpret_cast<FreeNode *>(start + i * block_size_);
node->next = free_list_;
free_list_ = node;
}
}
~MemoryPool() {
std::free(pool_);
}
void *allocate() {
if (!free_list_) return nullptr;
FreeNode *node = free_list_;
free_list_ = free_list_->next;
return node;
}
void deallocate(void *ptr) {
if (!ptr) return;
FreeNode *node = static_cast<FreeNode *>(ptr);
node->next = free_list_;
free_list_ = node;
}
};
int main() {
// 创建10个32字节内存块
MemoryPool pool(32, 10);
void *p1 = pool.allocate();
void *p2 = pool.allocate();
std::cout << "p1 = " << p1 << std::endl;
std::cout << "p2 = " << p2 << std::endl;
// 使用完成以后归还内存池
pool.deallocate(p1);
pool.deallocate(p2);
return 0;
}
整个过程可以总结为:
构造MemoryPool
↓
malloc一大块内存
↓
按照block_size切分
↓
建立free_list
↓
allocate
↓
从free_list头部取块
↓
用户使用
↓
deallocate
↓
重新插回free_list
五、面试最容易继续追问什么
如果能把上面的简版内存池写出来,面试官一般还会继续追问几个问题。
1. 为什么不用malloc/free?
不能回答:
因为malloc很慢
这种说法太绝对。
更合适的表达是:
在大量固定大小或者小对象频繁申请和释放的场景下,内存池可以提前批量申请内存,通过空闲链表进行复用,减少频繁进行通用动态内存分配的管理开销,同时提高内存局部性。
2. 为什么allocate和deallocate是O(1)?
因为两个操作本质都是修改:
链表头
申请:
node = free_list_;
free_list_ = free_list_->next;
释放:
node->next = free_list_;
free_list_ = node;
都没有遍历链表。
所以平均可以做到:
O(1)
3. 为什么block_size至少要大于一个指针?
因为空闲状态下需要在块里面保存:
FreeNode *next;
因此:
block_size_
至少必须满足:
block_size_ >= sizeof(void *);
否则连:
下一个空闲块地址
都放不进去。
4. 这个内存池有什么问题?
这个简化版本并不是工业级实现,还有很多问题:
只支持固定大小内存块
没有处理线程安全
没有检测重复释放
没有检测非法指针
没有自动扩容
没有处理更复杂的内存对齐
内存池耗尽后只能返回nullptr
如果需要支持不同大小,可以进一步设计:
8字节 FreeList
16字节 FreeList
32字节 FreeList
64字节 FreeList
128字节 FreeList
例如:
申请13字节
↓
进入16字节档位
申请:
27字节
↓
进入32字节档位
这就是:
size class
大小分类。
5. 多线程情况下怎么办?
现在:
allocate();
deallocate();
都会修改:
free_list_
如果多个线程同时操作:
线程A ─┐
├→ free_list_
线程B ─┘
就可能产生数据竞争。
最简单的解决方法就是:
std::mutex
在申请和释放时加锁。
更加复杂的实现还可以考虑:
线程本地缓存
无锁FreeList
CAS
分级内存池
不过一般面试让手写简版内存池时,先把:
大块内存
+
固定大小Block
+
Free List
+
O(1)申请释放
这几个核心点写出来就已经足够。
如果让我在面试中用几句话总结这个实现,我会这样说:
我实现的是一个固定大小块的简化内存池。构造时一次性申请一块连续内存,再按照固定 block size 划分成多个小块,并利用空闲块自身保存 next 指针组成 Free List。申请内存时直接从空闲链表头取出一个块,释放时再通过头插法放回,因此申请和释放都可以做到 O(1)。当前实现主要用于说明内存池核心原理,如果继续完善还可以增加多级 size class、内存对齐、线程安全以及自动扩容。
这段也是面试时比较适合直接表达的一套回答。