TCMalloc底层实现

文章目录

本人实现源码:https://gitee.com/xfuturejianchi/concurrent-memory-pool

储备知识

malloc就是内存池,不同编译器平台用的不同,gcc使用ptmalloc

操作系统会执行brk或者mmap来申请虚拟地址空间(一般不是特别大的空间都是brk)

为了优化 malloc,减少内存碎片,所以 google 开发了 tcmalloc 这个著名内存池

threadCache

FreeList 结构

cpp 复制代码
void* _freeList = nullptr;    ///< 指向空闲链表头部的指针
size_t _maxSize = 1;          ///< 当前可持有的最大内存块数量(动态调整)
size_t _size = 0;             ///< 当前链表中实际内存块数量

用于小于 256KB 内存分配,线程独享

申请内存:

  1. 判断空间是否超过 256KB
  2. 计算对齐后的大小和自由链表索引
cpp 复制代码
/**
 * @brief 向上对齐到指定粒度
 * 
 * 使用位运算实现高效对齐:
 * (bytes + alignNum - 1) & ~(alignNum - 1)
 * 
 * @param bytes 原始字节数
 * @param alignNum 对齐粒度(2的幂)
 * @return size_t 对齐后的字节数
 */
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
/**
 * @brief 根据不同范围使用不同对齐粒度进行向上对齐
 * 
 * @param size 原始大小
 * @return size_t 对齐后的大小
 * 
 * 对齐策略:
 * - size <= 128:     8字节对齐
 * - size <= 1024:    16字节对齐
 * - size <= 8KB:     128字节对齐
 * - size <= 64KB:    1024字节对齐
 * - size <= 256KB:   8KB对齐
 * - 超过256KB:       按页面对齐(8KB)
 */
static inline size_t RoundUp(size_t size)
{
    if (size <= 128) 
    {
        return _RoundUp(size, 8);
    }
    else if (size <= 1024)
    {
        return _RoundUp(size, 16);
    }
    else if (size <= 8*1024)
    {
        return _RoundUp(size, 128);
    }
    else if (size <= 64*1024)
    {
        return _RoundUp(size, 1024);
    }
    else if (size <= 256 * 1024)
    {
        return _RoundUp(size, 8*1024);
    }
    else
    {
        return _RoundUp(size, 1<<PAGE_SHIFT);
    }
}
cpp 复制代码
/**
 * @brief 计算对齐后的索引(内部函数)
 * 
 * @param bytes 字节数
 * @param align_shift 对齐的位数偏移
 * @return size_t 计算得到的索引
 * 
 * 计算公式:((bytes + 2^align_shift - 1) >> align_shift) - 1
 */
static inline size_t _Index(size_t bytes, size_t align_shift)
{
    return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}

/**
 * @brief 根据内存大小计算对应的自由链表索引
 * 
 * @param bytes 要分配的内存大小(不超过MAX_BYTES)
 * @return size_t 对应的自由链表索引
 * 
 * 该函数实现了复杂的索引映射逻辑,将不同范围的内存大小
 * 映射到208个不同的自由链表中
 */
static inline size_t Index(size_t bytes)
{
    assert(bytes <= MAX_BYTES);

    // 每个范围内的链表数量:16 + 56 + 56 + 56 + 24 = 208
    static int group_array[4] = { 16, 56, 56, 56 };
    if (bytes <= 128){
        return _Index(bytes, 3);  // 8字节对齐,索引范围 [0, 15]
    }
    else if (bytes <= 1024){
        // 减去前一个范围的起始点,加上前几个范围的总和
        return _Index(bytes - 128, 4) + group_array[0];  // [16, 71]
    }
    else if (bytes <= 8 * 1024){
        return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];  // [72, 127]
    }
    else if (bytes <= 64 * 1024){
        return _Index(bytes - 8 * 1024, 10) + group_array[2] + group_array[1] + group_array[0];  // [128, 183]
    }
    else if (bytes <= 256 * 1024){
        return _Index(bytes - 64 * 1024, 13) + group_array[3] + group_array[2] + group_array[1] + group_array[0];  // [184, 207]
    }
    else{
        assert(false);  // 不应该到这里
    }

    return -1;
}
  1. 判断是否对应 index 还有内存
  2. 如果没有,向 CentralCache 申请
