【C/C++面试】手写内存池:从空闲链表到内存申请与释放

一、面试让你手写内存池,到底要写什么

如果面试官突然问:

手写一个简单的内存池。

一般并不是让我们重新实现一套完整的:

复制代码
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、内存对齐、线程安全以及自动扩容。

这段也是面试时比较适合直接表达的一套回答。

0voice · GitHub

相关推荐
昵称画1 小时前
交接班记录电子化:三班之间的信息不漏
开发语言·数据库·人工智能·低代码·php
“AI国潮设计-小江”2 小时前
【AIGC实战】Python+SDXL批量生成“潮汕英歌舞不倒翁”IP | 角色一致性与多场景融合方案,附完整代码
开发语言·人工智能·python·prompt·aigc
传奇开心果编程2 小时前
【Rust入门练中学】第6课:结构体与方法
开发语言·rust·结构体·编程语言理论·结构体方法·编程通用语言
code tsunami2 小时前
【无标题】
开发语言·python
缓慢更新2 小时前
审批的加签与会签:多人意见怎么合
android·java·数据库·低代码·rxjava
CodeGL2 小时前
10、Python基础之进程和线程
开发语言·python
yuniko-n3 小时前
【JUC】volatile 非原子性深入解析
java
TechWayfarer3 小时前
PHP漏洞被利用,怎么揪出攻击IP?
开发语言·python·tcp/ip·网络安全·php
sun_weitao4 小时前
极简版 MCP 工具实现方案
java·linux·服务器
金銀銅鐵5 小时前
[Java] 展示增删元素时 LinkedList 的内部变化
java·后端·ai编程