【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

相关推荐
EatFan1 小时前
Spring Boot 4 迁移避坑清单:Jackson 3、starter 拆分与最低 JDK 口径核对(含若依/芋道/CRMEB 升级对照)
java·数据库·spring boot·spring boot 4·java 21·jakarta ee 11·jackson 3
栖凤1 小时前
多 Agent 工作流实践:从单打独斗到协同作战
java·linux·服务器
派小心.1 小时前
页面关闭前埋点丢失:sendBeacon验收
开发语言·php
一木 之林1 小时前
DeepSeek Agent 开发
java·前端·人工智能
Java后端的Ai之路1 小时前
Python 进阶探索25 - difflib模块之文本对比
开发语言·python·django·哈希算法·difflib
Evand J1 小时前
【MATLAB例程】三维快速扩展随机树(RRT)路径规划与到达角(AOA)定位算法|三维避障+定位仿真。附下载链接
开发语言·算法·matlab·路径规划·定位
空堂与归1 小时前
GPT-6.1 Sol 来了:1/5 价逼近 Astra 级
开发语言·人工智能·gpt·ai
王霸天1 小时前
Three.js 模型体积优化:Draco/Meshopt 压缩与 DRACOLoader 配置的 4 个步骤
java·前端·javascript
程序员老陆1 小时前
C++ 悬空指针:从原理到工程级解决方案
开发语言·c++·程序设计