cpp 复制代码
/**
 * @brief 计算从CentralCache一次批量获取的内存块数量
 * 
 * 批量获取策略:
 * - 最少2个,最多512个
 * - 计算公式:MAX_BYTES / size
 * 
 * @param size 单个内存块的大小
 * @return size_t 批量获取的数量
 */
static size_t NumMoveSize(size_t size)
{
    assert(size > 0);

    // 计算批量大小,确保有足够的内存复用价值
    int num = MAX_BYTES / size;
    if (num < 2)
        num = 2;  // 最少2个

    if (num > 512)
        num = 512;  // 最多512个

    return num;
}
size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
  1. 动态调整阈值
cpp 复制代码
// 动态调整:每次成功申请后增加阈值
if (_freeLists[index].MaxSize() == batchNum)
{
    _freeLists[index].MaxSize() += 1;
}
  1. 调用 FetchRangeObj 向 CentralCache 申请内存
cpp 复制代码
/**
 * @brief 批量从CentralCache获取内存块
 *
 * 该函数是CentralCache向ThreadCache提供内存的主要接口。
 * 它从一个Span中获取指定数量的内存块,供ThreadCache使用。
 *
 * 批量获取的优势:
 * - 减少锁获取次数
 * - 提高内存局部性
 * - 降低CentralCache的访问频率
 *
 * @param start 输出参数,获取的内存块链表起始位置
 * @param end 输出参数,获取的内存块链表结束位置
 * @param batchNum 期望获取的内存块数量
 * @param size 单个内存块的大小
 * @return size_t 实际获取的内存块数量(可能小于batchNum)
 */
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
    // 第一步:计算对应的SpanList索引并加锁
    size_t index = SizeClass::Index(size);
    _spanLists[index]._mtx.lock();

    // 第二步:获取一个有空闲内存的Span
    Span* span = GetOneSpan(_spanLists[index], size);
    assert(span);               // Span必须存在
    assert(span->_freeList);    // Span必须有空闲内存

    // 第三步:从Span的_freeList中取出batchNum个内存块
    // start指向取出的第一个内存块
    start = span->_freeList;
    end = start;
    size_t i = 0;
    size_t actualNum = 1;  // 至少取出1个

    // 遍历链表,取出最多batchNum个节点
    // 注意:循环条件是 i < batchNum - 1,因为end已经指向第一个节点
    while (i < batchNum - 1 && NextObj(end) != nullptr)
    {
        end = NextObj(end);
        ++i;
        ++actualNum;
    }

    // 更新Span的状态
    span->_freeList = NextObj(end);  // Span的新空闲链表头
    NextObj(end) = nullptr;          // 取出的链表的尾部置空
    span->_useCount += actualNum;    // 增加使用计数

    // 第四步:释放锁并返回
    _spanLists[index]._mtx.unlock();

    return actualNum;
}
  • 根据下标找到 span 加锁,然后 GetOneSpan、
  • GetOneSpan 的策略:
    • 根据 SpanList 一个个遍历 Span,如果 Span 的 _freeList 为空就接着遍历下一个 Span
    • 如果这个 SpanList 的所有 Span 都没有空间,对 central 对应的 SpanList 解锁,开始调用 NewSpan(SizeClass::NumMovePage(size)) 向 PageCache 申请内存
    • 标记 NewSpan 返回的 Span 为正在使用,同时记录该 Span 的内存块的大小(Span 的 _freeList 分割的内存块的大小)
    • 对这个 Span 初始化------根据页面号进行偏移来获取实际地址,通过 _n 成员得知分得的页数,然后就可以得知 end 地址,开始根据内存块大小进行切分
    • 重新对 centralCache 对应的 SpanList 加锁,把 span 头插,并且作为返回值返回
