空间配置器:C++ STL 的内存管家

聊到 STL,很多人第一反应是 vector、map 这些容器。但很少有人想过一个问题:容器里的对象,到底是怎么分配内存的?

嗯,这就是空间配置器(allocator)干的事。说白了,它就是 STL 的内存管家。我刚开始用 STL 时,根本没注意过它的存在。直到有一次,我在一个嵌入式项目里频繁创建和销毁小对象,发现性能不对劲……这才回头好好研究了一下这个“幕后英雄”。

空间配置器是什么?

空间配置器,英文叫 allocator。它的职责很简单:为容器分配和释放内存

每个 STL 容器都有一个默认的 allocator 参数。比如 vector 的声明:

template<typename T, typename Alloc = std::allocator<T>>
class vector;

你平时写 std::vector<int>,其实背后用的是 std::allocator<int>。这个默认配置器,就是 SGI STL 实现的那套经典方案。

我个人习惯把空间配置器理解为“内存批发商”。它从系统拿一大块内存,然后按需分给容器。这样做的好处很明显——减少系统调用,提升性能。

一级空间配置器:直接跟系统打交道

一级空间配置器,说白了就是 malloc/free 的简单封装

它的逻辑很直接:

  • 分配内存时,调用 malloc
  • 释放内存时,调用 free
  • 如果 malloc 失败,尝试调用用户设置的 new_handler,再试一次

代码大概长这样:

class __malloc_alloc_template {
public:
    static void* allocate(size_t n) {
        void* result = malloc(n);
        if (0 == result) {
            // 内存不足,尝试调用处理函数
            result = oom_malloc(n);
        }
        return result;
    }

    static void deallocate(void* p, size_t /*n*/) {
        free(p);
    }

private:
    static void* oom_malloc(size_t n) {
        void (*my_malloc_handler)();
        while (true) {
            my_malloc_handler = __malloc_alloc_oom_handler;
            if (0 == my_malloc_handler) {
                throw std::bad_alloc();
            }
            (*my_malloc_handler)();
            void* result = malloc(n);
            if (result) return result;
        }
    }
};

你看,一级配置器其实没什么花哨的。它适合分配大块内存。为什么?因为大块内存用 malloc 直接管理,碎片问题不严重。

注意:一级配置器没有内存池,每次分配都走系统调用。如果你频繁分配小块内存,性能会很难看。

二级空间配置器:内存池的妙用

这才是 STL 空间配置器的精髓所在。

二级空间配置器专门处理小块内存的分配。它维护了一个内存池,把释放的小块内存缓存起来,下次分配时直接复用。

为什么会这样设计?你想想看,STL 容器里经常存的是 int、char、小结构体。这些对象很小,如果每次都 malloc/free,开销太大了。我在项目中遇到过,一个服务每秒创建和销毁数万个小型对象,用默认配置器性能直接崩了。

二级配置器的核心思路:

  • 维护 16 个自由链表(free list),分别管理 8、16、24……128 字节的内存块
  • 分配时,从对应链表取一块
  • 释放时,把内存块放回链表
  • 如果链表为空,从内存池批量申请

示意图如下:

二级空间配置器结构 自由链表数组 (16个) 8B 16B 24B ...... 128B 内存池 (Memory Pool) 一次性从系统申请大块内存,按需切分 分配:从自由链表取 → 链表为空 → 从内存池批量申请 释放流程 用户释放 → 根据大小找到对应自由链表 → 插入链表头部 判断逻辑 大于128字节 → 一级配置器 | 小于等于128字节 → 二级配置器

二级配置器的核心代码:

class __default_alloc_template {
private:
    // 自由链表,16个
    static obj* free_list[16];
    // 内存池起始和结束
    static char* start_free;
    static char* end_free;
    static size_t heap_size;

    // 根据字节数找到对应的自由链表索引
    static size_t FREELIST_INDEX(size_t bytes) {
        return (bytes + ALIGN - 1) / ALIGN - 1;
    }

    // 将字节数对齐到8的倍数
    static size_t ROUND_UP(size_t bytes) {
        return (bytes + ALIGN - 1) & ~(ALIGN - 1);
    }

public:
    static void* allocate(size_t n) {
        if (n > MAX_BYTES) {
            // 大块内存,交给一级配置器
            return malloc_alloc::allocate(n);
        }

        // 小块内存,从自由链表取
        obj** my_free_list = free_list + FREELIST_INDEX(n);
        obj* result = *my_free_list;

        if (result == nullptr) {
            // 链表为空,从内存池批量申请
            return refill(ROUND_UP(n));
        }

        *my_free_list = result->next;
        return result;
    }

    static void deallocate(void* p, size_t n) {
        if (n > MAX_BYTES) {
            // 大块内存,还给系统
            malloc_alloc::deallocate(p, n);
            return;
        }

        // 小块内存,插回自由链表
        obj** my_free_list = free_list + FREELIST_INDEX(n);
        obj* q = (obj*)p;
        q->next = *my_free_list;
        *my_free_list = q;
    }
};
关键点:二级配置器只处理 ≤128 字节的内存块。超过这个大小,直接丢给一级配置器。这个阈值是 SGI STL 定的,你也可以自己调整。

内存池技术:为什么这么快?

内存池的核心思想就一句话:一次申请,多次复用

传统 malloc 每次分配都要走系统调用,进入内核态,开销很大。而内存池提前从系统申请一大块内存,然后自己管理。

具体来说:

  • 批量申请:当自由链表为空时,一次申请 20 块内存(默认值)
  • 零碎片:所有小块内存大小都是 8 的倍数,不会产生外部碎片
  • 常数时间操作:分配和释放都是 O(1),只是链表指针操作

我曾经在一个高并发服务里做过测试:用默认的 STL 分配器,每秒能处理 5 万次小对象分配;换成自己手写的内存池后,这个数字飙到了 80 万。差距就是这么明显。

小技巧:如果你在写游戏服务器、高频交易系统这类对性能敏感的应用,可以考虑自定义 allocator。STL 允许你传入自己的分配器,这是 C++ 留给你的后门。

避坑指南

我曾经踩过一个坑:在析构函数里释放了内存,但没有通知配置器。结果内存池里的指针全成了野指针,程序跑着跑着就崩了。

记住几个原则:

  • 谁分配,谁释放:用配置器分配的内存,一定要用同一个配置器释放
  • 不要混用:别在 new/delete 和 allocator 之间混着用,会出大问题
  • 线程安全:SGI 的二级配置器不是线程安全的。多线程环境下,要么加锁,要么用线程局部存储

总结

空间配置器是 STL 里最容易被忽略,但又最重要的组件之一。它用一级配置器处理大块内存,用二级配置器配合内存池处理小块内存,既保证了性能,又控制了碎片。

我个人建议,如果你要深入理解 C++ 的性能特性,一定要把空间配置器搞透。它不只是一个分配器,更是一种内存管理思想——把频繁的小操作批量处理,用空间换时间。这个思路在很多场景下都适用。


公众号:蓝海资料掘金营,微信deep3321