C++ 内存池什么的

By | 2015-08-01

最近因为某些原因鼓捣了好久内存池, 姑且记录一下

之前对内存池的了解只停留在表面, 咋看一下好像没啥, 做起来各种头疼, 总结一下大概有几种实现方式:


  • 只分配, 不释放, 而是在内存池销毁时全部释放, 元素定长或不定长均可

    这种最简单了, rapidxml 中用的类似方法, 适合于有明确作用域的情形, 而且通常仅适用于 POD 类型,
    因为不需要保存额外信息, 所以效率非常好 (当然局限性也很大)

  • 定长 POD 内存池

    这种也比较简单, 由于 POD 类型所以没有指针的多态变化, 地址查找算法也比较简单高效, 找到所属 buffer, 除法算出内存块索引即可

  • 不定长 POD 或对象内存池

    这个就很复杂了, 主要问题在于 poolDelete 时如何高效率的查找到所属的内存块, 具体实现方式还没深究, 大体都是用双向链表


扯皮完了目前了解到的皮毛, 尝试着自己实现了一份, 大概设计思路:


  • 内存池维护一个 "已分配" 和 "未分配" 的 buffer 队列
  • buffer 本身不定长, 分别包含 32, 64, 128, 256, … 字节的内存块序列, 序列中使用双向链表保存数据
  • 每个对象占用一个内存块
  • 创建对象时直接 malloc (内存块大小 + 头信息 * 2) (乘2 用于内存对齐, 下文提到), 然后链接到 "已分配" 列表中
  • 销毁对象时, 使用特殊的查找算法 (下文提到) 找到所属内存块, 删除对象后将该内存块缓存回 "未分配" 中

数据结构:

class ObjectData {
    byte *basePtr; // malloc 得到的内存
    byte *objectPtr; // 扣除 header 以及内存对齐后的对象地址
    ObjectTypeEnum objectType; // POD 或者 Object 等类型
    int headerVerify; // 关键的校验位, 下文提到
    ObjectDeleteCallback objectDeleteCallback; // Object 需要销毁回调
    ObjectData *prevData; // 双向链表
    ObjectData *nextData;
};
class MemPool {
    // 分别包含 32, 64, 128, 256, ... 字节的内存块,
    // 比如 used[0] 保存大小不超过 32 字节的对象, 等等
    ObjectData used[BLOCK_COUNT];
    ObjectData unused[BLOCK_COUNT];
};

其他没啥好说的, 简单的双向链表插入和删除等等, 最关键的问题在于, 给一个 void * 指针, 如何找到所属的内存块

解释做法之前, 先说明下这点的蛋疼之处, 先看看代码:

    ChildType *child = new ChildType();
    BaseType *base = child;

大家都很熟的 C++ 多态, 不过似乎很少人注意过 base 指针到底做了啥事, 以 %p 或者 %08X 打印一下指针就知道了,
base 指针相对于 child 会有一定的偏移量, 具体原因扯到 C++ 内存布局相关的, 就不解释了

当这一机制挪到内存池中, 杯具就来了, 比如, 当这个偏移量大于你的内存块单位大小时, 你的任何高效的寻址算法就歇菜了,
当然你可以额外添加很多头信息或处理方式来解决这一问题, 但是, 效率又下去了


好了, 现在来说明下脑洞大开的实现方式, 玄妙在于 malloc 时的内存对齐以及用作校验的 headerVerify:

  • 通常内存池中内存块的内存布局为 (头信息 + 对象内存), 但这里新增一条要求, 即头信息的起始地址必须与 sizeof(ObjectData) 对齐

    大体算法是: (为清晰明了, 部分强制转换代码未写)

    byte *basePtr = malloc(sizeof(ObjectData) * 2 + object_size);
    ObjectData *objectData = basePtr + sizeof(ObjectData) - (basePtr % sizeof(ObjectData));
  • 有了上述保证, 给定一个指针, 即可快速找到所属内存块:

    byte *checkingPtr = p - (p % sizeof(ObjectData));
    while(!isObjectDataValid(checkingPtr)) {
        checkingPtr -= sizeof(ObjectData);
    }
  • 怎样判断 checkingPtr 是否是要找的内存块呢? 这时就要用到之前提到的 headerVerify 来校验这段内存是否是有效的内存块头信息了

    这个做法就很多了, 为了效率, 将 basePtr, objectPtr 等与内存地址有关且不会变动的内容,
    用与运算和或运算做校验, 理论上不容易出现冲突的情况


差不多就这么回事, 比起传统意义上的内存池, 倒是更像 cache 或对象池

简单测试性能提升大约只有 1.5 倍左右, 极端情况下可能比不用内存池性能还差 2 倍左右 (比如一直创建不销毁)

总结一下:

  • 优点什么的就是使用灵活, 任意类型任意长度通吃且不用担心对象析构的问题, 做些小技巧甚至能支持 protected/private 构造函数的对象

  • 缺点什么的也蛮多: 内存浪费略严重 (每个内存块要额外占用 (头信息*2) 字节的大小), 对内存碎片也没啥太大帮助,
    而且当 delete 一个无效指针的时候, 可能因为头信息校验过程中的非法内存访问而直接造成死机, 连输出警告的机会都没有

  • 总的来说, 个人还是建议使用定长/固定类型的内存池, 没有必要去弄这么复杂的不定长/不定类型的内存池, 复杂度太高, 性能提升却并不明显

转载请注明来自: http://zsaber.com/blog/p/52

既然都来了, 有啥想法顺便留个言呗? (无奈小广告太多, 需审核, 见谅)

Category: C++

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注