cpp 复制代码
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    // 第一步:遍历SpanList,查找_freeList非空的Span
    // 这种"先查找缓存"的策略可以减少向PageCache申请的次数
    Span* it = list.Begin();
    while (it != list.End())
    {
        if (it->_freeList != nullptr)
        {
            // 找到有空闲内存块的Span,直接返回
            return it;
        }
        else
        {
            // 该Span已被完全分配,继续查找下一个
            it = it->_next;
        }
    }

    // 第二步:没有找到可用的Span,需要向PageCache申请
    // 重要:释放当前锁,避免与其他线程死锁
    // CentralCache的锁是按大小分类的,PageCache是全局锁
    list._mtx.unlock();

    // 获取PageCache全局锁,申请指定页数的Span
    PageCache::GetInstance()->_pageMtx.lock();
    Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
    span->_isUse = true;      // 标记Span为使用中
    span->_objSize = size;    // 记录该Span管理的内存块大小
    PageCache::GetInstance()->_pageMtx.unlock();

    // 第三步:将Span切分成等大小的内存块
    // 内存布局:Span包含N个页面,每个页面8KB
    //          Span被切分成 M = (N * 8KB) / size 个内存块

    // 计算Span的起始地址和总大小
    char* start = (char*)(span->_pageId << PAGE_SHIFT);  // 页面号转换为实际地址
    size_t bytes = span->_n << PAGE_SHIFT;              // 总字节数 = 页数 * 8KB
    char* end = start + bytes;                           // 结束地址

    // 切分Span的内存,构建自由链表
    // 1. 初始化:第一个内存块作为链表头
    span->_freeList = start;
    start += size;               // 移动到下一个内存块位置
    void* tail = span->_freeList; // tail指向链表尾部
    int i = 1;

    // 2. 循环切分:构建链表
    while (start < end)
    {
        ++i;
        // 将前一个节点指向当前节点
        NextObj(tail) = start;
        // tail移动到当前节点
        tail = NextObj(tail);
        // start移动到下一个内存块
        start += size;
    }

    // 3. 链表尾部置空
    NextObj(tail) = nullptr;

    // 第四步:将Span插入SpanList头部
    // 重新获取锁
    list._mtx.lock();
    list.PushFront(span);

    return span;
}
  • 如果实际对应 Span 只有一个内存节点就直接返回,否则放入对应哈希桶的自由链表中(start 给用户)
  • 记得把 Span 的 _freeList 和 _userCount 更新,然后对对应 SpanList 解锁
  1. FetchRangeObj 返回实际获得的内存块,还有内存块的 start end。就可以更新 _freeLists[index] 了

释放内存

传参 size(内存块大小) 和 void*

  1. 判断空间是否超过 256KB
  2. 计算自由链表索引
  3. 向对应 _freeList Push ptr
  4. 归还后,判断对应 _freeList Size 是否超过允许拥有的 MaxSize
  5. 超过,就执行ListTooLong(_freeLists[index], size); size 表示内存块大小
  • ListTooLong 策略:
    • 将自由链表中一半的内存(按MaxSize计算)归还给CentralCache。
  • 优势:
    • 减少 ThreadCache 内存占用
    • 让内存可以在不同线程间复用
cpp 复制代码
/**
 * @brief 将过多的内存归还给CentralCache
 * 
 * 该函数将自由链表中一半的内存(按MaxSize计算)归还给CentralCache。
 * 这样可以:
 * 1. 减少ThreadCache的内存占用
 * 2. 让内存可以在不同线程间复用
 * 3. 避免内存碎片化
 * 
 * @param list 自由链表引用
 * @param size 内存块大小
 */
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
    void* start = nullptr;
    void* end = nullptr;
    
    // 取出当前最大数量的内存块
    list.PopRange(start, end, list.MaxSize());
    // 把end->next = nullptr,所以后续调用可以直接遍历start,不需要传参end

    // 将这些内存块归还给CentralCache
    CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

CentralCache::ReleaseListToSpans(void* start, size_t size)策略:

  • 计算索引,对对应 SpanList 加锁
  • 遍历 start
  • 调用PageCache::MapObjectToSpan 找到对应的 span 应该在哪
  • 更新 span: 把 start 插入到对应 span,更新 span _useCount,如果 _useCount == 0,开始归还 pagecache 合并
    • 从 Central 对应 SpanList 移除该 span
    • 释放 SpanList 锁
    • 开始申请 PageCache 锁执行ReleaseSpanToPageCache,然后释放 PageCahe 锁
      • 大于128 page的直接还给堆
      • 对span前后的页,尝试进行合并,缓解内存碎片问题(根据 Span->pageId 寻找)
      • 合并出超过128页的span没办法管理,不合并了
      • 合并后给 idSpanMap
    • 申请 SpanList 锁
  • 释放 SpanList 锁

PageCache::MapObjectToSpan(void*) 策略:根据 start 找到内存块对应页,根据页找到从哪个 span 获取的,返回 Span*

centralCache

cpp 复制代码
struct Span
{
    PAGE_ID _pageId = 0;     ///< 起始页面ID(页面号 × 8KB = 实际起始地址)
    size_t  _n = 0;           ///< 该Span包含的页面数量

    Span* _next = nullptr;   ///< 双向链表的下一个Span
    Span* _prev = nullptr;   ///< 双向链表的上一个Span

    size_t _objSize = 0;     ///< 该Span中分割的单个内存块大小
    size_t _useCount = 0;    ///< 当前已被分配出去的内存块数量(用于判断是否可以回收)
    void* _freeList = nullptr;  ///< 空闲内存块链表的头指针

    bool _isUse = false;     ///< Span是否正在被使用
};

根据合适时机回收 threadCache

多线程取,所以会需要加桶锁

向 PageCache 申请内存

cpp 复制代码
// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
    assert(k > 0);

    // 大于128 page的直接向堆申请
    if (k > NPAGES-1)
    {
        void* ptr = SystemAlloc(k);
        //Span* span = new Span;
        Span* span = _spanPool.New();

        span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
        span->_n = k;

        //_idSpanMap[span->_pageId] = span;
        _idSpanMap.set(span->_pageId, span);

        return span;
    }

    // 先检查第k个桶里面有没有span
    if (!_spanLists[k].Empty())
    {
        Span* kSpan = _spanLists[k].PopFront();

        // 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
        for (PAGE_ID i = 0; i < kSpan->_n; ++i)
        {
            //_idSpanMap[kSpan->_pageId + i] = kSpan;
            _idSpanMap.set(kSpan->_pageId + i, kSpan);
        }

        return kSpan;
    }

    // 检查一下后面的桶里面有没有span,如果有可以把他它进行切分
    for (size_t i = k+1; i < NPAGES; ++i)
    {
        if (!_spanLists[i].Empty())
        {
            Span* nSpan = _spanLists[i].PopFront();
            //Span* kSpan = new Span;
            Span* kSpan = _spanPool.New();

            // 在nSpan的头部切一个k页下来
            // k页span返回
            // nSpan再挂到对应映射的位置
            kSpan->_pageId = nSpan->_pageId;
            kSpan->_n = k;

            nSpan->_pageId += k;
            nSpan->_n -= k;

            _spanLists[nSpan->_n].PushFront(nSpan);
            // 存储nSpan的首位页号跟nSpan映射,方便page cache回收内存时
            // 进行的合并查找
            //_idSpanMap[nSpan->_pageId] = nSpan;
            //_idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;
            _idSpanMap.set(nSpan->_pageId, nSpan);
            _idSpanMap.set(nSpan->_pageId + nSpan->_n - 1, nSpan);

            // 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
            for (PAGE_ID i = 0; i < kSpan->_n; ++i)
            {
                //_idSpanMap[kSpan->_pageId + i] = kSpan;
                _idSpanMap.set(kSpan->_pageId + i, kSpan);
            }

            return kSpan;
        }
    }

    // 走到这个位置就说明后面没有大页的span了
    // 这时就去找堆要一个128页的span
    //Span* bigSpan = new Span;
    Span* bigSpan = _spanPool.New();
    void* ptr = SystemAlloc(NPAGES - 1);
    bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
    bigSpan->_n = NPAGES - 1;

    _spanLists[bigSpan->_n].PushFront(bigSpan);

    return NewSpan(k);
}

向 PageCache 释放内存

根据计算 ThreadCache 的空间点太长,调用函数取出来一部分节点来给 CentralCache 回收。通过桶下标定位到 centralCache 对应 Span 的 spanList 位置,然后通过基数树找到对应 Span 进行回收

pageCache

以页为单位存储,centralCache 没有内存对象时,从 pageCache 分配一定 page 切割,分配给 centralCache 。当 Span 的几个跨度页对象都回收后,pageCache 会回收 centralCache 满足套件的 Span 对象,并且合并相邻的页缓解内存碎片问题。

_idSpanMap

使用基数树快速完成内存块向 Span 的映射

原理:

  • 使用内存块的地址可以确定内存块所在页号
  • _idSpanMap 标明页号和 Span 的映射关系
  • 使用 _idSpanMap 来映射页号

对比 hash:

  • 使用 hash 在并发访问时需要加锁
  • 如果不加锁会因操作节点导致 hash 结构发生变化,并发访问出现问题
  • 基数树没有这个问题

使用基数树而不是 vector:

  • 优化空间:
    • 在 32 位下,一页 8KB,一共有 32 - (3 + 10) = 16,2^16 张页,一页我们使用一个指针指向,那么就需要 2^16*4 = 2^18 = 0.25MB 存放地址
    • 在 64 位下,一共有 64 - (3 + 10) = 48,2^48 张页,一页我们使用一个指针指向,那么就需要 2^48*8 = 2^51 = 2048TB 存放地址
  • 所以为了适配 64 位,我们应该使用基数树来映射,比如基数树第一层表示前 15bit,第二层表示 15 bit ,第三次表示 21bit 然后按需开辟空间,而不是一次开辟完全。一次减少指针的内存占用

NewSpan给 CentralCache 内存

  • 判断如果内存大于 128page,直接找堆要
  • 检查第 k 个桶里面有没有 span
    • 如果有就申请,并且通过 _idSpanMap 映射页号和 span
    • 如果没有,进行切分
    • 再没有,向堆申请 128page span
cpp 复制代码
/**
 * @brief SpanList数组
 *
 * 129个SpanList,按页数索引:
 * - _spanLists[1]:管理1页Span
 * - _spanLists[2]:管理2页Span
 * - ...
 * - _spanLists[128]:管理128页Span
 * - _spanLists[128+]:大于128页的Span
 */
SpanList _spanLists[NPAGES];
/**
 * @brief Span对象的内存池
 *
 * 使用ObjectPool预分配Span对象,避免频繁的new/delete
 */
ObjectPool<Span> _spanPool;

/**
 * @brief 页号到Span的映射表
 *
 * 使用TCMalloc风格的一级页映射:
 * - 键:页号(PAGE_ID)
 * - 值:Span指针
 *
 * 映射范围:32 - PAGE_SHIFT = 19位,可映射512MB地址空间
 * 页号 = 地址 >> 13,因此可以映射 2^(19+13) = 2^32 = 4GB 虚拟地址空间
 *
 * 映射策略:
 * - 每个Span的起始页和结束页都建立映射
 * - 方便快速查找任意地址所属的Span
 */
//std::unordered_map<PAGE_ID, Span*> _idSpanMap;  // 备用方案:使用unordered_map
//std::map<PAGE_ID, Span*> _idSpanMap;            // 备用方案:使用std::map
TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;   // 当前方案:使用TCMalloc风格页映射

MapObjectToSpan

通过地址找到 Span

  • 通过地址找到起始页
  • 通过起始页根据_idSpanMap找到 Span

ReleaseSpanToPageCache

通过传入 Span 完成 Span 合并

  • 通过 Span 记录的页号寻找前后的 Span 然后合并后更新 _idSpanMap

SizeClass

  • 加上 alignNum - 1 确保任何起始位置都能"跨越"到下一个对齐边界
cpp 复制代码
/**
 * @brief 向上对齐到指定粒度
 * 
 * 使用位运算实现高效对齐:
 * (bytes + alignNum - 1) & ~(alignNum - 1)
 * 
 * @param bytes 原始字节数
 * @param alignNum 对齐粒度(2的幂)
 * @return size_t 对齐后的字节数
 */
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    return ((bytes + alignNum - 1) & ~(alignNum - 1));
}
/**
 * @brief 根据不同范围使用不同对齐粒度进行向上对齐
 * 
 * @param size 原始大小
 * @return size_t 对齐后的大小
 * 
 * 对齐策略:
 * - size <= 128:     8字节对齐
 * - size <= 1024:    16字节对齐
 * - size <= 8KB:     128字节对齐
 * - size <= 64KB:    1024字节对齐
 * - size <= 256KB:   8KB对齐
 * - 超过256KB:       按页面对齐(8KB)
 */
static inline size_t RoundUp(size_t size)
{
    if (size <= 128)
    {
        return _RoundUp(size, 8);
    }
    else if (size <= 1024)
    {
        return _RoundUp(size, 16);
    }
    else if (size <= 8*1024)
    {
        return _RoundUp(size, 128);
    }
    else if (size <= 64*1024)
    {
        return _RoundUp(size, 1024);
    }
    else if (size <= 256 * 1024)
    {
        return _RoundUp(size, 8*1024);
    }
    else
    {
        return _RoundUp(size, 1<<PAGE_SHIFT);
    }
}

扩展以及参考文章

代替 malloc

不同平台替换⽅式不同。 基于unix的系统上的glibc,使⽤了weak alias的⽅式替换。具体来说是

因为这些⼊⼝函数都被定义成了weak symbols,再加上gcc⽀持 alias attribute,所以替换就变

成了这种通⽤形式:

void* malloc(size_t size) THROW attribute__ ((alias (tc_malloc)))

因此所有malloc的调⽤都跳转到了tc_malloc的实现

具体参考这⾥:GCC attribute 之weak,alias属性_gcc weak alias-CSDN博客

有些平台不⽀持这样的东西,需要使⽤hook的钩⼦技术来做。

malloc 底层相关文章以及其他内存池

https://zhuanlan.zhihu.com/p/384022573

malloc()背后的实现原理------内存池 - 阿照的日志

malloc的底层实现(ptmalloc)-CSDN博客

Hook技术 - zzfx - 博客园

内存优化总结:ptmalloc、tcmalloc和jemalloc_jemalloc是什么-CSDN博客

TCMALLOC 源码阅读-CSDN博客

https://www.zhihu.com/question/25527491/answer/1851688957

相关推荐
逍遥德1 小时前
如何学编程之01.理论篇.如何通过阅读代码来提高自己的编程能力?
前端·后端·程序人生·重构·软件构建·代码规范
wangjialelele2 小时前
平衡二叉搜索树:AVL树和红黑树
java·c语言·开发语言·数据结构·c++·算法·深度优先
m0_481147332 小时前
拦截器跟过滤器的区别?拦截器需要注册吗?过滤器需要注册吗?
java
Coder_Boy_2 小时前
基于SpringAI的在线考试系统-相关技术栈(分布式场景下事件机制)
java·spring boot·分布式·ddd
独自破碎E2 小时前
【BISHI15】小红的夹吃棋
android·java·开发语言
冻感糕人~2 小时前
【珍藏必备】ReAct框架实战指南:从零开始构建AI智能体,让大模型学会思考与行动
java·前端·人工智能·react.js·大模型·就业·大模型学习
程序员agions2 小时前
2026年,“配置工程师“终于死绝了
前端·程序人生
啦啦啦_99992 小时前
Redis实例-2
java
alice--小文子2 小时前
cursor-mcp工具使用
java·服务器·前端