【项目设计】C++ 高并发内存池

文章目录

  • [1. 项目介绍](#1. 项目介绍)
    • [1.1 这个项目做的是什么?](#1.1 这个项目做的是什么?)
    • [1.2 项目所需的技术储备](#1.2 项目所需的技术储备)
  • [2. 什么是内存池](#2. 什么是内存池)
    • [2.1 池化技术(Pooling)](#2.1 池化技术(Pooling))
    • [2.2 内存池(Memory Pool)](#2.2 内存池(Memory Pool))
    • [2.3 内存池主要解决什么问题?](#2.3 内存池主要解决什么问题?)
    • [2.4 malloc 本质上也是一种内存池](#2.4 malloc 本质上也是一种内存池)
  • [3. 先设计一个定长的内存池](#3. 先设计一个定长的内存池)
    • [3.1 设计目标](#3.1 设计目标)
    • [3.2 向堆申请空间和释放空间](#3.2 向堆申请空间和释放空间)
    • [3.3 整体结构](#3.3 整体结构)
    • [3.4 申请一个对象的流程](#3.4 申请一个对象的流程)
    • [3.5 释放一个对象的流程](#3.5 释放一个对象的流程)
    • [3.6 测试代码与性能对比](#3.6 测试代码与性能对比)
  • [4. 高并发内存池整体框架设计](#4. 高并发内存池整体框架设计)
  • [5. Thread Cache](#5. Thread Cache)
    • [5.1 Thread Cache 整体设计](#5.1 Thread Cache 整体设计)
    • [5.2 ThreadCache 哈希桶映射与对齐规则](#5.2 ThreadCache 哈希桶映射与对齐规则)
      • [5.2.1 如何进行对齐](#5.2.1 如何进行对齐)
      • [5.2.2 空间浪费率分析](#5.2.2 空间浪费率分析)
      • [5.2.3 对齐与映射相关函数的实现](#5.2.3 对齐与映射相关函数的实现)
      • [5.2.4 ThreadCache 类定义](#5.2.4 ThreadCache 类定义)
    • [5.3 ThreadCache 的 TLS 无锁访问](#5.3 ThreadCache 的 TLS 无锁访问)
  • [6. Central Cache](#6. Central Cache)
    • [6.1 CentralCache 整体设计](#6.1 CentralCache 整体设计)
      • [6.1.1 CentralCache 与 ThreadCache 的不同点](#6.1.1 CentralCache 与 ThreadCache 的不同点)
    • [6.2 CentralCache 结构设计](#6.2 CentralCache 结构设计)
      • [6.2.1 页号(PageId)的类型](#6.2.1 页号(PageId)的类型)
      • [6.2.2 Span 的结构](#6.2.2 Span 的结构)
      • [6.2.3 双链表结构封装](#6.2.3 双链表结构封装)
      • [6.2.4 CentralCache 的整体结构](#6.2.4 CentralCache 的整体结构)
    • [6.3 CentralCache 的核心实现](#6.3 CentralCache 的核心实现)
      • [6.3.1 CentralCache 的实现方式(单例模式)](#6.3.1 CentralCache 的实现方式(单例模式))
      • [6.3.2 慢开始反馈调节算法](#6.3.2 慢开始反馈调节算法)
      • [6.3.3 ThreadCache 从 CentralCache 获取对象](#6.3.3 ThreadCache 从 CentralCache 获取对象)
      • [6.3.4 CentralCache 从某个桶中取出一批对象](#6.3.4 CentralCache 从某个桶中取出一批对象)
      • [6.3.5 将一段对象链挂回 FreeList](#6.3.5 将一段对象链挂回 FreeList)
  • [7. PageCache](#7. PageCache)
    • [7.1 PageCache 整体设计](#7.1 PageCache 整体设计)
      • [7.1.1 PageCache 与 CentralCache 的相同点](#7.1.1 PageCache 与 CentralCache 的相同点)
      • [7.1.2 PageCache 与 CentralCache 的不同点](#7.1.2 PageCache 与 CentralCache 的不同点)
      • [7.1.3 在 PageCache 中获取一个 n 页的 Span 的过程](#7.1.3 在 PageCache 中获取一个 n 页的 Span 的过程)
      • [7.1.4 PageCache 的实现方式(锁与单例模式)](#7.1.4 PageCache 的实现方式(锁与单例模式))
    • [7.2 PageCache 中获取 Span](#7.2 PageCache 中获取 Span)
      • [7.2.1 CentralCache 获取一个 "非空 Span" 的过程](#7.2.1 CentralCache 获取一个 “非空 Span” 的过程)
        • [7.2.1.1 根据对象大小,计算 CentralCache 向 PageCache 申请的页数](#7.2.1.1 根据对象大小,计算 CentralCache 向 PageCache 申请的页数)
        • [7.2.1.2 CentralCache::GetOneSpan 的完整流程](#7.2.1.2 CentralCache::GetOneSpan 的完整流程)
      • [7.2.2 PageCache::NewSpan ------ 获取一个 k 页的 Span](#7.2.2 PageCache::NewSpan —— 获取一个 k 页的 Span)
  • [8. ThreadCache 回收内存](#8. ThreadCache 回收内存)
  • [9. CentralCache 回收内存(小对象回收)](#9. CentralCache 回收内存(小对象回收))
    • [9.1 通过对象地址计算页号](#9.1 通过对象地址计算页号)
    • [9.2 通过页号找到对应 Span(页号 → Span 映射)](#9.2 通过页号找到对应 Span(页号 → Span 映射))
    • [9.3 CentralCache 回收小对象到 Span](#9.3 CentralCache 回收小对象到 Span)
  • [10. PageCache 回收 Span(大块内存回收与合并)](#10. PageCache 回收 Span(大块内存回收与合并))
    • [10.1 PageCache 前后页合并策略](#10.1 PageCache 前后页合并策略)
  • [11. 申请内存 和 释放内存 过程联调](#11. 申请内存 和 释放内存 过程联调)
  • [12. 大于 256KB 的大块内存申请问题](#12. 大于 256KB 的大块内存申请问题)
    • [12.1 大块内存的申请过程](#12.1 大块内存的申请过程)
      • [12.1.1 ConcurrentAlloc 支持大块内存](#12.1.1 ConcurrentAlloc 支持大块内存)
      • [12.1.2 PageCache::NewSpan 对大页数申请的改造](#12.1.2 PageCache::NewSpan 对大页数申请的改造)
    • [12.2 大块内存的释放过程](#12.2 大块内存的释放过程)
      • [12.2.1 PageCache 回收大块 Span](#12.2.1 PageCache 回收大块 Span)
      • [12.2.2 SystemFree 封装说明](#12.2.2 SystemFree 封装说明)
  • [13. 使用定长内存池配合脱离使用 new](#13. 使用定长内存池配合脱离使用 new)
    • [13.1 使用定长内存池管理 Span 对象](#13.1 使用定长内存池管理 Span 对象)
    • [13.2 使用定长内存池管理 ThreadCache 对象](#13.2 使用定长内存池管理 ThreadCache 对象)
    • [13.3 SpanList 头结点也用定长内存池](#13.3 SpanList 头结点也用定长内存池)
  • [14. 释放对象时优化为 "不传 size"](#14. 释放对象时优化为 “不传 size”)
    • [14.1 为什么一开始释放需要传 size](#14.1 为什么一开始释放需要传 size)
    • [14.2 在 Span 上记录对象大小 _objSize](#14.2 在 Span 上记录对象大小 _objSize)
    • [14.3 释放时不再需要传 size](#14.3 释放时不再需要传 size)
    • [14.4 在 ObjectPool<T> 中修复多线程竞态问题](#14.4 在 ObjectPool<T> 中修复多线程竞态问题)
  • [15. 多线程并发环境下,对比 malloc 和 ConcurrentAlloc 的效率](#15. 多线程并发环境下,对比 malloc 和 ConcurrentAlloc 的效率)
    • [15.1 固定大小内存的申请与释放](#15.1 固定大小内存的申请与释放)
    • [15.2 不同大小内存的申请与释放](#15.2 不同大小内存的申请与释放)
  • [16. 性能瓶颈分析](#16. 性能瓶颈分析)
  • [17. 针对性能瓶颈使用单层基数树进行优化](#17. 针对性能瓶颈使用单层基数树进行优化)
  • [18. 使用基数树优化 PageCache 中的页号映射](#18. 使用基数树优化 PageCache 中的页号映射)
    • [18.1 PageCache 结构修改](#18.1 PageCache 结构修改)
    • [18.2 将所有 _idSpanMap 操作替换为基数树](#18.2 将所有 _idSpanMap 操作替换为基数树)
  • [19. 基数树优化前后性能和内存对比](#19. 基数树优化前后性能和内存对比)
    • [19.1 固定大小内存的申请与释放](#19.1 固定大小内存的申请与释放)
    • [19.2 不同大小内存的申请与释放](#19.2 不同大小内存的申请与释放)
  • [20. 项目源码](#20. 项目源码)

1. 项目介绍

1.1 这个项目做的是什么?

这个项目的目标是:参考 Google 开源的内存分配器 tcmalloc(Thread-Caching Malloc),实现一个简化版的高并发内存池,用来替代传统的 malloc/free,在多线程场景下降低锁竞争、减少内存碎片、提升分配/释放性能。

tcmalloc 的核心思想是:

  • 为每个线程维护线程本地缓存(Thread Cache),减少跨线程的锁竞争
  • 按大小划分不同的 size class,对小块内存进行批量申请和批量回收
  • 后端有一个全局 Central Cache / Page Heap 做统一管理和大块分配

在这个项目中,我不是直接使用 tcmalloc,而是把它的核心框架抽象出来自己实现一遍:

  • 简化了 tcmalloc 的实现,保留了 "线程缓存 + 中央缓存 + 页堆" 的核心架构
  • 自己实现了内存块按 size class 分类管理、小对象批量分配、线程本地缓存回收等逻辑
  • 通过多线程压测,对比系统 malloc / free 与自实现内存池在高并发下的性能差异(吞吐、延迟、锁竞争情况)

项目的主要目的不是造一个生产可用的分配器,而是系统性地学习 tcmalloc 的设计思想和实现细节。因为 tcmalloc 是 Google 的工业级内存分配器,在 C++ 社区和大厂中使用非常广泛,也被 Go 语言作为内存分配器的基础之一。

1.2 项目所需的技术储备

为了实现一个高并发内存池,需要掌握以下几类核心基础知识:

  • C/C++ 基础与内存操作:指针、数组、内存分配 / 释放、基本内存对齐概念等;
  • 常见数据结构:链表、哈希表等;
  • 多线程与并发:线程的基本使用、互斥锁和线程安全、简单的线程局部缓存等;
  • 操作系统知识:Page与堆内存、大块内存与小块内存的区别;
  • 简单设计思想:单例模式;

2. 什么是内存池

2.1 池化技术(Pooling)

"池化技术" 是一种常见的性能优化方法,其核心思想是:

  • 程序提前向系统申请一批资源,并自行管理这些资源,以避免在使用过程中频繁与操作系统交互,从而提升效率。

之所以要提前申请,是因为:

  • 每一次向操作系统申请资源都会产生较大开销;
  • 在高并发场景下,这种开销会被放大;
  • 提前准备好,后续使用时就能非常快速,整体性能也会显著提高;

池化技术广泛应用于各类系统组件中,例如:

  • 线程池:提前创建多个线程,任务到来时直接复用已有线程
  • 连接池:提前建立数据库连接,避免频繁创建与销毁
  • 对象池:复用对象,降低频繁创建对象的开销
  • 内存池:提前申请一大块内存,将其分拆成小块反复使用

以线程池为例:

  • 程序启动时先创建若干线程,处于休眠状态
  • 当客户端请求到来时,唤醒池中的某个线程处理请求
  • 请求处理完毕后,该线程重新回到池中等待下一次任务

池化的本质就是【提前准备 + 复用资源 + 减少系统调用】。

2.2 内存池(Memory Pool)

内存池是将池化思想应用于内存管理中的一种技术。

其核心流程是:

  • 程序启动时,从操作系统一次性申请较大的一块内存。
  • 后续程序需要内存时,不再调用 malloc,而是直接从内存池中获取;
  • 释放时也不是交还给操作系统,而是归还到内存池中。

最终只有在程序退出或在某个特定阶段,内存池才会将整个大块内存一次性释放给系统。

这样做的优势包括:

  • 避免频繁调用系统级 malloc / free(这些调用代价非常大)
  • 提高小对象 / 频繁分配的性能
  • 能更好地管理内存碎片
  • 为高并发场景降低锁竞争

2.3 内存池主要解决什么问题?

内存池主要解决两个方面的问题:

1️⃣ 内存申请与释放的性能问题

频繁的系统级 malloc / free 代价很大,尤其是:

  • 多线程情况下涉及全局锁
  • 内核的内存管理步骤复杂
  • 小对象频繁申请会造成大量性能浪费

通过内存池,可以让分配和释放变得极快。

2️⃣ 内存碎片问题

内存碎片分为两类:外部碎片 和 内部碎片。

外部碎片:

  • 可用内存总量足够;
  • 但空闲块之间不连续;
  • 无法满足一次性的大块分配;

内存池通过统一管理、批量分配 / 回收,能够减少外部碎片。

外部碎片如下图所示:

内部碎片:

  • 程序请求 17 字节,但分配器给了 32 字节;
  • 多出部分无法被使用,造成浪费;

为了对齐与性能,内部碎片在内存池中是无法完全避免的,但可以通过合理的 size class 设计将其降到最低。

2.4 malloc 本质上也是一种内存池

在 C/C++ 中我们使用 malloc() 动态申请内存,但实际上:

  • malloc 自身就实现了一个 "内存池 + 内存管理器"。

程序不是每次调用 malloc 都立即向系统申请小块内存,而是:

  • malloc 底层一次性向操作系统批量申请较大的区域;
  • 然后把这些区域 "零售" 给应用程序;
  • 当空间不足时,再向系统 "进货";

不同平台 malloc 的实现也不同,例如:

  • Linux glibc 使用 ptmalloc
  • Windows 使用微软自研的分配器
  • Google 的 tcmalloc、jemalloc 等库本质上都是替代 glibc malloc 的高级内存池实现

如下图所示:

3. 先设计一个定长的内存池

在进入复杂的高并发内存池框架之前,我们先实现一个简单版本的定长内存池(ObjectPool),一方面当作热身,熟悉【池化 + 自由链表 + 大块分配】的基本套路;另一方面,这个定长内存池在后面的整体内存管理体系中,也可以作为一个基础组件复用。

如下图所示:

3.1 设计目标

这个定长内存池( ObjectPool<T> )的目标很简单:

  • 专门为某一种类型 T 提供内存分配/释放(定长对象)
  • 申请内存时:
    • 优先复用已经释放回来的对象(通过自由链表管理)
    • 不够用时,从操作系统一次性申请一大块内存,再按对象大小切分使用;
  • 释放内存时:
    • 不直接 free 给系统;
    • 而是挂回自由链表,下次复用;

相比直接 new/delete,这种方式可以显著减少系统级分配次数,提升频繁创建/销毁对象时的性能。

3.2 向堆申请空间和释放空间

既然是内存池,那么我们首先得向系统申请一块内存空间,然后对其进行管理。要想直接向堆申请内存空间,在 Windows 下,可以调用 VirtualAlloc 函数;在 Linux 下,可以调用 brk 或 mmap 函数。

cpp 复制代码
#ifdef _WIN32
	#include <Windows.h>
#else
	//...
#endif

//直接去堆上申请按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
	void* ptr = VirtualAlloc(0, kpage<<13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	// linux下brk mmap等
#endif
	if (ptr == nullptr)
		throw std::bad_alloc();
	return ptr;
}

// 释放从堆上申请的空间
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
	VirtualFree(ptr, 0, MEM_RELEASE);
#else
	// sbrk unmmap等
#endif
}

这里我们可以通过条件编译将对应平台下向堆申请内存的函数进行封装,此后我们就不必再关心当前所在平台,当我们需要直接向堆申请内存时直接调用我们封装后的 SystemAlloc 函数即可。

3.3 整体结构

核心类定义如下:

cpp 复制代码
// 定长内存池
template<class T>
class ObjectPool
{
public:
    T* New();
    void Delete(T* obj);

private:
    char* _memory = nullptr;    // 指向当前大块内存中尚未使用的起始位置
    int _remainBytes = 0;       // 当前大块内存中剩余的字节数
    void* _freeList = nullptr;  // 管理"回收对象"的自由链表头指针
};

它内部维护了三块关键信息:

  • _memory:当前 "大块内存" 的游标,指向还没切出去使用的内存起始位置;
  • _remainBytes:这块大内存里还剩多少字节可供切分;
  • _freeList:一个单向链表,存放 "已经释放回来的对象" 的地址,用于下次复用;

如下图所示:

3.4 申请一个对象的流程

核心代码:

cpp 复制代码
T* New()
{
    T* obj = nullptr;

    // 1. 如果自由链表中有空闲对象,直接拿
    if (_freeList != nullptr)
    {
        void* next = *((void**)_freeList); // 取下一个结点
        obj = (T*)_freeList;
        _freeList = next;
    }
    else  // 2. 否则从大块内存中"切"一块
    {
        // 剩余内存不够一个对象大小时,重新向系统申请一大块
        if (_remainBytes < sizeof(T))
        {
            _remainBytes = 128 * 1024;
            //_memory = (char*)malloc(_remainBytes);
            _memory = (char*)SystemAlloc(_remainBytes >> 13);
            if (_memory == nullptr)
            {
                throw std::bad_alloc();
            }
        }

        // 剩余内存足够一个对象大小,从当前游标处切一块出来
        obj = (T*)_memory;
        size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
        _memory += objSize;
        _remainBytes -= objSize;
    }

    // 3. 使用"定位 new"调用 T 的构造函数
    new(obj)T;
    return obj;
}

可以分 3 步理解:

1️⃣ 优先从自由链表中复用对象

  • _freeList 维护的是一条单链表,链表中的每个节点其实就是一个【已经被释放过的 T 对象内存块】;
  • 如果 _freeList != nullptr,说明有可复用的对象,那么:
    • 把当前 _freeList 作为返回对象地址;
    • 再从对象头部读出【下一个节点】的指针,更新 _freeList 头指针;

如下图所示:

思考:为什么要把内存块最开头的那 4 字节拿来存 next?

void* 在 32 位平台下是 4 字节,64 位平台下是 8 字节;

*((void**)obj) = _freeList 是利用对象内存的起始位置存放【下一节点指针】,这样就不用额外的结构体去表示链表节点。

核心原理:因为 free 之后,这块内存已经不是一个对象,它只是 "原始内存"。原始内存没有字段,没有结构,没有布局。我们必须找一个固定、通用、不依赖对象结构的位置放 next,而唯一安全的位置就是开头那几个字节。剩下所有字节在 free 状态下全部无意义,下次 New 会重写它们。

2️⃣ 自由链表为空时,从大块内存中切分

如果没有可复用的对象,就从 _memory 所指向的大块内存里【切】一块给 T 使用:

  • 如果 _remainBytes < sizeof(T),说明这一大块快用完了:
    • 向系统申请新的 128KB 内存:malloc(128 * 1024)
    • 重置 _memory_remainBytes
  • 计算单个对象的占用大小:
cpp 复制代码
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);

这一步是为了保证至少能存下一个指针大小(为了后面把对象头部当链表节点来用)。

  • 使用完后,移动 _memory 游标、减少 _remainBytes,类似【挨个切砖】的过程;

如下图所示:

3️⃣ 调用构造函数:定位 new(placement new)

cpp 复制代码
new(obj)T;

这一步不会重新分配内存,只是在 obj 指向的那块已经准备好的内存上,调用 T 的构造函数,这样就完成了【构造 + 初始化】

3.5 释放一个对象的流程

核心代码:

cpp 复制代码
void Delete(T* obj)
{
    // 1. 显式调用 T 的析构函数
    obj->~T();

    // 2. 头插到自由链表
    *((void**)obj) = _freeList;	// 把当前 freeList 写到 obj 头部
    _freeList = obj;						// 头插到 freeList
}

释放过程分两步:

1️⃣ 显式调用析构函数

  • 因为对象是通过 placement new 构造的,内存本身不是通过 operator delete 释放的;
  • 所以只需要手动调用析构函数做资源清理(比如释放内部成员、关闭句柄等)

2️⃣ 把该对象的这块内存挂回到 _freeList 链表头

  • 把当前 _freeList 写入到 obj 所指向内存的开头位置(当作 next 指针)
  • 然后 _freeList = obj,完成单链表头插;

如下图所示:

下次调用 New() 时,就可以优先从 _freeList 中取出这个对象复用。

3.6 测试代码与性能对比

测试代码如下:

cpp 复制代码
// 二叉树结点
struct TreeNode
{
	int _val;
	TreeNode* _left;
	TreeNode* _right;

	TreeNode()
		: _val(0), _left(nullptr), _right(nullptr)
	{}
};

void TestObjectPool()
{
	// 申请释放的轮次
	const size_t Rounds = 3;

	// 每轮申请释放多少次
	const size_t N = 100000;

	std::vector<TreeNode*> v1;
	v1.reserve(N);

	// 调用系统的
	size_t begin1 = clock();
	for (size_t i = 0; i < Rounds; ++i)
	{
		for (int j = 0; j < N; ++j)
		{
			v1.push_back(new TreeNode);
		}
		for (int j = 0; j < N; ++j)
		{
			delete v1[j];
		}
		v1.clear();
	}
	size_t end1 = clock();

	ObjectPool<TreeNode> TNPool;
	std::vector<TreeNode*> v2;
	v2.reserve(N);

	size_t begin2 = clock();
	for (int i = 0; i < Rounds; ++i)
	{
		for (int j = 0; j < N; ++j)
		{
			v2.push_back(TNPool.New());
		}
		for (int j = 0; j < N; ++j)
		{
			TNPool.Delete(v2[j]);
		}
		v2.clear();
	}
	size_t end2 = clock();

	// 测试速度
	cout << "new cost time:" << end1 - begin1 << endl;
	cout << "object pool cost time:" << end2 - begin2 << endl;
}

测试代码大致流程是:定义轮次 Rounds = 3,每轮申请 / 释放 N = 100000 个 TreeNode

分别用两种方式做测试:

  • 直接使用 new TreeNode / delete
  • 使用 ObjectPool<TreeNode>::New() / Delete()

通过 clock() 统计总耗时:

cpp 复制代码
cout << "new cost time:" << end1 - begin1 << endl;
cout << "object pool cost time:" << end2 - begin2 << endl;

通常情况下,你会看到:ObjectPool 的耗时明显低于系统 new / delete,并且随着 Rounds、N 增大,优势更明显。

这是因为:

  • 系统 new / delete 底层涉及更复杂的通用分配器逻辑、锁竞争、元数据管理;
  • 我们的 ObjectPool 专门针对【定长对象 + 频繁申请 / 释放】场景做了简化和优化;

4. 高并发内存池整体框架设计

现代计算机系统普遍采用多核多线程架构,内存分配在高并发场景下不可避免地会出现激烈的锁竞争。虽然 malloc 本身已十分优秀,但在多线程环境中,tcmalloc 通过分层缓存和减少全局锁竞争的方式,表现更为出色。本项目所实现的高并发内存池,正是基于 tcmalloc 的思想进行简化与复刻。

在设计并发内存池时,需要重点关注以下三个方面:

  • 性能问题:如何提高高频、小对象分配释放的吞吐量。
  • 锁竞争问题:多线程同时申请内存时如何减少全局锁开销。
  • 内存碎片问题:如何通过分级管理、批量分配与页合并来降低内外碎片。

整个 concurrent memory pool(高并发内存池)由以下三个核心组件构成:

1️⃣ Thread Cache(线程缓存)

  • Thread Cache 是 每个线程独享的本地缓存层,用于处理 小于 256KB 的小对象分配。
  • 在 Thread Cache 中分配内存 无需加锁,这是内存池在多线程场景下能够高效运行的关键点。
  • 每个线程都有一个独立的 cache,因此绝大多数的小对象分配请求都在本线程内完成,不会与其他线程产生竞争。

2️⃣ Central Cache(中央缓存)

  • Central Cache 是所有线程共享的全局缓存层,负责将 Span(定长小块)批量分配给各个 Thread Cache。
  • Thread Cache 在自身 freeList 为空时,才会向 Central Cache 获取对象;在 freeList 积累过多时,会将部分对象归还至 Central Cache。
  • 为了减少锁冲突,Central Cache 采用 桶锁(分段锁) 的方式对不同 size class 的链表加锁。
  • 由于 Thread Cache 绝大部分时间都能满足小对象分配需求,因此真正访问 Central Cache 的频率并不高,锁竞争相对较低。
  • Central Cache 的核心目标:为多个线程按需提供内存对象,并保证线程间的内存使用更加均衡。

3️⃣ Page Cache(页缓存)

  • Page Cache 管理的是更底层、更大颗粒度的内存区域,以 "页(page)" 为单位进行存储和分配。
  • 当 Central Cache 缺少小对象时,会向 Page Cache 申请一定数量的页(形成 Span),然后切割成定长的小块供 Central Cache 使用。
  • 当某个 Span 内的所有小块都被 Thread Cache 释放后,该 Span 会被归还到 Page Cache。
  • Page Cache 会在合适的时机 自动合并相邻的空闲页,从而重新形成更大的页块,用以缓解外部碎片问题,提高大块内存的连续性。

5. Thread Cache

5.1 Thread Cache 整体设计

定长内存池只支持 "固定大小" 的内存块申请和释放,所以只需要一个自由链表来管理被释放的内存块。

而在高并发内存池中,我们要支持多种不同大小的内存块,那么就必须为不同大小维护多条自由链表。因此,ThreadCache 本质上是一个 "哈希桶结构":每个桶管理一种对齐后的尺寸,对应一条自由链表。

ThreadCache 负责处理 小于等于 256KB 的内存申请。如果我们为 "每一个字节数" 都单独维护一条自由链表,那从 1Byte 到 256KB 一共要维护 20 多万条链表,光是存这些头指针就会消耗非常可观的内存,明显不划算。

为此,我们需要做一点 "空间换时间" 的折中,让不同的字节数按照一定规则向上对齐,复用同一条自由链表。比如:

  • 1~8 字节的申请,都对齐到 8 字节,由同一条链表管理;
  • 9~16 字节的申请,都对齐到 16 字节,以此类推。

当线程申请某个大小的内存时,流程如下:

  • 根据原始字节数进行对齐,得到对齐后的大小;
  • 根据对齐后的大小,计算出对应的哈希桶下标;
  • 如果该桶对应的自由链表非空:从链表头部弹出一个内存块返回;
  • 如果该自由链表为空:向下一层的 CentralCache 申请一批该尺寸的小块。

如下图所示:

这种对齐必然会引入一定的内部碎片。例如线程实际只申请 6 字节,而 ThreadCache 返回的却是 8 字节,多出的 2 字节无法利用,这部分就属于内部碎片。不过只要对齐策略设计合理,整体浪费是可控的。

由于项目整体复杂度较高,自由链表这个结构我们会单独封装成一个 FreeList 类。当前只提供两个最核心的接口:

  • Push:将对象头插进自由链表;
  • Pop:从自由链表头部弹出一个对象。

代码如下:

cpp 复制代码
// 获取内存对象中存储的头4 or 8字节值,即链接的下一个对象的地址
static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

// 管理小对象的自由链表
class FreeList
{
public:
	// 插入(来了一个对象的时候,需要插入到自由链表中)
	void Push(void* obj)
	{
		// obj不能为空
		assert(obj);

		// 头插: 把这个对象的前4字节(64位平台下就是8字节)作为【next】指针
		//*((void**)obj) = _freeList;
		NextObj(obj) = _freeList;
		_freeList = obj;
	}

	// 从链表头部取出一个对象,然后返回地址,并从链表头部移除
	void* Pop()
	{
		// 链表不能为空
		assert(_freeList);

		// 头删
		void* obj = _freeList;
		_freeList = NextObj(obj);

		return obj;
	}

private:
    void* _freeList = nullptr; // 自由链表头指针
};

因此,ThreadCache 实际上就是一个数组,数组的每个元素是一个 FreeList。数组里到底需要多少个自由链表,完全取决于我们为不同字节数制定的 映射 / 对齐规则。

5.2 ThreadCache 哈希桶映射与对齐规则

5.2.1 如何进行对齐

前面已经说明,不可能为每个字节数都单独维护一条自由链表 ------ 开销太大。因此,我们需要制定一套合理的映射与对齐规则,在 "桶数量" 和 "内碎片浪费" 之间做平衡。

首先,这些内存块最终会挂在自由链表上。为了保证链表指针能安全存下,无论在 32 位还是 64 位平台,我们都必须保证:

  • 每块小对象的大小 至少能容纳一个指针。

因此最自然的起点是:按 8 字节对齐。

但如果 1Byte ~ 256KB 全部统一按 8 字节对齐:

  • 需要的桶数 = 256 × 1024 ÷ 8 = 32768
  • 这个数量仍然偏大,不够"经济"。

注意:

KB(千字节)是计算机存储单位,1 KB = 1024 Byte(字节)= 2^10 Byte

于是,我们采用分段对齐的策略:不同范围的字节数采用不同的对齐粒度,具体如下:

字节数范围 对齐数 哈希桶下标范围
[1, 128] 8 [0, 16)
[129, 1024] 16 [16, 72)
[1025, 8×1024] 128 [72, 128)
[8×1024+1, 64×1024] 1024 [128, 184)
[64×1024+1, 256×1024] 8×1024 [184, 208)

例如:

复制代码
- 1 到 128 字节,按 8 字节对齐,一共 16 个桶(128 / 8 = 16)
- 129 到 1024 字节,按 16 字节对齐,一共 56 个桶((1024-128) / 16 = 56)

这样可以在桶数量和内碎片浪费率之间取得一个比较好的平衡。

我们拿 129 到 1024 字节,按 16 字节对齐,为例子:

对齐为什么会产生浪费?

为什么最大浪费是 15 字节?

那为什么最小对齐值是 144?

5.2.2 空间浪费率分析

对齐带来的内部碎片不可避免,但我们希望控制其浪费比例。按照上述对齐规则,整体浪费可以控制在 约 10% 左右。

注意:

1 ~ 128 区间我们不单独讨论,因为就算对齐到 2 字节,1 字节也会有 50% 的浪费,这个区间本身波动较大。下面从第二个区间开始分析。

浪费率 = 浪费的字节数 / 对齐后的字节数 浪费率 = 浪费的字节数 / 对齐后的字节数 浪费率=浪费的字节数/对齐后的字节数

想要得到某个区间的 "最大浪费率",就要让:

  • 分子(浪费字节数)尽量大;
  • 分母(对齐后字节数)尽量小。

129 ~ 1024 区间为例:

项目 解释
对齐单位 16 字节 固定规则
最大浪费 15 字节 等于 16 - 1
最小对齐后的值 144 字节 129 对齐后得到
最大浪费率 15 / 144 ≈ 10.42% 这是区间的最差浪费情况

同理,后面两个区间的最大浪费率为:

  • 127 ÷ 1152 ≈ 11.02%
  • 1023 ÷ 9216 ≈ 11.10%

可见整体在 10% ~ 11% 区间内,是一个可以接受的折中。

5.2.3 对齐与映射相关函数的实现

有了对齐规则之后,我们需要提供两个函数:

  • 给定字节数,得到 向上对齐后的大小
  • 给定字节数,得到其在 ThreadCache 中对应的 哈希桶下标

这两个操作我们统一封装到 SizeClass 类中:

cpp 复制代码
// 管理对齐和映射等关系
class SizeClass
{
public:
    // 获取向上对齐后的字节数
    static inline size_t RoundUp(size_t bytes);

    // 获取对应哈希桶的下标
    static inline size_t Index(size_t bytes);
};

注意:

  • 这些函数会被频繁调用,因此设计成 静态成员函数 + 内联函数 更合适;
  • 静态函数不依赖对象实例,调用开销更低,也避免每次都创建 SizeClass 对象。

RoundUp:向上对齐函数

cpp 复制代码
// 获取向上对齐后的字节数
static inline size_t RoundUp(size_t bytes)
{
    if (bytes <= 128)
    {
        return _RoundUp(bytes, 8);
    }
    else if (bytes <= 1024)
    {
        return _RoundUp(bytes, 16);
    }
    else if (bytes <= 8 * 1024)
    {
        return _RoundUp(bytes, 128);
    }
    else if (bytes <= 64 * 1024)
    {
        return _RoundUp(bytes, 1024);
    }
    else if (bytes <= 256 * 1024)
    {
        return _RoundUp(bytes, 8 * 1024);
    }
    else
    {
        assert(false);
        return (size_t)-1;
    }
}

_RoundUp 子函数负责执行具体对齐逻辑。

一般写法:

cpp 复制代码
// 一般写法
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    size_t alignSize = 0;
    if (bytes % alignNum != 0)
    {
        alignSize = (bytes / alignNum + 1) * alignNum;
    }
    else
    {
        alignSize = bytes;
    }
    return alignSize;
}

位运算写法(更高效):

cpp 复制代码
// 位运算写法
static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    return ((bytes + alignNum - 1) & ~(alignNum - 1));
}

解释示例(bytes = 8,alignNum = 8):

Index:映射到哈希桶下标

Index 用于将某个字节数映射到 FreeList 数组中的下标:

cpp 复制代码
// 获取对应哈希桶的下标
static inline size_t Index(size_t bytes)
{
    // 每个区间有多少个自由链表
    static size_t groupArray[4] = { 16, 56, 56, 56 };

    if (bytes <= 128)
    {
        return _Index(bytes, 3);
    }
    else if (bytes <= 1024)
    {
        return _Index(bytes - 128, 4) + groupArray[0];
    }
    else if (bytes <= 8 * 1024)
    {
        return _Index(bytes - 1024, 7) + groupArray[0] + groupArray[1];
    }
    else if (bytes <= 64 * 1024)
    {
        return _Index(bytes - 8 * 1024, 10) + groupArray[0] + groupArray[1] + groupArray[2];
    }
    else if (bytes <= 256 * 1024)
    {
        return _Index(bytes - 64 * 1024, 13) + groupArray[0] + groupArray[1] + groupArray[2] + groupArray[3];
    }
    else
    {
        assert(false);
        return (size_t)-1;
    }
}

其中 _Index 负责按对齐粒度计算 "第几个桶"。

cpp 复制代码
// 一般写法
size_t _Index(size_t bytes, size_t alignNum)
{
    // 例如:bytes = 7, alignNum = 8
    // 7 % 8 = 1,1 - 1 = 0,落在第 0 号桶
    if (bytes % alignNum == 0)
    {
        return bytes / alignNum - 1;
    }
    else
    {
        return bytes / alignNum;
    }
}

位运算写法(传入的是对齐的 "指数"):

cpp 复制代码
// 位运算写法
static inline size_t _Index(size_t bytes, size_t alignShift)
{
    return ((bytes + (1 << alignShift) - 1) >> alignShift) - 1;
}

说明:

  • alignShift 是对齐数的指数,例如:对齐数 8 = 2³,则 alignShift = 3
  • 左移一位相当于乘以 2,右移一位相当于除以 2(向下取整)

示例:bytes = 7, alignShift = 3

复制代码
1 << 3 = 8
8 - 1 = 7
7 + 7 = 14
14 >> 3 = 14 / 8 = 1
1 - 1 = 0  → 落在第 0 号桶

对于大区间(如 128+1~1024),会先减去前一段的上界(例如减去 128),再按照本区间的对齐规则计算桶位置。

5.2.4 ThreadCache 类定义

根据上面的对齐规则:

  • ThreadCache 中桶的数量(即自由链表数量)为 208;
  • ThreadCache 能处理的最大申请尺寸为 256KB。

我们可以将这些常量定义如下:

cpp 复制代码
// 小于等于 MAX_BYTES 的请求交给 ThreadCache
// 大于 MAX_BYTES 的请求走 PageCache 或系统堆
static const size_t MAX_BYTES   = 256 * 1024;

// ThreadCache / CentralCache 中 FreeList 数组大小
static const size_t NFREELISTS = 208;	// 哈希桶的总数量
`

ThreadCache 类定义如下:
```cpp
// ThreadCache 本质上是一个哈希映射到多个对象自由链表的结构
class ThreadCache
{
public:
    // 申请和释放内存对象
    void* Allocate(size_t size);
    void  Deallocate(void* ptr, size_t size);

private:
    // 用数组来模拟哈希表,每个下标位置挂一条自由链表
    FreeList _freeLists[NFREELISTS];
};

申请内存对象 Allocate 的基本逻辑:

cpp 复制代码
// 在当前线程的 ThreadCache 中申请一个小对象
void* ThreadCache::Allocate(size_t size)
{
	// ThreadCache 只负责小于等于 MAX_BYTES(256KB)的对象
	assert(size <= MAX_BYTES);

	// 1. 先把用户申请的 size 按照 SizeClass 规则向上对齐
	//    比如用户申请 7 字节,我们实际要按 8 字节对应的桶来管理
	size_t alignSize = SizeClass::RoundUp(size);

	// 2. 根据"对齐后的大小"映射到对应的哈希桶下标
	//    index 表示:当前 size 应该落到 _freeLists 数组中的哪一个桶
	//    注意:这里更严谨的写法应该是传 alignSize,而不是 size
	size_t index = SizeClass::Index(alignSize); 

	// 3. 如果当前桶对应的 FreeList 不为空,说明 ThreadCache 里已经有
	//    对应大小的空闲对象,可以直接从该 FreeList 中 Pop 一个出来返回
	if (!_freeLists[index].Empty())
	{
		return _freeLists[index].Pop();
	}
	else
	{
		// 4. 如果当前桶下面的 FreeList 为空,说明本线程内部没有可用对象了,
		//    这时就需要向 CentralCache 申请一批该大小的对象。
		//    FetchFromCentralCache 会:
		//      1)向 CentralCache 对应桶批量要一串对象;
		//      2)把其中第一个对象返回给当前线程;
		//      3)把剩余对象挂回当前桶的 FreeList,供后续快速分配。
		return FetchFromCentralCache(index, alignSize);
	}
}

FetchFromCentralCache 也是 ThreadCache 的成员函数,负责与 CentralCache 交互,后续再实现。

5.3 ThreadCache 的 TLS 无锁访问

每个线程都应该有自己独享的 ThreadCache。

如果把 ThreadCache 写成一个全局变量,那所有线程都会共享它,必然要加锁保护,既增加了控制成本,也降低了并发性能。

要实现 "每个线程无锁访问自己专属的 ThreadCache",我们可以利用 TLS(Thread Local Storage,线程局部存储):

  • 使用 TLS 声明的变量,在各自线程内部是 "全局可见" 的;
  • 但不同线程之间互相不可见,从而保证了数据天然线程安全。

声明如下:

cpp 复制代码
// TLS: Thread Local Storage
// 程序中如果有 3 个线程,那么每个线程都会有一份独立的 pTLSthreadcache
// 它在本线程内相当于一个"线程内全局变量",但不会被其他线程访问到。
static _declspec(thread) ThreadCache* pTLSthreadcache = nullptr;

// static 保证该全局变量只在当前编译单元内可见

并不是 "线程一创建就立刻拥有 ThreadCache",而是在该线程第一次进行内存申请时才延迟创建:

cpp 复制代码
// 在申请内存的公共接口中,按需初始化 ThreadCache
if (pTLSthreadcache == nullptr)
{
    pTLSthreadcache = new ThreadCache;
}

这样,每个线程都可以通过 TLS 无锁地获取自己的 ThreadCache,在本地完成绝大多数的小对象分配和释放,真正需要加锁的只有访问 CentralCache / PageCache 的少量情况,大幅提升高并发场景下的整体性能。

6. Central Cache

6.1 CentralCache 整体设计

当线程申请某一大小的内存时,如果 ThreadCache 中对应的自由链表不为空,直接从中取出一个内存块返回即可;如果该自由链表为空,则 ThreadCache 需要向 CentralCache 申请内存。

CentralCache 的整体结构与 ThreadCache 基本一致,都是哈希桶结构,并且两者采用相同的对齐与映射规则。

这样设计有一个明显好处:当 ThreadCache 某个桶中没有可用内存块时,可以直接去 CentralCache 中 同一个桶 里取对象,映射关系是一一对应的。

6.1.1 CentralCache 与 ThreadCache 的不同点

CentralCache 和 ThreadCache 有两个比较关键的区别。

1️⃣ 访问粒度不同:ThreadCache 线程私有,CentralCache 全局共享

  • ThreadCache 是 每个线程独享 的本地缓存;
  • CentralCache 是 所有线程共享 的全局缓存。

当某个线程的 ThreadCache 某个桶耗尽时,就会去访问 CentralCache 对应的桶,因此访问 CentralCache 时必须加锁保证线程安全。

CentralCache 并不是对整个结构加一把大锁,而是采用 桶锁(分段锁) 的方式:

  • 每个桶有一把互斥锁;
  • 只有多个线程同时访问 同一个桶 时才会产生竞争;
  • 不同线程访问不同桶时互不影响,降低了锁竞争的概率和成本。

2️⃣ 存储内容不同:ThreadCache 挂 "内存块",CentralCache 挂 "Span"

  • ThreadCache 的每个桶下挂的是一个个 已经切好的小内存块
  • CentralCache 的每个桶下挂的是一个个 Span

每个 Span 管理的是一块 "以页为单位的大块内存",同一个桶中的多个 Span 以双向链表组织起来;

每个 Span 内部又维护一个自由链表,用于管理这块大内存中切分出的定长小块(大小由所属哈希桶决定)。

如下图所示:

6.2 CentralCache 结构设计

6.2.1 页号(PageId)的类型

一个进程运行起来后,会拥有自己的进程虚拟地址空间:

  • 在 32 位平台下,地址空间大小为 2³² Byte;
  • 在 64 位平台下,理论地址空间大小为 2⁶⁴ Byte(实际会受硬件与操作系统限制,但计算逻辑不变)。

假设页大小为 8KB,即 2¹³ Byte,则:

  • 32 位平台:总页数 = 2³² ÷ 2¹³ = 2¹⁹
  • 64 位平台:总页数 = 2⁶⁴ ÷ 2¹³ = 2⁵¹

页号本质上就是一个 "编号",与地址类似:

  • 地址是以 "字节" 为单位的编号;
  • 页号是以 "页" 为单位的编号。

补充:常见存储单位关系

cpp 复制代码
1KB = 1024 Byte = 2¹⁰ Byte

1MB = 1024 KB

1GB = 1024 MB

依次是按 1024 倍放大

由于在 64 位平台下页号的取值范围是 [0, 2⁵¹),因此不能简单用 32 位无符号整型来存储,需要区分平台来选择合适的类型,这里用条件编译进行处理:

cpp 复制代码
#ifdef _WIN64
	typedef unsigned long long PAGE_ID;
#elif _WIN32
	typedef size_t PAGE_ID;
#else
	// linux 平台留做扩展
#endif

注意:

  • 在 32 位下,_WIN32 有定义,_WIN64 没有定义;
  • 在 64 位下,_WIN32 和 _WIN64 都有定义。

所以条件编译时必须 先判断 _WIN64 再判断 _WIN32,否则 64 位下逻辑容易写错。

6.2.2 Span 的结构

CentralCache 的每个桶下挂的是一个个 Span,每个 Span 管理一块 "以页为单位" 的大块内存。结构如下:

cpp 复制代码
// Span 管理一段连续的大块内存,以页为单位
struct Span
{
	PAGE_ID _pageId = 0;	// 起始页号
	size_t _n = 0;			// 管理的页数

	// 双向循环链表指针
	Span* _next = nullptr;
	Span* _prev = nullptr;

	void*  _freelist = nullptr;  // 将大块切成小块后,用链表串起来
	size_t _usecount = 0;        // 已分配给 ThreadCache 的小块数量,==0 说明该 Span 所有对象都回来了
};

说明:

  • _pageId:记录当前 Span 管理的大块内存的 起始页号,方便 PageCache 以后做前后页合并;
  • _n:该 Span 管理的页数,并不是固定值,会根据分配策略动态调整;
  • _freelist:Span 内部的小块自由链表,管理切分后的定长小对象;
  • _usecount:记录当前 Span 内被分配给 ThreadCache 的小块数量;当 _usecount == 0 时,说明这块大内存中所有小块都已经归还,此时 CentralCache 可以将该 Span 还给 PageCache。

每个桶中多个 Span 按双向链表组织:

  • 当需要将某个 Span 归还给 PageCache 时,只需要从双链表中 O(1) 地摘除;
  • 如果采用单链表,则删除时还需要找到前驱节点,操作会更麻烦。

6.2.3 双链表结构封装

根据上面的描述,CentralCache 每个哈希桶中存放的是一个带头结点的双向循环链表,我们用 SpanList 进行封装:

cpp 复制代码
// 带头双向循环链表
class SpanList
{
public:
	SpanList()
	{
		_head = new Span;
		_head->_next = _head;
		_head->_prev = _head;
	}

	// 在 pos 前插入 newSpan
	void Insert(Span* pos, Span* newSpan)
	{
		assert(pos);
		assert(newSpan);

		// [prev] ----- [pos]
		// 在 pos 前插入:
		// [prev] -- [newSpan] -- [pos]
		Span* prev = pos->_prev;
		
		prev->_next   = newSpan;
		newSpan->_prev = prev;
		
		newSpan->_next = pos;
		pos->_prev     = newSpan;
	}

	// 在 pos 位置删除(只是摘链,不 delete)
	void Erase(Span* pos)
	{
		assert(pos);
		assert(pos != _head);

		// 删掉 [pos]
		// [prev] -- [pos] -- [next]
		// [prev] --------- [next]
		Span* prev = pos->_prev;
		Span* next = pos->_next;

		prev->_next = next;
		next->_prev = prev;

		// 注意:不在这里 delete pos
		// 因为这个 Span 还要还给 PageCache 使用
	}

private:
	Span* _head = nullptr;	// 头结点

public:
	// 如果多个线程同时访问同一个桶,就会产生竞争,因此需要桶锁
	std::mutex _mtx;	// 桶锁
};

需要注意的是:

  • 从双链表中摘除的 Span 是要 "下放" 给 PageCache 的,只是解除链表关系,不需要在这里 delete。

6.2.4 CentralCache 的整体结构

CentralCache 的哈希映射规则与 ThreadCache 完全一致,因此桶的数量也是 NFREELISTS = 208

不同的是:CentralCache 中每个桶存的是 SpanList 而不是 FreeList

cpp 复制代码
class CentralCache
{
public:
	// ...

private:
	SpanList _spanLists[NFREELISTS];	// 每个桶是一个 Span 双向链表
};

映射一致带来的好处非常直接:

  • ThreadCache 某个桶没内存时,可以直接访问 CentralCache 对应下标的桶,无需额外转换。

6.3 CentralCache 的核心实现

6.3.1 CentralCache 的实现方式(单例模式)

每个线程都有自己独立的 ThreadCache,我们通过 TLS(Thread Local Storage)实现线程无锁访问自己的 ThreadCache。

而 CentralCache 和 PageCache 在整个进程中应该只存在一份,是全局共享的资源。对于此类 "全局唯一" 的对象,我们通常采用 单例模式 实现。

单例模式可以保证:

  • 系统中该类只有一个实例;
  • 提供一个全局访问点,整个程序都通过这个入口获取实例。

这里使用实现简单的 饿汉模式 即可:

cpp 复制代码
class CentralCache
{
public:
	// 3. 公共静态成员函数:全局唯一获取实例的入口
	static CentralCache* getInstance()
	{
		return &_sInst;
	}

private:
	SpanList _spanLists[NFREELISTS];	// 按对齐方式映射的桶数组

private:
	// 1. 私有构造函数:禁止外部直接构造
	CentralCache() {}

	// 2. 禁止拷贝与赋值,防止被复制出多个实例
	CentralCache(const CentralCache&) = delete;				// 禁用拷贝构造
	CentralCache operator=(const CentralCache&) = delete;	// 禁用赋值重载

	// 静态成员:程序启动时初始化,全局唯一
	static CentralCache _sInst;
};

在类外初始化静态实例:

cpp 复制代码
CentralCache CentralCache::_sInst;

这样,整个进程中只有一个 CentralCache 实例,所有线程都通过 CentralCache::getInstance() 访问它。

6.3.2 慢开始反馈调节算法

当 ThreadCache 向 CentralCache 申请对象时,有一个关键问题:一次性应该给 ThreadCache 多少个对象

  • 如果给得太少,ThreadCache 很快又会用完,不断去找 CentralCache,频繁加锁,效率低;
  • 如果给得太多,ThreadCache 可能用不完,大量对象长期 "躺" 在本地,造成内存浪费。

针对这个问题,这里采用类似 "慢开始" 的反馈调节算法:

  • 对象越小:一次可以批量给得多一些;
  • 对象越大:一次批量给得少一些;
  • 并且控制一次批量对象数量在 [2, 512] 之间:
    • 再小的对象,一次最多给 512 个;
    • 再大的对象,一次至少给 2 个。

具体实现如下:

cpp 复制代码
class SizeClass
{
public:
		// .......省略

    // 计算一次 ThreadCache 向 CentralCache 申请 "最多多少个对象"
    // 注意:这里只是一个"理论上限",真正本次要多少,还会再和 FreeList::_maxSize 取 min
    static size_t NumMoveSize(size_t size)
    {
        assert(size > 0);

        // 设计思路:
        // 1)总预算:最多可以用 256KB(MAX_BYTES)这块"预算"来装这一批对象
        // 2)单个对象 size 越小,一次就可以多要一些;size 越大,一次就只能要少量几个
        //    理论个数 = MAX_BYTES / size
        size_t num = MAX_BYTES / size;

        // 3)给一个下限:至少一次要 2 个
        //    否则如果只要 1 个,ThreadCache 每次都要找 CentralCache,太频繁了
        if (num < 2)
            num = 2;

        // 4)给一个上限:最多一次要 512 个
        //    否则小对象(比如 8B)一次要太多,会导致 ThreadCache 持有的内存过大
        if (num > 512)
            num = 512;

        return num;
    }

    // .......省略
};

仅仅这样还不够精细:即使是小对象,一上来就一次给 512 个也太激进了。

为此,我们在 FreeList 中增加一个 _maxSize 字段,用来记录这个自由链表当前 "建议的批量大小",初始值为 1:

cpp 复制代码
// 管理小对象的自由链表
class FreeList
{
public:
	// ......

	size_t& MaxSize()
	{
		return _maxSize;
	}

private:
	void*  _freeList = nullptr;
	size_t _maxSize = 1;	// 慢开始批量上限
};

当 ThreadCache 向 CentralCache 申请对象时:

  • 计算理论上限:SizeClass::NumMoveSize(size)
  • min(_maxSize, 理论上限) 作为本次批量申请的数量;
  • 如果本次用的是 _maxSize,说明之前的批量还不够用,则将 _maxSize 再加 1,慢慢增大。

这样:

  • 第一次申请某个大小的对象时,最多只会拿 1 个;
  • 之后如果同尺寸需求持续存在,_maxSize 会一点一点增大,最终稳定在理论上限附近;
  • 整体行为类似网络中的 "拥塞控制慢开始",既能避免一上来要太多,又能适应长期需求。

📺 一个简单生活类比:

cpp 复制代码
- 第一次搬家,你去找朋友帮忙:
- 第一次:你只叫 1 个朋友(怕叫多浪费)
- 发现不够 → 下次叫 2 个
- 还不够 → 下次叫 3 个
- ......
- 最后你找到最佳人数,超过就不会再多叫了

这就是 _maxSize 的成长过程。

6.3.3 ThreadCache 从 CentralCache 获取对象

当 ThreadCache 发现某个桶为空时,会调用 FetchFromCentralCache 向 CentralCache 批量申请对象:

cpp 复制代码
// ThreadCache 向 CentralCache 批量获取对象的核心函数
// index:当前 size 对应的桶下标
// size:对齐后的对象大小(比如 7B 会对齐成 8B)
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	// 慢开始反馈调节算法:
	// ----------------------
	// SizeClass::NumMoveSize(size):
	//    根据对象大小计算"理论批量上限",小对象批量上限大,大对象批量上限小。
	// _freeLists[index].MaxSize():
	//    当前这个桶历史上的"经验上限",从 1 开始慢慢增长。
	//
	// 两者取 min,得到本次实际希望申请的数量:
	//    既不会一上来就要很多(避免过度占用),也允许随着需求逐渐放大批量,提高效率。
	size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));

	// 如果本次批量数已经等于当前经验上限,说明历史上申请这个 size 的频率确实比较高,
	// 那么可以适当把"经验上限"再提高一点,下次就能批量要更多,减少找 CentralCache 的次数。
	if (batchNum == _freeLists[index].MaxSize())
	{
		_freeLists[index].MaxSize() += 1;
	}

	// 向 CentralCache 对应桶申请一串对象:
	//  - start:返回的一段链表的头指针
	//  - end:  返回的一段链表的尾指针
	//  - actualNum:实际拿到的对象个数(可能小于 batchNum,比如 span 剩的不够了)
	void* start = nullptr;
	void* end   = nullptr;
	size_t actualNum = CentralCache::getInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum > 0);

	// 如果只拿到了 1 个对象,那就没必要往 FreeList 里挂了,直接返回给调用线程即可。
	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		// 如果一次性从 CentralCache 拿到了多个对象:
		//   - 第一个对象:直接返回给当前线程,这就是本次 Allocate 要用的对象;
		//   - 剩余的 actualNum - 1 个对象:全部头插到当前桶的 FreeList 中,作为后续快速分配的缓存。
		//
		// 注意:
		//   start → obj2 → obj3 → ... → end
		//   NextObj(start) 就是第二个对象的指针,从它开始是一整段可以挂回自由链表的对象链。
		_freeLists[index].PushRange(NextObj(start), end, actualNum - 1);

		// 把第一个对象返回给调用方
		return start;
	}
}

这里要特别说明:

  • ThreadCache 本质上是因为 "自己没内存了",才会去找 CentralCache 拿一批;
  • CentralCache 返回的第一个对象,会直接交给当前正在申请的线程;
  • 其余对象则被缓存在线程本地,供后续快速分配使用。

6.3.4 CentralCache 从某个桶中取出一批对象

CentralCache 侧对应的接口 FetchRangeObj 负责:

  • 从指定 size 对应的桶中,取出若干个对象;
  • 这些对象来自该桶下某个 Span 的 _freelist
  • 取出的对象会以链表形式串起来,只需要返回这段链表的 startend 即可。

具体实现如下:

cpp 复制代码
// 从 CentralCache 中取出一批 size 大小的小对象,返回一段 [start, end] 单链表
// start、end 通过引用返回,actualNum 表示实际取出的个数(可能小于 batchNum)
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
    // 1. 计算桶下标
    //    CentralCache 和 ThreadCache 使用的是同一套 SizeClass 映射规则,
    //    所以某个 size 在 ThreadCache 中落到哪个桶,在 CentralCache 中也落到同一个桶。
    size_t index = SizeClass::Index(size);

    // 2. 对当前桶加"桶锁"
    //    这是 CentralCache 的并发控制手段:
    //    - 不同桶之间可以并行
    //    - 同一个桶下的 Span 列表在同一时刻只能被一个线程修改
    _spanLists[index]._mtx.lock();

    // 3. 在当前桶下找到一个"可用的 Span"
    //    GetOneSpan 的逻辑:
    //      1)如果桶里有 span 且其 _freelist 不为空,就直接返回;
    //      2)如果所有 span 都用完了,就去 PageCache 申请新的 span,
    //         切分成小对象挂到 span->_freelist 中,然后再返回该 span。
    Span* span = GetOneSpan(_spanLists[index], size);
    assert(span);
    assert(span->_freelist);

    // 4. 从这个 span 的 freelist 中,取出一段连续的对象链表
    //    取出的链表用 [start, end] 表示,方便后续 ThreadCache 挂回自己的 FreeList
    start = span->_freelist;
    end   = start;

    size_t actualNum = 1;  // 已经先拿了第 1 个
    size_t i = 0;

    // 向后继续拿对象,直到:
    //   1)已经拿够 batchNum 个,或者
    //   2)链表提前结束(NextObj(end) == nullptr)
    while (i < batchNum - 1 && NextObj(end) != nullptr)
    {
        end = NextObj(end);  // end 往后走一格
        ++i;
        ++actualNum;
    }

    // 5. 从 span 的 freelist 中"切掉"这一段 [start, end]
    //    现在:
    //      - [start ... end] 这段链表由 ThreadCache 使用
    //      - span->_freelist 指向剩余未分配的那部分小对象
    span->_freelist = NextObj(end);  // 剩下的从 end 的下一个开始
    NextObj(end)    = nullptr;       // 把取出的这一段尾结点的 next 置空,形成独立链表

    // 6. 更新 span 的使用计数
    //    _usecount 表示:这个 span 中有多少小对象已经被分配出去了(在 ThreadCache 手里)
    //    以后 ThreadCache 释放小对象时会递减这个计数,当减到 0,
    //    说明这个 span 中切出去的所有小对象都回来了,就可以把整个 span 还给 PageCache。
    span->_usecount += actualNum;

    // 7. 解桶锁,让其他线程可以继续访问当前桶
    _spanLists[index]._mtx.unlock();

    // 返回本次实际拿到的小对象个数
    return actualNum;
}

这里实际拿到的对象数量 actualNum 可能 小于 申请的 batchNum,但这并不会影响正确性:

  • ThreadCache 的本意其实是 "至少要 1 个对象";
  • 一次批量多拿是为了减少未来再次向 CentralCache 请求的次数;
  • 如果这次拿少了,最多就是下次再来要一遍而已。

6.3.5 将一段对象链挂回 FreeList

当 ThreadCache 从 CentralCache 拿到多于 1 个对象时,需要将其中的一段链表挂回本地自由链表中,为此在 FreeList 中提供 PushRange 接口:

cpp 复制代码
// 管理小对象的自由链表
class FreeList
{
public:
	// ......

	// 插入一段范围 [start, end] 的对象链表
	void PushRange(void* start, void* end, size_t n)
	{
		NextObj(end) = _freeList;
		_freeList    = start;

		_size += n;
	}

private:
	void*  _freeList = nullptr;
	size_t _maxSize  = 1;

	// ... 其他成员
};

这样,CentralCache 和 ThreadCache 之间就可以:

  • 以 "批量" 的方式高效搬运小对象;
  • 利用慢开始机制自动调节批量大小;
  • 尽量让绝大多数分配请求都在 ThreadCache 层完成,提高整体性能。

7. PageCache

7.1 PageCache 整体设计

7.1.1 PageCache 与 CentralCache 的相同点

PageCache 和 CentralCache 一样,整体结构都是 哈希桶

  • PageCache 中的每个桶下面挂的是若干个 Span;

  • 这些 Span 之间同样是用 带头结点的双向循环链表 组织起来,便于插入、删除和遍历。

这一点上,两者在结构形式上是统一的。

7.1.2 PageCache 与 CentralCache 的不同点

1️⃣ 映射规则不同

  • CentralCache 的哈希桶映射规则与 ThreadCache 完全一致,是按 "对象大小区间 + 对齐策略" 来映射的;

  • PageCache 则不同,它是按 页数 来映射的,采用的是 直接定址 的规则:

    • 第 1 号桶:挂的都是 1 页的 Span
    • 第 2 号桶:挂的都是 2 页的 Span
    • 第 k 号桶:挂的都是 k 页的 Span

这样,CentralCache 只要知道 "需要 k 页" 的大块内存,就可以直接到 PageCache 的第 k 号桶寻找。

2️⃣ 管理的粒度不同

  • CentralCache 中的 Span 已经被 切分成定长小对象,用来服务 ThreadCache 的小块分配;

  • PageCache 中的 Span 不再继续往下切小,每个 Span 代表一个 "以页为单位的大块内存"。

原因很简单:

  • PageCache 是给 CentralCache 提供 "页级大块内存" 的;

  • CentralCache 更清楚自己要为哪个 size class 提供对象,因此 Span 怎么切、切成多大,应该由 CentralCache 自己决定,PageCache 只负责页级资源管理。

3️⃣ 桶的数量设定

PageCache 的哈希桶个数,取决于我们希望 "最多管理多少页大小的 Span"。

这里我们约定:

  • PageCache 只显式管理 最多 128 页的 Span;
  • 把 0 号桶空出来不用,从 1 号桶到 128 号桶分别挂 1~128 页的 Span;
  • 因此桶的个数就是 129:
cpp 复制代码
// page cache 中哈希桶的个数(0 号桶空出来)
static const size_t NPAGES = 129;

后续从系统申请的大块内存,都会按 "最多 128 页" 为单位管理和切分。

如下图所示:

7.1.3 在 PageCache 中获取一个 n 页的 Span 的过程

当 CentralCache 需要一个 k 页 的 Span 时,PageCache 的处理流程如下:

1️⃣ 优先从第 k 号桶中取

  • 如果第 k 号桶中有 Span,直接从该桶头删一个 Span 返回给 CentralCache 即可;
  • 这是最理想情况,不需要做切分与合并。

2️⃣ 如果第 k 号桶为空,则向后查找更大的 Span

  • 依次检查第 k+1 号桶、第 k+2 号桶......直到第 128 号桶;
  • 一旦在某个桶中找到一个 span,它管理的页数为 m(m > k),记为 nSpan:
    • 将 nSpan 切分成两个 Span:
      • 一个 kSpan:管理 k 页(返回给 CentralCache 使用);
      • 一个 remainSpan:管理 m−k 页(挂回到第 m−k 号桶中,供后续继续使用)。

3️⃣ 如果从第 k 号桶到第 128 号桶都没有 Span

  • 说明 PageCache 当前没有任何可用的页块,此时需要向系统堆一次性申请一个 128 页的连续大块内存;
  • 将这 128 页封装成一个 bigSpan,记录其起始页号和页数(128 页),并先挂到第 128 号桶中;
  • 随后通过已有的切分逻辑,把 bigSpan 再切分成一个 k 页的 Span 和一个 128−k 页的 Span:
    • k 页的 Span 返回给 CentralCache;
    • 128−k 页的 Span 挂到第 128−k 号桶中。

设计上的关键点:

  • PageCache 向系统申请内存时,总是以 128 页为单位成块申请;
  • 所有供给 CentralCache 的 Span,都是由这些 128 页的大块不断切分出来的。

这样做可以保证:

  • 尽量减少系统调用次数,降低申请大块虚拟内存的开销;
  • 页号连续,便于 PageCache 在回收 Span 时进行前后页的合并,从而缓解外部碎片问题。

7.1.4 PageCache 的实现方式(锁与单例模式)

当每个线程的 ThreadCache 内存耗尽时,会去找 CentralCache;

CentralCache 的多个桶在高并发下可能会同时向 PageCache 申请 Span。

因此:

  • PageCache 同样涉及线程安全问题,访问时必须加锁。

但 PageCache 和 CentralCache 在 "访问方式" 上有明显不同。

访问 CentralCache 某个桶时:

  • ThreadCache 只会根据对象大小访问 某一个固定的桶;
  • 不会跨桶操作;
  • 所以 CentralCache 可以使用 "桶锁"(每个桶一个 mutex)。

访问 PageCache 时:

  • 为了找一个 k 页的 Span,可能会遍历多个桶(k 号桶、k+1 号桶......);
  • 切分大 Span 时,也会访问多个桶(将 n-k 页挂到第 n-k 号桶);
  • 做页合并时,也会访问前后相邻大小的桶;
  • 若使用桶锁,会在多个桶之间频繁 加锁 / 解锁,复杂且容易出错。

因此 PageCache 采用的是 一把 "大锁" 保护整个 PageCache

  • 每次访问 PageCache 时,加一把全局锁 _pageMtx
  • 访问完成后一次性解锁;
  • 虽然锁粒度较大,但 PageCache 的访问频率远远小于 ThreadCache / CentralCache 层,因此总体性能仍可接受。

此外,PageCache 在整个进程中也应只存在一个实例,因此同样采用 饿汉单例模式:

cpp 复制代码
// 1. PageCache 是以页为单位管理 Span 的自由链表
// 2. 为保证全局唯一,将该类设计为单例模式
class PageCache
{
public:
	// 3. 公共静态成员函数:全局唯一获取实例的入口
	static PageCache* getInstance()
	{
		return &_sInst;
	}

private:
	SpanList _spanLists[NPAGES];	// 按页数映射的哈希桶

private:
	// 1. 私有构造函数:禁止外部通过 new/栈对象创建
	PageCache() {}

	// 2. 禁止拷贝构造和赋值运算符(避免出现多个实例)
	PageCache(const PageCache&) = delete;		 // 禁用拷贝构造
	PageCache operator=(const PageCache&) = delete; // 禁用赋值

	static PageCache _sInst;	// 单例(饿汉模式)

public:
	std::mutex _pageMtx;		// 全局大锁,定义为 public 方便外部在访问前加锁
};

程序启动后静态对象会立刻被创建:

cpp 复制代码
// 类外初始化静态成员
PageCache PageCache::_sInst;

7.2 PageCache 中获取 Span

这一部分主要配合 CentralCache 的 GetOneSpan 和 PageCache 的 NewSpan 来讲。

7.2.1 CentralCache 获取一个 "非空 Span" 的过程

当 ThreadCache 向 CentralCache 申请对象时,CentralCache 需要:

  • 先从 对应哈希桶 中找一个 Span;
  • 从这个 Span 的 _freelist 中取出若干小对象返回给 ThreadCache。

所以 CentralCache 首先要解决的是:如何从某个桶中找到一个 "非空的 Span"

1️⃣ 在当前桶中遍历 Span 链表

  • 首先遍历该桶中的双向链表;
  • 若发现某个 Span 的 _freelist 非空,直接返回这个 Span 即可。

为了方便遍历,我们在 SpanList 中模拟 "简易迭代器" 接口:

cpp 复制代码
// 带头双向循环链表
class SpanList
{
public:
	// ...

	// 返回第一个有效节点
	Span* Begin()
	{
		return _head->_next;
	}

	// 返回"尾后位置"(也就是头结点本身)
	Span* End()
	{
		return _head;
	}

private:
	Span* _head = nullptr;	// 头结点

public:
	// 桶锁:多个线程同时访问同一桶时需要加锁
	std::mutex _mtx;
};

2️⃣ 当前桶中没有可用 Span:向 PageCache 申请

如果遍历后发现:

  • 链表中没有 Span,或者
  • 链表中所有 Span 的 _freelist 都为空,

说明 CentralCache 当前桶里暂时没有资源可用,需要去 PageCache 申请新的 Span。

申请多少页?这里就要用到前面讲的 按对象大小计算页数的策略

7.2.1.1 根据对象大小,计算 CentralCache 向 PageCache 申请的页数

逻辑很自然:

  • 先根据对象大小 size,计算出 ThreadCache 一次向 CentralCache 申请的对象个数上限(NumMoveSize(size)
  • 再用这个个数乘以每个对象的大小,得到此次申请需要的总字节数;
  • 再把总字节数转换成 "多少页",不足一页就按一页算。

对应代码如下:

cpp 复制代码
// 计算对象大小映射时系统一次申请多少页
class SizeClass
{
public:
	// ......省略

	// 计算一次向系统获取多少页
	// 单个对象大小: 8 byte ... 256 KB
	static size_t NumMovePage(size_t size)
	{
		size_t num   = NumMoveSize(size); // 一次最多移动多少个对象
		size_t npage = num * size;        // 总字节数

		npage >>= PAGE_SHIFT;            // 换算成"多少页"
		if (npage == 0)
			npage = 1;                   // 至少申请一页
		return npage;
	}
};

其中 PAGE_SHIFT 表示 "页大小对应的移位数",如果一页是 8KB,则:

cpp 复制代码
// 页大小转换偏移:1 页 = 2^13 = 8KB
static const size_t PAGE_SHIFT = 13;

注意:

  • 当 CentralCache 申请到一个多页的 Span 后,还需要根据 size 将这个 Span 切成定长小对象,挂到该 Span 的 _freelist 上;
  • 切分逻辑在下面的 GetOneSpan 中会一并描述。
7.2.1.2 CentralCache::GetOneSpan 的完整流程

下面是从 SpanList(CentralCache 的某个桶)或 PageCache 获取一个 "带有可用小对象的 Span" 的完整实现:

cpp 复制代码
// 从 SpanList 或 PageCache 获取一个"可用的且 _freelist 非空"的 span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    // ============================
    // 1. 在当前 CentralCache 的桶中查找
    // ============================

    // 当前桶中可能挂着多个 span,每个 span 负责提供 size 对象
    Span* it = list.Begin();

    while (it != list.End())
    {
        // 如果 span 的 freelist 非空,说明还有对象可分配
        if (it->_freelist != nullptr)
        {
            return it; // 直接返回:无需访问 PageCache,也不需要切分
        }

        it = it->_next; // 否则继续找下一个 span
    }

    // 走到这里表示:
    // 当前桶里所有 span 都没有剩余小对象(即 span->freelist 全部为空)

    // ============================
    // 2. 去 PageCache 申请新的 span
    // ============================

    // 在访问 PageCache 前,要先解掉当前桶锁!
    // ----------------------------
    // 为什么?
    // 因为 PageCache 分配可能很耗时(需要切分、申请系统内存等)
    // 如果不提前解锁,其他线程无法向这个桶释放小对象,会被阻塞住!
    // 所以这里必须 unlock,否则会造成严重的"锁长时间占用"问题。
    list._mtx.unlock();

    // PageCache 是全局资源,用大锁保护
    PageCache::getInstance()->_pageMtx.lock();

    // NumMovePage(size) 根据 size 决定一次应该批量拿多少页
    // NewSpan(k) 返回一个刚分配好的、连续 k 页的 Span
    Span* span = PageCache::getInstance()->NewSpan(
        SizeClass::NumMovePage(size)
    );

    PageCache::getInstance()->_pageMtx.unlock();

    // 此时 span 是"私有的",还没挂回任何链表
    // 所以下面的切分操作不需要加锁,其他线程无法访问到它

    // ============================
    // 3. 把大块 Span 切成小对象
    // ============================

    // span->_pageId 为页号,通过左移得到真实内存起始地址
    char* start = (char*)(span->_pageId << PAGE_SHIFT);

    // span 管理的总字节数 = 页数 × 一页大小
    size_t bytes = span->_n << PAGE_SHIFT;
    char* end    = start + bytes;

    // 建立小对象链表:先取第一块作为 freelist 的头
    span->_freelist = start;
    start += size;

    void* tail = span->_freelist;

    // 把整块 span 的空间切成 "size 大小" 的节点,使用尾插构造单链表
    //
    // 注意这里判断条件是 (start + size < end)
    // 为什么不是 (start < end)?
    //
    // 因为最后那一小段可能不足 size 字节,如果强行作为一个对象挂入链表,
    // 后续访问其 NextObj(tail) 时会越界,非常危险!
    //
    // 所以这里严格保证必须够 size 大小,剩下的小尾巴直接废弃。
    while (start + size < end)
    {
        NextObj(tail) = start;
        tail          = NextObj(tail);
        start        += size;
    }
    NextObj(tail) = nullptr;

    // ============================
    // 4. 把新 span 插回 CentralCache 的桶
    // ============================

    // 挂回前必须重新加锁
    list._mtx.lock();

    // 头插方式挂入当前桶
    // 下次有线程需要这个大小的对象时,优先从它开始取
    list.PushFront(span);

    return span;
}

这里有几个要点非常重要:

  • 在去 PageCache 之前先解掉桶锁,避免长时间占用 CentralCache 的锁;
  • 在 PageCache 中加的是大锁 _pageMtx,保证页级操作的正确性;
  • 切分 Span 时不加锁,因为此时 Span 尚未挂入任何公共结构;
  • 切好 Span 之后再挂回 CentralCache 对应桶,并重新上锁。

由于 CentralCache 的每个桶都是通过 SpanList 维护的一个带头结点的双向循环链表,因此 SpanList 必须提供接口,用于将 span 插入链表。这里我们选择 头插法(PushFront)。

由于 SpanList 类本身已经实现了 Insert(position, span)Begin() 函数,因此头插法可以非常简洁地实现:只需将新 span 插入到 Begin() 位置即可。

cpp 复制代码
// 带头双向循环链表
class SpanList
{
public:
    // ... 省略其他代码 ...

    // 头插,将 span 插到链表第一个有效位置
    void PushFront(Span* span)
    {
        // Begin() 返回头结点之后的第一个元素位置,
        // 调用 Insert(pos, span) 即可在链表头部插入新 span
        Insert(Begin(), span);
    }

    // ... 省略其他代码 ...
};

7.2.2 PageCache::NewSpan ------ 获取一个 k 页的 Span

当 CentralCache 找不到可用 Span 时,会调用 PageCache::NewSpan(k) 申请一个 k 页的 Span。

因为 PageCache 是按页数直接映射的,所以获取流程是:

1️⃣ 优先从第 k 号桶中获取:

  • 如果第 k 号桶不为空,直接 PopFront() 取一个 Span 返回。

2️⃣ 第 k 号桶为空,则向后寻找更大的 Span:

  • 从 k+1 号桶开始遍历到 NPAGES-1 号桶,找到某个 nSpan(管理 m 页, m > k);
  • 将其切为:
    • kSpan:管理 k 页;
    • remainSpan:管理 m-k 页,挂到第 m-k 号桶中;
  • 返回 kSpan。

3️⃣ 如果所有桶都没有 Span:

  • 调用 SystemAlloc(NPAGES-1) 向操作系统一次性申请 128 页的大块内存;
  • 得到的地址是这 128 页内存的起始地址;
  • 将其转换为页号:pageId = (PAGE_ID)ptr >> PAGE_SHIFT
  • 构造一个 bigSpan,挂到第 128 号桶;
  • 然后通过递归调用 NewSpan(k),复用已有逻辑对 bigSpan 进行切分。

代码如下:

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

	// 1. 先检查第 k 个桶里是否有 span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();
		return kSpan;
	}
	
	// 2. 第 k 个桶为空,则去后面的桶里找更大的 span
	for (size_t i = k + 1; i < NPAGES; i++)
	{
		if (!_spanLists[i].Empty())
		{
			// 找到一个 i 页的 nSpan,将其切分为:
			// - kSpan:k 页,返回给 CentralCache
			// - nSpan:剩余 i-k 页,再挂回第 i-k 号桶
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = new Span;

			// 在 nSpan 的头部切出 k 页
			kSpan->_pageId = nSpan->_pageId;	// 起始页号
			kSpan->_n      = k;				// 页数 k

			nSpan->_pageId += k;			// 剩余部分的起始页号
			nSpan->_n      -= k;			// 剩余页数 i-k

			_spanLists[nSpan->_n].PushFront(nSpan); // 挂到第 i-k 号桶
			return kSpan;
		}
	}

	// 3. 后面的桶中也没有 span,只能向堆申请 128 页的大块内存
	Span* bigSpan = new Span;

	void* ptr = SystemAlloc(NPAGES - 1); // 按页数向操作系统申请一大片连续虚拟内存

	// 将起始地址换算成页号:页号 = 地址 / 页大小(8KB) = 地址 >> PAGE_SHIFT
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n      = NPAGES - 1;	// 128 页

	// 把这 128 页挂到第 128 号桶
	_spanLists[bigSpan->_n].PushFront(bigSpan);

	// 4. 再次调用 NewSpan(k),此时一定能在后面的桶中找到可切分的 span
	return NewSpan(k);
}

这里有一个小技巧:

  • 申请到 128 页后,不在这里直接写一套新的 "切分成 k 页 + 128-k 页" 的代码,而是先挂到第 128 号桶;
  • 然后通过递归调用 NewSpan(k) 复用已有逻辑,这样可以避免大量重复代码。

另外,我们还需要再给 SpanList 类添加对应的 Empty 和 PopFront 函数。

cpp 复制代码
// 带头双向循环链表
class SpanList
{
public:
	// ... 省略其他代码 ...

	//
	Span* PopFront()
	{
		Span* span = Begin();
		Erase(span);
		return span;
	}

	// 判空
	bool Empty()
	{
		return _head->_next == _head;
	}

	// ... 省略其他代码 ...
};

8. ThreadCache 回收内存

当某个线程不再需要某块小对象内存时,可以通过 ThreadCache 将其释放回本线程的自由链表中:

  • 先根据对象大小 size 找到对应的自由链表(哈希桶);
  • 再把对象头插到该自由链表即可。

然而,如果某个 ThreadCache 中某个桶下的自由链表越来越长,这些内存在一个线程手里 "堆太多" 就是一种浪费:

  • 其他线程想用这些内存时,必须重新向 CentralCache / PageCache 申请;
  • 而这个线程自己可能已经不再频繁申请这个尺寸的对象了。

为此,当 ThreadCache 某个桶下的链表长度过长时,我们会把其中一段对象归还给 CentralCache,让这些内存重新变成 "全局可用"。

代码如下:

cpp 复制代码
// 释放内存对象(小于等于 256KB 的情况)
// ptr  :要释放的对象指针
// size :对象大小(已经是对齐后的 size,或可通过 Span::_objSize 得到)
void ThreadCache::Deallocate(void* ptr, size_t size)
{
    assert(size <= MAX_BYTES);
    assert(ptr);

    // 1. 根据 size 计算应该落到哪个桶(FreeList)的下标
    //    这里使用的是与 CentralCache 一致的 SizeClass 映射规则,
    //    保证同一个 size 在 ThreadCache/CentralCache 中都能对应到同一个逻辑"类别"。
    size_t index = SizeClass::Index(size);

    // 2. 把对象头插到对应的 FreeList 中
    //    这一步只是把对象重新挂回当前线程自己的缓存中,还没有归还到 CentralCache。
    _freeLists[index].Push(ptr);

    // 3. 控制当前 FreeList 的"长度上限"
    //    如果某个桶中的空闲对象过多,就会导致:
    //      - 当前线程占用了过多内存
    //      - 其他线程可能内存紧张,却拿不到这些对象
    //
    //    这里的策略是:
    //      当当前 FreeList 的长度 >= MaxSize() 时,
    //      就从该链表中"切出一段"(长度为 MaxSize),归还给 CentralCache。
    if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
    {
        ListTooLong(_freeLists[index], size);
    }
}

当链表长度过长时,我们具体做法是:

  • 从该 FreeList 中取出 MaxSize() 个对象;
  • 将这一段对象链表统一还给 CentralCache 对应的 Span。
cpp 复制代码
// 当某个 FreeList(某一种对象大小的缓存链表)长度过长时,
// 从该链表中取出一段对象(长度 = MaxSize),并归还给 CentralCache。
void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
    void* start = nullptr;
    void* end   = nullptr;

    // 从自由链表中弹出 MaxSize() 个对象,并组成一段连续的单链表:
    //   start → obj2 → obj3 → ... → end
    //
    // PopRange 的作用:
    //   - start 指向链表第一块对象
    //   - end   指向链表最后一块对象
    //   - list 的头指针被更新为剩余链表的起点
    list.PopRange(start, end, list.MaxSize());

    // 将这段对象链 [start, end] 归还给 CentralCache。
    // CentralCache 会根据 size 找到对应的 span,然后:
    //   - 将对象头插回 span->_freelist;
    //   - 更新 span->_usecount;
    //   - 如果某个 span 的所有对象都回来了(_usecount == 0),
    //       CentralCache 会把这个 span 再归还给 PageCache;
    //   - PageCache 会尝试做 span 前后合并,减少外碎片。
    CentralCache::getInstance()->ReleaseListToSpans(start, size);
}

这里 FreeList 需要支持:

  • Size():返回自由链表当前节点个数;
  • PopRange(start, end, n):从链表头部取出 n 个对象,返回这一段链表的头尾指针。

因此我们在 FreeList 中增加 _size 字段,并在插入 / 删除时维护它:

cpp 复制代码
// 管理小对象的自由链表
class FreeList
{
public:
    // ... 省略其他代码 ...

    // 从链表头部"弹出" n 个节点,组成一段单独的链表 [start, end]
    // 调用结束后:
    //   - [start, end] 这一段从当前 FreeList 中被移除
    //   - _freeList 指向剩余链表的新表头
    //   - _size 减少 n 个
    void PopRange(void*& start, void*& end, size_t n)
    {
        // 要取出的数量 n 不能超过当前链表中已有的节点数
        assert(n <= _size);
        assert(_freeList);

        // 1. 从当前自由链表的表头开始
        start = _freeList;
        end   = start;

        // 2. 向后走 n-1 步,找到这一段链表的尾结点 end
        for (size_t i = 0; i < n - 1; ++i)
        {
            end = NextObj(end);
        }

        // 3. 断链:
        //    - _freeList 跳到 end 的下一个位置(即剩余链表的新表头)
        //    - 把 end->next 置空,这样 [start, end] 就成为一段独立链表
        _freeList    = NextObj(end);
        NextObj(end) = nullptr;

        // 4. 更新当前 FreeList 中的节点个数
        _size -= n;
    }

    // 返回当前链表中节点数量
    size_t Size() const
    {
        return _size;
    }

    // ... 省略其他代码 ...

private:
    void*  _freeList = nullptr; // 单链表头指针
    size_t _maxSize  = 1;       // 当前桶的"批量阈值"(配合 ListTooLong 使用)
    size_t _size     = 0;       // 当前链表中的节点个数
};

9. CentralCache 回收内存(小对象回收)

当 ThreadCache 中某个自由链表过长时,会将这一段链表中的对象 "还给 CentralCache 对应的 Span"。

难点在于:

  • 这些对象不一定都来自同一个 Span。
  • 同一个 size class 的桶下面,可能挂着多个 Span,每个 Span 管理一块不同的页区间。

所以 CentralCache 在回收对象时要解决两个问题:

  • 给定一个对象地址,如何知道它属于哪一页?
  • 给定一个页号,如何知道它属于哪个 Span?

9.1 通过对象地址计算页号

这一步比较直接:

  • 假设一页大小是 2^PAGE_SHIFT 字节(例如 8KB 时 PAGE_SHIFT = 13);
  • 某个地址 obj 所在的页号就是:
cpp 复制代码
PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

类比一下:如果一页大小是 100 字节:

  • 第 0 页:地址 [0, 99],统一认为页号为 0;
  • 第 1 页:地址 [100, 199],统一认为页号为 1;
  • 以此类推。

9.2 通过页号找到对应 Span(页号 → Span 映射)

仅有 "页号" 还不够,因为:

  • 一个 Span 可以管理多页;
  • 我们需要知道 "该页属于哪个 Span"。

为此,在 PageCache 中维护一个从 页号 → Span* 的映射表 _idSpanMap

这个映射在两个地方会用到:

  • CentralCache 回收小对象时,用 地址 → 页号 → Span
  • PageCache 合并前后相邻空闲页时,用 页号 → Span

我们在 PageCache 中增加如下成员(只展示新增部分):

cpp 复制代码
class PageCache
{
public:
    // 获取从对象到 span 的映射
    Span* MapObjectToSpan(void* obj);

private:
    // 页号 -> Span 映射
    std::unordered_map<PAGE_ID, Span*> _idSpanMap;
};

当 PageCache 把一个 Span 分配给 CentralCache 时,需要为该 Span 管理的每一页建立映射关系。

比如在 NewSpan(k) 中返回 k 页 Span 给 CentralCache 前:应该建立这 k 个页号与该 span 之间的映射关系。

cpp 复制代码
// 获取一个 k 页的 span
Span* PageCache::NewSpan(size_t k)
{
    // 要求:k 至少为 1,且不能超过 PageCache 能管理的最大页数(这里是 1 ~ 128)
    assert(k > 0);

    // 1. 优先从第 k 号桶中直接取一个 k 页的 span
    //    PageCache 的映射规则是:第 i 号桶挂的都是 i 页的 span
    if (!_spanLists[k].Empty())
    {
        // 头删一个 span,这个 span 管理的正好是 k 页
        Span* kSpan = _spanLists[k].PopFront();

        // 为这 k 页建立【页号 -> span】的映射
        // 作用:
        //   - CentralCache 回收小块内存(对象)时,只拿到对象指针,
        //     需要先根据地址算出页号,再通过"页号 -> span"找到它属于哪个 span;
        //   - PageCache 后续做前后页合并时,也要根据页号快速查到相邻的 span。
        for (PAGE_ID j = 0; j < kSpan->_n; ++j)
        {
            _idSpanMap[kSpan->_pageId + j] = kSpan;
        }

        return kSpan;
    }

    // 2. 如果第 k 号桶为空,再去后面的桶里找更大的 span,把它切分出一个 k 页的部分
    for (size_t i = k + 1; i < NPAGES; ++i)
    {
        if (!_spanLists[i].Empty())
        {
            // 这里省略具体切分逻辑:
            //   - 从第 i 号桶取出一个 nSpan(页数为 i)
            //   - 从它前面切出一个 kSpan(管理 k 页,返回给上层使用)
            //   - 剩余 (i - k) 页封装回 nSpan,挂到第 (i - k) 号桶中

            // ...... 切分 nSpan -> kSpan + 剩余 span 的代码省略 ......

            // 建立新得到的 kSpan 的【页号 -> span】映射
            for (PAGE_ID j = 0; j < kSpan->_n; ++j)
            {
                _idSpanMap[kSpan->_pageId + j] = kSpan;
            }

            return kSpan;
        }
    }

    // 3. 如果从第 k 号桶到第 128 号桶都找不到 span,
    //    说明 PageCache 当前已经没有可用的大块页内存了,
    //    后续就只能:
    //      - 向系统堆申请一块 128 页的大块内存,
    //      - 封装成 bigSpan 挂到第 128 号桶,
    //      - 然后复用上面的逻辑再切分出 kSpan。
    //
    // ...... 向堆申请 128 页大块内存 + 递归调用 NewSpan(k) 的代码省略 ......
}

此后,CentralCache 在回收小对象时,只需:

  • 通过对象地址计算页号;
  • _idSpanMap 中查找对应的 Span。
cpp 复制代码
// 根据对象地址,找到它所属的 Span
Span* PageCache::MapObjectToSpan(void* obj)
{
    // 1. 根据地址计算该对象所在的"页号"
    //    因为:页号 = 地址 >> PAGE_SHIFT(等价于:地址 / PageSize)
    PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

    // 2. PageCache 维护着全局的页号 → span 映射表(_idSpanMap)
    //    但 PageCache 中的映射可能被其他线程修改(切分、合并),
    //    所以访问映射表时必须加 PageCache 的大锁,保证线程安全。
    std::unique_lock<std::mutex> lock(_pageMtx);

    // 3. 查找对应的 span
    auto ret = _idSpanMap.find(id);
    if (ret != _idSpanMap.end())
    {
        // 找到了------说明该页号已经被建立映射
        // 返回它所属的 span 指针即可
        return ret->second;
    }
    else
    {
        // 理论上不可能出现找不到的情况:
        //   - 所有从 PageCache/系统申请出来的 span 都会建立完整映射(每一页)
        //   - 所有大块内存(>256KB)申请也会建立起始页号的映射
        //
        // 若此处触发,说明页号映射建立或维护时出现了逻辑错误。
        assert(false);
        return nullptr;
    }
}

9.3 CentralCache 回收小对象到 Span

有了 对象地址 → 页号 → Span 的映射后,CentralCache 回收小对象就很清晰了:

  • 遍历从 ThreadCache 归还过来的这一段对象链表;
  • 对于每个对象:
    • 找到它所在的 Span;
    • 头插进该 Span 的 _freelist 中;
    • Span::_usecount 减 1;
  • 如果某个 Span 的 _usecount 减到 0:
    • 说明这个 Span 切出去的所有小对象都已经回来了;
    • 可以将该 Span 从 CentralCache 中摘下,进一步还给 PageCache。

代码如下:

cpp 复制代码
// ThreadCache 把一个桶中的一段对象链表归还给 CentralCache
void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
    // 1. 根据 size 找到 CentralCache 的桶下标
    size_t index = SizeClass::Index(size);

    // 加桶锁保护对应 SpanList
    _spanLists[index]._mtx.lock();

    // 遍历 ThreadCache 归还过来的一段对象链表
    while (start)
    {
        void* next = NextObj(start); // 先保存后继指针(因为待会要断链)

        // 2. 找到该对象属于哪个 Span(通过 PageCache 的页号映射)
        Span* span = PageCache::getInstance()->MapObjectToSpan(start);

        // 3. 把该对象头插回 Span 的 freelist
        //    (CentralCache 不做切分,只管理 freelist)
        NextObj(start)  = span->_freelist;
        span->_freelist = start;

        // 4. 更新 span 的使用计数:此对象已经回收
        span->_usecount--;
        
        // ------------------------------------------------------------
        // 5. 如果这个 span 的 _usecount == 0
        //    => 说明它分配出去的小对象全部归还
        //    => 这个 span 已完全空闲,可返还给 PageCache
        // ------------------------------------------------------------
        if (span->_usecount == 0)
        {
            // (1)从 CentralCache 的该桶链表中摘掉这个 span
            _spanLists[index].Erase(span);

            // 清空链表指针,避免"脏指针"影响 PageCache 的链表结构
            span->_freelist = nullptr;
            span->_next     = nullptr;
            span->_prev     = nullptr;

            // ------------------------------------------------------------
            // ⚠ 关键设计:必须先解 CentralCache 的桶锁
            //
            // 因为我们接下来要加 PageCache 的大锁,
            // 如果我们同时持有两个锁,会造成死锁风险:
            //   CentralCache(桶锁) → PageCache(大锁)
            //   PageCache(大锁) → CentralCache(桶锁)
            //
            // 所以必须先释放桶锁,降低锁粒度。
            // ------------------------------------------------------------
            _spanLists[index]._mtx.unlock();

            // (2)把 span 交回 PageCache,由 PageCache 尝试做前后页合并
            PageCache::getInstance()->_pageMtx.lock();
            PageCache::getInstance()->ReleaseSpanToPageCache(span);
            PageCache::getInstance()->_pageMtx.unlock();

            // ------------------------------------------------------------
            // (3)继续处理剩余对象前,需要重新加回桶锁
            //     因为 start 链表中可能还有剩余对象需要处理
            // ------------------------------------------------------------
            _spanLists[index]._mtx.lock();
        }

        // 继续处理链表中的下一个对象
        start = next;
    }

    // 处理完所有对象链后,释放桶锁
    _spanLists[index]._mtx.unlock();
}

注意几点:

  • 把 Span 还给 PageCache 前,要先从 CentralCache 的双链表中摘除;
  • 还给 PageCache 时,需要持有 PageCache 的大锁 _pageMtx
  • 由于还 Span 是 "慢操作",在这段期间尽量不要长时间持有 CentralCache 的桶锁,因此中间有一次【解锁 + 重新加锁】的过程;
  • Span 的 _pageId_n 不能清空,否则这块大内存就完全失去定位信息了。

10. PageCache 回收 Span(大块内存回收与合并)

当 CentralCache 中某个 Span 的 _usecount 减为 0 时,CentralCache 会将这个 Span 还给 PageCache。

看起来似乎只要 "挂回对应桶的链表" 就结束了,但为了 缓解外部碎片,PageCache 还会尝试与前后的空闲 Span 做合并。

10.1 PageCache 前后页合并策略

当 CentralCache 把一个完全空闲的 Span 还给 PageCache 时,PageCache 会尝试把它和前后相邻的空闲 Span 进行合并,来缓解外部碎片问题。

  • 假设归还的 Span 起始页号为 num,管理页数为 n。

PageCache 的合并策略分为两步:

1️⃣ 向前合并(prev merge):

  • 查看第 num - 1 页是否属于某个空闲 Span;
  • 如果存在,并且该 Span 不在使用(_isUse == false),并且合并后总页数不超过 128 页,则可以与当前 span 合并;
  • 合并后需要更新当前 span 的 _pageId_n
  • 并从对应桶的链表中删除被合并的 span;
  • 合并后继续尝试向前合并,直到无法再合并。

2️⃣ 向后合并(next merge):

  • 类似地,查看第 num + n 页是否属于某个空闲 Span;
  • 条件满足则合并,更新页数并继续向后尝试合并。

3️⃣ 为什么要维护页号 → span 的映射关系?

PageCache 需要根据页号快速找到对应的 span。

  • CentralCache 回收小对象时,通过 对象地址 → 页号 → span 找到该对象属于哪个 span;
  • PageCache 做前后页合并时,需要根据页号查看相邻 span 是否存在。

因此 PageCache 必须维护 _idSpanMap

4️⃣ span 的使用状态标记:_isUse

在 Span 结构中增加 _isUse 字段,用于标记 span 是否正在被 CentralCache 使用:

cpp 复制代码
// Span管理一个跨度的大块内存, 管理以页为单位的大块内存
struct Span
{
	// ......省略
	
	bool _isUse = false;	// 是否在使用
};

CentralCache 拿到 span 后:

cpp 复制代码
span->_isUse = true;

CentralCache 归还 span 到 PageCache 时:

cpp 复制代码
span->_isUse = false;

5️⃣ 两类页号映射(重点)

PageCache 的 _idSpanMap 中的页号条目分两类:

  • 分配给 CentralCache 的 k 页 Span,必须建立 每一页 到 span 的映射,用于对象回收时查找 span。
  • 仍在 PageCache 中的剩余 span(n-k 页),只需要建立 首尾页 → span 的映射,用于 PageCache 做前后页合并。

因此,当我们把一个 n 页的 span 切分为:

  • k 页的 kSpan(给 CentralCache 使用)
  • n-k 页的 remainSpan(挂回 PageCache)

所以,我们在 PageCache::NewSpan(k) 新增首尾页映射逻辑:

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

    // 1. 优先从第 k 号桶中直接取
    if (!_spanLists[k].Empty())
    {
        Span* kSpan = _spanLists[k].PopFront();

        // 建立"每一页 → span"的映射
        for (PAGE_ID j = 0; j < kSpan->_n; ++j)
        {
            _idSpanMap[kSpan->_pageId + j] = kSpan;
        }

        return kSpan;
    }

    // 2. 从后续桶中找更大的 span,进行切分
    for (size_t i = k + 1; i < NPAGES; ++i)
    {
        if (!_spanLists[i].Empty())
        {
            // ... 切分 nSpan 得到 kSpan 和剩余 (i-k) 页的 remainSpan ...

            // 对剩余 span 建立首尾页映射
            _idSpanMap[nSpan->_pageId] = nSpan; 
            _idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;

            // 对 kSpan 建立每一页映射
            for (PAGE_ID j = 0; j < kSpan->_n; ++j)
            {
                _idSpanMap[kSpan->_pageId + j] = kSpan;
            }

            return kSpan;
        }
    }

    // 3. 找不到 span,则向系统申请 128 页 ...
}

下面这个 ReleaseSpanToPageCache 就实现了上述合并逻辑:

cpp 复制代码
// 释放空闲 span 回到 PageCache,并尝试合并相邻的空闲 span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
    /******************** 1️⃣ 向前合并(prev merge)********************/
    while (1)
    {
        // 前一个页的页号:如果它属于某个 span,说明前面可能存在可合并的空闲块
        PAGE_ID prevId = span->_pageId - 1;
        auto ret = _idSpanMap.find(prevId);

        // ① 找不到 prevId 所对应的 span ------ 前面不存在可合并的 span
        if (ret == _idSpanMap.end())
        {
            break;
        }

        Span* prevSpan = ret->second;

        // ② 前面的 span 正在使用(来自 CentralCache),不能参与合并
        if (prevSpan->_isUse)
        {
            break;
        }

        // ③ 合并后页数超过 PageCache 能管理的上限(128 页),不能合并
        if (prevSpan->_n + span->_n > NPAGES - 1)
        {
            break;
        }

        // ④ 满足所有条件,进行向前合并:
        //    - 新 span 的起始页号变成前一个 span 的起始页号
        //    - 页数累加
        span->_pageId = prevSpan->_pageId;
        span->_n     += prevSpan->_n;

        // prevSpan 已被合并,需要从它所在桶中移除并释放掉结构
        _spanLists[prevSpan->_n].Erase(prevSpan);
        delete prevSpan;

        // 合并后继续 while(1),尝试再向前看看是否还可以继续合并
    }


    /******************** 2️⃣ 向后合并(next merge)********************/
    while (1)
    {
        // 下一个 span 的起始页号 = 当前 span 的 "最后一页 + 1"
        PAGE_ID nextId = span->_pageId + span->_n;
        auto ret = _idSpanMap.find(nextId);

        // ① 没找到 nextId ------ 后面不存在可合并的 span
        if (ret == _idSpanMap.end())
        {
            break;
        }

        Span* nextSpan = ret->second;

        // ② 后面的 span 正在使用,不能参与合并
        if (nextSpan->_isUse)
        {
            break;
        }

        // ③ 合并后页数超过 PageCache 能管理的最大页数,不能合并
        if (nextSpan->_n + span->_n > NPAGES - 1)
        {
            break;
        }

        // ④ 满足所有条件,向后合并
        span->_n += nextSpan->_n;

        // 合并掉 nextSpan,需从对应桶删除并释放结构
        _spanLists[nextSpan->_n].Erase(nextSpan);
        delete nextSpan;

        // 合并后继续尝试向后探索
    }


    /******************** 3️⃣ 合并完成,将 span 挂回 PageCache ********************/
    // 当前 span 的页数已变化,因此放回它对应页数的桶
    _spanLists[span->_n].PushFront(span);

    // 标记为"未使用"状态(中央缓存不再占用)
    span->_isUse = false;

    // 更新首页与末页在映射表中的映射,用于后续合并查找
    _idSpanMap[span->_pageId]                = span;   // 首页
    _idSpanMap[span->_pageId + span->_n - 1] = span;   // 末页
}

11. 申请内存 和 释放内存 过程联调

1️⃣ 统一对外申请接口:ConcurrentAlloc

在将 ThreadCache、CentralCache 和 PageCache 各自的申请流程打通之后,我们可以对外提供一个统一的内存申请接口 ConcurrentAlloc。

每个线程第一次调用该函数时,会通过 TLS(线程局部存储)懒初始化一个 "只属于自己的 ThreadCache 对象",之后该线程的所有小对象申请,都会通过自己的 ThreadCache 完成,无需加锁。

cpp 复制代码
// 多线程无锁、小对象高效申请接口
static void* ConcurrentAlloc(size_t size)
{
    // 通过 TLS(Thread Local Storage)
    // 每个线程拥有独立的 ThreadCache 对象,互不干扰,无需加锁
    if (pTLSThreadCache == nullptr)
    {
        pTLSThreadCache = new ThreadCache;
    }

    // 打印线程ID和 ThreadCache 的地址,可用于验证"每线程一个"
    cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;

    // 将申请操作交给 ThreadCache 处理
    // 内部会走:FreeList → CentralCache → PageCache
    return pTLSThreadCache->Allocate(size);
}

这也是整个并发内存池性能的关键点之一:

  • 小对象分配流程基本都停留在线程本地,不需要竞争 CentralCache 的锁,更不需要直接触达 PageCache 或系统堆。

2️⃣ 统一对外释放接口:ConcurrentFree

至此,我们已经打通了:

  • ThreadCache 的释放流程;
  • CentralCache 回收小对象 + 还 Span 到 PageCache 的流程;
  • PageCache 回收 Span 并进行前后合并的流程。

现在可以对外提供统一的释放接口 ConcurrentFree:

cpp 复制代码
// 多线程无锁、小对象高效释放接口
static void ConcurrentFree(void* ptr, size_t size)
{
    // 每个线程释放自己分配的对象,因此一定存在对应的 ThreadCache
    assert(pTLSThreadCache);

    // 把对象还回线程自己的 ThreadCache
    // 内部会根据 size 找对应 FreeList,然后根据链表策略可能返还给 CentralCache
    pTLSThreadCache->Deallocate(ptr, size);
}

12. 大于 256KB 的大块内存申请问题

12.1 大块内存的申请过程

前面我们约定:

  • ThreadCache 只负责处理 小于等于 256KB 的小块内存;
  • 对于 大于 256KB 的内存申请,就不再走 ThreadCache,而是走 PageCache 或直接向系统堆申请。

在 PageCache 中,我们最多显式管理 128 页 的 Span(NPAGES = 129,0 号桶空着,1 ~ 128 号桶分别代表 1 ~ 128 页)。

因此从 "页" 的角度来看,逻辑可以整理成下表:

假设:1 页 = 8KB,则 256KB = 32 页

申请内存的大小(按页换算后) 申请方式
x <= 256KB(<= 32 页) 向 ThreadCache 申请
32 页 < x <= 128 页 向 PageCache 申请 Span
x > 128 页 直接向堆申请(系统分配)

当申请的内存 大于 256KB 时:

  • 不再使用 ThreadCache;
  • 仍然需要向上对齐,但此时对齐单位不再是 8/16/128 字节,而是按页大小对齐。

因此我们需要对 RoundUp 做一个小改造:

  • bytes > 256 * 1024 时,不再 assert(false),而是按页大小进行对齐:

代码如下:

cpp 复制代码
// 获取向上对齐后的字节数
static inline size_t RoundUp(size_t bytes)
{
    if (bytes <= 128)
    {
        return _RoundUp(bytes, 8);
    }
    else if (bytes <= 1024)
    {
        return _RoundUp(bytes, 16);
    }
    else if (bytes <= 8 * 1024)
    {
        return _RoundUp(bytes, 128);
    }
    else if (bytes <= 64 * 1024)
    {
        return _RoundUp(bytes, 1024);
    }
    else if (bytes <= 256 * 1024)
    {
        return _RoundUp(bytes, 8 * 1024);
    }
    else
    {
        // 大于 256KB 的按"页大小"对齐(1 << PAGE_SHIFT)
        return _RoundUp(bytes, 1 << PAGE_SHIFT);
    }
}

这样一来:

  • 对于 "小对象":仍用原有的 8 / 16 / 128 / 1024 / 8K 对齐规则,方便映射到正确的桶;
  • 对于 "超过 256KB 的大对象":直接对齐到页边界,方便 PageCache 或系统分配整页。

12.1.1 ConcurrentAlloc 支持大块内存

size > MAX_BYTES(即 256KB)时,申请流程改为:

  • 先用 RoundUp 对齐到页大小的整数倍;
  • 换算出页数 kpage;
  • 通过 PageCache 的 NewSpan(kpage) 申请对应页数的 Span;
  • 将 Span 的起始页号转换成真实地址返回。

否则(小于等于 256KB),仍然走 ThreadCache。

cpp 复制代码
// 申请内存对象(对外统一接口)
static void* ConcurrentAlloc(size_t size)
{
    // ---------------------------
    // 大对象(> 256KB)直接走 PageCache
    // ---------------------------
    if (size > MAX_BYTES)    // MAX_BYTES = 256KB
    {
        // 1. 对齐,大对象按页对齐(8KB)
        size_t alignSize = SizeClass::RoundUp(size);

        // 计算需要多少页:对齐后的字节数 / 8KB
        size_t kpage = alignSize >> PAGE_SHIFT;

        // 2. PageCache 是全局共享结构,需要大锁保护
        PageCache::getInstance()->_pageMtx.lock();

        // 向 PageCache 申请 k 页的大 Span
        Span* span = PageCache::getInstance()->NewSpan(kpage);

        // 记录原始对象大小(用于释放时判断走 PageCache 还是走 ThreadCache)
        span->_objSize = size;

        PageCache::getInstance()->_pageMtx.unlock();

        // 3. 根据页号计算返回的真实地址
        void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
        return ptr;
    }


    // ---------------------------
    // 小对象(≤ 256KB)走 ThreadCache(TLS)
    // ---------------------------
    else
    {
        // 每个线程第一次申请小对象时会创建自己的 ThreadCache
        // pTLSthreadcache 是线程局部存储(TLS),互不影响,不需要加锁
        if (pTLSthreadcache == nullptr)
        {
            pTLSthreadcache = new ThreadCache;
        }

        // 将小对象申请交给 ThreadCache
        // ThreadCache → FreeList → CentralCache → PageCache(逐级回退)
        return pTLSthreadcache->Allocate(size);
    }
}

注意:

  • 小对象分配走 ThreadCache,无锁高并发;
  • 大对象分配直接走 PageCache / 系统,避免小对象路径承担过重逻辑,简化 fast path。

12.1.2 PageCache::NewSpan 对大页数申请的改造

前面我们实现 NewSpan(k) 时,默认 k <= 128

现在为了支持大块内存,需要对 NewSpan 做一个扩展:

k <= 128 时:

  • 仍然从 PageCache 管理的 Span 链表中找;

k > 128(即大于 PageCache 显式管理上限)时:

  • 直接向堆申请 k 页;
  • 构造一个新的 Span,记录页号与页数;
  • 建立页号与 Span 的映射关系(至少要记录首页),以便释放时能查回这个 Span。

代码如下:

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

    // ----------------------------------------------------
    // 情况一:K > 128 页(超过 PageCache 可管理的最大页数)
    // ----------------------------------------------------
    // PageCache 设计中,NPAGES = 129,对应能够管理的页数区间为:
    //   1 ~ 128 页:挂在 _spanLists[1..128] 上
    // 大于 128 页的请求,不再走 PageCache 的页桶管理逻辑,
    // 而是直接向系统堆申请一整块连续内存。
    //
    // 这类"大块申请"的典型场景:
    //   - 用户一次性申请了非常大的内存(比如 > 1MB,甚至几十MB)
    //   - 不适合和小对象的 span 混在 PageCache 的桶里管理
    //
    // 释放时则走:ptr → 页号 → Span → 判断 n > 128 → 直接 SystemFree
    // ----------------------------------------------------
    if (k > NPAGES - 1) // NPAGES - 1 == 128
    {
        // 1. 向系统申请 k 页的连续虚拟内存
        //    SystemAlloc(k) 里通常是 VirtualAlloc / mmap 等系统调用,
        //    这里按"页"为单位进行申请。
        void* ptr = SystemAlloc(k);

        // 2. 为这块大内存包装一个 Span 结构,用于后续释放时的管理
        Span* span = new Span;

        // 起始页号 = 起始地址 >> PAGE_SHIFT(相当于 / 8KB)
        span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
        span->_n      = k;  // 页数为 k

        // 3. 建立【首页页号 → Span】的映射
        //    这样在释放时,通过 ptr 计算出首页页号,
        //    就可以从 _idSpanMap 中找到对应的 Span,再决定如何释放。
        _idSpanMap[span->_pageId] = span;

        // 注意:这里没有挂入 _spanLists[],因为它不参与 PageCache 的
        //       "页桶 + 前后合并"管理,属于"直接向系统要 & 直接还给系统"的大块。
        return span;
    }

    // ----------------------------------------------------
    // 情况二:1 <= K <= 128 页
    // ----------------------------------------------------
    // 下面就是之前"小于等于 128 页"的逻辑:
    //   - 优先从 _spanLists[k] 取
    //   - 否则去后面的桶找更大的 span 切分
    //   - 再否则向系统申请 128 页,并切分使用
    // ----------------------------------------------------
    // ...... 下方是"小于等于 128 页"时的逻辑(略)
}

这里的大块 Span 不再参与 PageCache 的 "按页数分桶管理",只需要保证:

  • 申请时能找到对象对应的 Span;
  • 释放时能正确把这整块内存交还给系统即可。

12.2 大块内存的释放过程

释放时同样需要区分 "小对象" 和 "大对象"。整体逻辑与申请对称:

假设:1 页 = 8KB,则 256KB = 32 页

释放内存的大小(按页换算后) 释放方式
x <= 256KB(<= 32 页) 释放给 ThreadCache
32 页 < x <= 128 页 释放给 PageCache,尝试合并
x > 128 页 释放给堆(调用 SystemFree)

释放逻辑关键点如下。

对于 size <= MAX_BYTES 的 "小对象":

  • 仍按照之前的逻辑:通过 ThreadCache 对应的 FreeList 归还给 【CentralCache → PageCache】;

对于 size > MAX_BYTES 的 "大对象":

  • 需要先通过 ptr 找到对应的 Span;
  • 再根据 Span 的页数决定:
    • 如果 _n <= 128:交给 PageCache 的 ReleaseSpanToPageCache,由 PageCache 合并;
    • 如果 _n > 128:说明是直接向堆申请的,直接还给系统。

下面是 ConcurrentFree 的实现:

cpp 复制代码
// 释放内存对象(对外统一接口)
static void ConcurrentFree(void* ptr, size_t size)
{
    // ---------------------------
    // 情况一:大对象(> 256KB)
    // ---------------------------
    if (size > MAX_BYTES)
    {
        // 对于大于 256KB 的对象,当时申请时是直接通过 PageCache::NewSpan
        // 申请的一整块页(Span),现在释放时:
        // 1)只有一个 ptr(对象起始地址)
        // 2)需要通过 ptr 找回所属的 Span
        //    - 先把 ptr 转成页号:pageId = ptr >> PAGE_SHIFT
        //    - 再在 PageCache::_idSpanMap 中查找 pageId 对应的 Span*
        Span* span = PageCache::getInstance()->MapObjectToSpan(ptr);

        // PageCache 是全局共享结构,内部有 span 切分、合并等操作,
        // 需要用一把大锁保护。
        PageCache::getInstance()->_pageMtx.lock();

        // 把这个 Span 还给 PageCache:
        // - 如果 span->_n <= 128 页:挂回 PageCache,并尝试与前后空闲 span 合并;
        // - 如果 span->_n > 128 页:说明当初是直接向堆申请的,
        //   此时直接 SystemFree 归还给系统。
        PageCache::getInstance()->ReleaseSpanToPageCache(span);

        PageCache::getInstance()->_pageMtx.unlock();
    }
    // ---------------------------
    // 情况二:小对象(≤ 256KB)
    // ---------------------------
    else
    {
        // 小对象一直是走 ThreadCache 这一层:
        // 1)根据 size 算出它应该在 ThreadCache 的哪个 FreeList 桶;
        // 2)头插到对应自由链表;
        // 3)如果链表长度过长,会触发 ListTooLong,把一段对象还给 CentralCache。
        assert(pTLSthreadcache);
        pTLSthreadcache->Deallocate(ptr, size);
    }
}

这里有个隐含设计点:

  • 大块内存也是通过 Span 管理的,只不过这些 Span 不挂在 PageCache 的 1~128 页桶链表 中,而是作为 "直接来自堆的大块",专门路径管理。

因此 申请大块时也必须建立 【页号 → Span】 的映射,否则释放时无法定位。

12.2.1 PageCache 回收大块 Span

ReleaseSpanToPageCache 中,PageCache 需要判断:

如果 span->_n > 128

  • 说明这个 Span 是直接通过 SystemAlloc(k) 从堆申请的;
  • 么回收时就应该直接 SystemFree 给堆,而不是挂回 PageCache;

如果 span->_n <= 128

  • 说明这是 PageCache 管理范围内的 Span;
  • 按之前的逻辑:尝试与前后空闲 Span 合并,然后挂回对应桶。

代码如下:

cpp 复制代码
// 释放空闲 span 回到 PageCache,并尝试合并相邻的空闲 span
void PageCache::ReleaseSpanToPageCache(Span* span)
{
    // --------------------------------------------------------
    // 情况一:大页 span(页数 > 128)
    // --------------------------------------------------------
    // 说明:
    //   - 这类 span 是在 NewSpan(k) 时,k > 128,直接通过 SystemAlloc(k) 向堆申请的;
    //   - 并没有挂在 PageCache 的 _spanLists[1..128] 链表中;
    //   - PageCache 对它不做页桶管理,也不参与前后合并。
    // 释放策略:
    //   - 直接把这块大内存还给系统堆;
    //   - 然后 delete 掉这个 span 结构本身。
    // --------------------------------------------------------
    if (span->_n > NPAGES - 1)  // NPAGES - 1 == 128
    {
        // 通过 pageId 还原出这段内存的起始地址:
        //   ptr = pageId << PAGE_SHIFT  (相当于 pageId * 8KB)
        void* ptr = (void*)(span->_pageId << PAGE_SHIFT);

        // 归还给操作系统(内部是 VirtualFree / munmap 等)
        SystemFree(ptr);

        // 释放包装这个大块内存的 Span 结构本身
        delete span;

        return;
    }

    // --------------------------------------------------------
    // 情况二:1 ~ 128 页的 span
    // --------------------------------------------------------
    // 这种 span 是 PageCache 负责管理的普通页块,需要:
    //   1)尝试与前后相邻的空闲 span 合并,缓解外部碎片;
    //   2)合并完之后再挂回 _spanLists[页数] 链表;
    //   3)重新建立"首页 / 末页 → span"的映射,方便后续继续合并。
    // --------------------------------------------------------
    // ......省略
}

12.2.2 SystemFree 封装说明

与 SystemAlloc 类似,我们也对释放接口做了一层封装 SystemFree,屏蔽不同平台上的具体系统调用。

在 Windows 下:

  • 申请:VirtualAlloc
  • 释放:VirtualFree

在 Linux 下:

  • 典型实现可能使用 mmap/munmap 或 sbrk/brk,这里可以统一封装在 SystemAlloc/SystemFree 中。

代码如下:

cpp 复制代码
// 直接将内存还给堆
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
    VirtualFree(ptr, 0, MEM_RELEASE);
#else
    // Linux 下对应的 munmap/sbrk 等实现
#endif
}

这样无论是 PageCache 中释放 "小于等于 128 页" 的 Span,还是释放 "大于 128 页" 的直接堆 Span,都可以通过统一接口 SystemFree 与系统堆交互,便于移植和维护。

13. 使用定长内存池配合脱离使用 new

tcmalloc 的目标是在高并发场景下替代 malloc 做内存分配,因此它在内部实现时,不能再依赖 malloc / new。

我们当前代码中还存在一些使用 new 的地方,比如:

  • 为 Span 对象申请空间;
  • 为 ThreadCache 对象申请空间;
  • 为 SpanList 的头结点申请空间。

而 new 底层实际上就是封装了 malloc,这和 "替代系统分配器" 的目标是冲突的。

这时候,前面实现的定长内存池 ObjectPool<T> 就派上用场了。

13.1 使用定长内存池管理 Span 对象

观察代码可以发现:

  • Span 对象的创建、销毁主要发生在 PageCache 层(包含切分、合并等操作);
  • Span 的大小是固定的,非常适合作为定长对象放进 ObjectPool<Span> 管理。

因此,我们在 PageCache 中增加一个成员 _spanPool,专门用来申请/释放 Span 对象:

cpp 复制代码
// 单例模式
class PageCache
{
public:
    // ...... 代码省略

private:
    // 使用定长内存池管理 Span 对象,彻底避免对 Span 使用 new/delete
    ObjectPool<Span> _spanPool;
};

然后,把原来所有类似这样的代码:

cpp 复制代码
Span* span = new Span;
// ...
delete span;

都替换为:

cpp 复制代码
Span* span = _spanPool.New();
// ...
_spanPool.Delete(span);

注意:

  • ObjectPool::New() 内部使用定位 new 调用了 Span 的构造函数,因此对象初始化不会丢失;
  • ObjectPool::Delete() 会显式调用析构,再把内存块挂回对象池,整个流程不会依赖系统 malloc / free。

并且,Span 对象全部由 PageCache 内部的 ObjectPool<Span> 管理,PageCache 不再对 Span 使用 new / delete。

13.2 使用定长内存池管理 ThreadCache 对象

每个线程第一次申请内存时,会创建一个属于自己的 ThreadCache。之前我们是直接 new ThreadCache,这同样会落到系统分配器上。

我们可以把 ThreadCache 的创建也改成走一个全局共享的定长内存池:

cpp 复制代码
// 申请
static void* ConcurrentAlloc(size_t size)
{
	if (size > MAX_BYTES)	// 如果申请的内存大于256KB
	{
			// ......省略
	}
	else
	{
		if (pTLSthreadcache == nullptr)
		{
			//pTLSthreadcache = new ThreadCache;

			// 也是使用定长内存池进行替换
			static ObjectPool<ThreadCache> tcPool;
			pTLSthreadcache = tcPool.New();
		}
		return pTLSthreadcache->Allocate(size);		
	}

说明几点:

  • tcPool 定义为 static,保证全局只有这一个 ThreadCache 对象池;
  • 各线程第一次申请自己的 ThreadCache 时,都从这个池里拿一块空间;

实际上 ThreadCache 是 "线程生命周期级别" 的对象,一般不主动释放,进程结束统一交给系统回收即可,所以我们通常不会对 ThreadCache 调用 Delete。

13.3 SpanList 头结点也用定长内存池

SpanList 是一个带头结点的双向循环链表,构造函数中会为头结点 _head 申请一个 Span 对象。原始写法是:

cpp 复制代码
SpanList()
{
    _head = new Span;
    _head->_next = _head;
    _head->_prev = _head;
}

为了彻底脱离 new,我们也可以用一个静态的 ObjectPool<Span> 来管理所有头结点:

cpp 复制代码
// 带头双向循环链表
class SpanList
{
public:
    SpanList()
    {
        // 之前直接使用 new,为了彻底替代 new/malloc,这里也改用对象池
        static ObjectPool<Span> spanPool;
        _head = spanPool.New();

        _head->_next = _head;
        _head->_prev = _head;
    }
	
    // ..... 省略其他方法

private:
    Span* _head = nullptr;	// 头结点

public:
    // 如果两个线程访问同一个桶, 那么就会存在竞争, 故而需要加锁
    std::mutex _mtx;	    // 桶锁
};

说明:

  • 每个 SpanList 实例只需要一个头结点;
  • 由于 SpanList 通常在内存池初始化阶段构造(比如 CentralCache、PageCache 的 _spanLists 是静态数组),这一步通常发生在单线程启动阶段,这里不加锁也不会有问题;

到这一步为止:

  • 所有 Span 对象(包括普通 Span 和 SpanList 头结点)都不再通过 new 申请;
  • ThreadCache 对象也不再通过 new 申请;
  • 整个内存池内部已经完成了 "彻底脱离 new / malloc"。

14. 释放对象时优化为 "不传 size"

14.1 为什么一开始释放需要传 size

目前我们实现的内存池,在释放时接口是这样的:

cpp 复制代码
static void ConcurrentFree(void* ptr, size_t size);

之所以需要传 size,原因有两个:

1️⃣ 区分大对象 / 小对象释放路径

  • 如果 size > 256KB:需要判断这块内存应该还给 PageCache,还是直接还给堆(大于 128 页的情况)。
  • 如果 size <= 256KB:需要根据 size 算出映射的 FreeList 下标,释放给对应的 ThreadCache 哈希桶。

2️⃣ ThreadCache 需要按 size 找桶

  • 小对象释放时,需要知道 "应该还回哪个 bucket(自由链表)"。

但从 "使用体验" 的角度来说:

  • malloc 只在 "申请" 时传大小,free 在 "释放" 时只需要一个指针,不需要再次传 size。

既然我们已经建立了比较完备的 Span 管理体系,并且可以从对象地址找到对应的 Span,那是否也能做到 释放时只传指针,不传 size 呢?

答案是:可以。关键是 ------ 把 "对象大小" 也放到 Span 上。

14.2 在 Span 上记录对象大小 _objSize

我们知道:

  • 所有小对象都是从某个 Span 切出来的;
  • 同一个 Span 的 _freelist 中挂的一定是相同大小的对象;
  • 因此我们完全可以把 "单个对象的大小" 记在 Span 里。

在 Span 结构中增加一个成员:

cpp 复制代码
// Span 管理一个跨度的大块内存,以页为单位
struct Span
{
    // .....省略其他字段

    size_t _objSize = 0;	// 切出来的单个对象的大小(对齐后的大小)
};

接着,在 CentralCache 刚从 PageCache 拿到一个 Span 时,就把这个 Span 对应的对象大小记录下来。

cpp 复制代码
// 从 SpanList 或者 PageCache 获取一个非空的 span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
    // ...... 省略前面的"在当前桶遍历 Span"的逻辑

    PageCache::getInstance()->_pageMtx.lock();
    Span* span = PageCache::getInstance()->NewSpan(SizeClass::NumMovePage(size));
    span->_isUse = true;
    span->_objSize = size;  // 记录该 Span 切分出来的单个对象大小
    PageCache::getInstance()->_pageMtx.unlock();

    // ...... 然后对 span 进行切分并挂入 CentralCache
}

对于大于 256KB 的大对象:

  • 申请时我们同样调用 NewSpan(kpage) 拿到一个 Span;
  • 也需要在拿到 Span 后给 _objSize 赋值(原始申请大小):
cpp 复制代码
// 申请:统一入口
static void* ConcurrentAlloc(size_t size)
{
    // 大于 256KB 的大对象,走 PageCache / 系统堆路径
    if (size > MAX_BYTES)
    {
        // 1. 加 PageCache 的大锁,从 PageCache 申请 k 页的 Span
        PageCache::getInstance()->_pageMtx.lock();
        Span* span = PageCache::getInstance()->NewSpan(kpage);

        // 2. 记录该 span 中"单个对象"的大小。
        //    对于大对象,这里其实就是整块内存本身的大小(对齐前的原始 size),
        //    以后释放时只需要根据 ptr 找到 span,再从 span->_objSize 取回 size 信息。
        span->_objSize = size;

        PageCache::getInstance()->_pageMtx.unlock();

        // 3. 将页号转换成真实的起始地址返回给调用方
        void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
        return ptr;
    }
    else
    {
        // 小于等于 256KB 的小对象,走 ThreadCache → CentralCache → PageCache 路径

        // 通过 TLS 每个线程可以无锁地获取自己专属的 ThreadCache 对象
        if (pTLSthreadcache == nullptr)
        {
            pTLSthreadcache = new ThreadCache;
        }

        return pTLSthreadcache->Allocate(size);
    }
}

注意:

  • 对于 "小对象":_objSize 是向上对齐后的大小(例如申请 7 字节 → 实际 _objSize = 8);
  • 对于 "大块内存":_objSize 可以记录用户申请的逻辑大小或对齐后的大小,主要用于判断释放路径。

14.3 释放时不再需要传 size

有了 _objSize 后,释放流程可以变成:

  • 通过指针 ptr 找到对应的 Span;
  • 通过 span->_objSize 得到该对象的大小;
  • 根据大小判断释放路径(ThreadCache / PageCache / 堆)。

于是 ConcurrentFree 接口就可以简化为:

cpp 复制代码
// 释放:只需要一个指针,不再需要 size
static void ConcurrentFree(void* ptr)
{
    // 1. 通过对象地址找到它属于哪个 Span
    //    (内部:ptr -> 页号 -> span)
    Span* span = PageCache::getInstance()->MapObjectToSpan(ptr);

    // 2. 从 Span 中取出当初分配时记录的对象大小(已经是对齐之后的 size)
    size_t size = span->_objSize;

    // 3. 按大小走不同的释放路径:
    //    - 大于 256KB:大块内存,交给 PageCache / 堆;
    //    - 小于等于 256KB:小对象,交给当前线程的 ThreadCache。
    if (size > MAX_BYTES)
    {
        PageCache::getInstance()->_pageMtx.lock();
        PageCache::getInstance()->ReleaseSpanToPageCache(span);
        PageCache::getInstance()->_pageMtx.unlock();
    }
    else
    {
        // 小对象:走 ThreadCache 的 Deallocate 流程
        assert(pTLSthreadcache);
        pTLSthreadcache->Deallocate(ptr, size);
    }
}

这样一来,外部调用体验就和 malloc / free 类似了:

  • 分配:void* p = ConcurrentAlloc(size);
  • 释放:ConcurrentFree(p);

14.4 在 ObjectPool 中修复多线程竞态问题

具体问题出现在内存池的 New 方法中:

  • 当多个线程并发调用 ObjectPool::New() 时,若没有加锁,会导致多个线程同时访问 _freeList,进而产生空指针访问、内存越界等问题。

为了解决这个问题,我们在获取对象和内存块申请过程中增加了必要的锁,防止多线程竞争时发生错误。

修改后的代码:加锁防止竞态

cpp 复制代码
// 定长内存池
template<class T>
class ObjectPool
{
public:
    T* New()
    {
        T* obj = nullptr;

        // 使用锁保护,确保多个线程调用时不会发生数据竞争
        std::unique_lock<std::mutex> lock(_mtx);

        // 如果自由链表有对象,直接取一个
        if (_freeList != nullptr)
        {
            void* next = *((void**)_freeList); // 取第一个结点
            obj = (T*)_freeList;
            _freeList = next;
        }
        else  // 如果链表为空, 那么就去申请新的内存
        {
            // 如果剩余内存不够一个对象大小时, 重新开大块空间
            if (_remainBytes < sizeof(T))
            {
                _remainBytes = 128 * 1024;
                // _memory = (char*)malloc(_remainBytes);
                _memory = (char*)SystemAlloc(_remainBytes >> 13); // 内存对齐为页
                if (_memory == nullptr)
                {
                    throw std::bad_alloc(); // 内存分配失败
                }
            }

            // 如果剩余内存足够一个对象大小时
            obj = (T*)_memory;
            size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T); // 对齐大小
            _memory += objSize; // 更新内存偏移
            _remainBytes -= objSize; // 更新剩余内存大小
        }

        // 使用定位 new 调用 T 的构造函数初始化
        new(obj) T;  // 调用对象的构造函数
        return obj;
    }

private:
    void* _freeList = nullptr;  // 自由链表
    size_t _remainBytes = 0;    // 剩余内存字节数
    char* _memory = nullptr;    // 当前分配的大块内存
    std::mutex _mtx;            // 保护内存池的锁
};

我们在 New 函数开始时加了一个 std::unique_lock<std::mutex> lock(_mtx) 锁,确保只有一个线程可以进入申请内存和处理 freeList 的逻辑。

这样可以保证多个线程在并发访问 _freeList 时不会同时修改它,避免空指针解引用和数据竞争。对于大块内存的申请,仍然保持内存池的单一锁定方式。

15. 多线程并发环境下,对比 malloc 和 ConcurrentAlloc 的效率

这一节我们在多线程场景下,对比系统的 malloc / free 与我们自己实现的 ConcurrentAlloc / ConcurrentFree 在内存申请和释放上的性能差异。

测试代码如下:

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1

// 对项目进行综合测试
#include "ConcurrentAlloc.h"

// ntimes 一轮申请和释放内存的次数
// rounds 轮次
// nworks 线程数量

// 测试 malloc / free
void BenchmarkMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
    std::vector<std::thread> vthread(nworks);
    std::atomic<size_t> malloc_costtime = 0;
    std::atomic<size_t> free_costtime = 0;

    for (size_t k = 0; k < nworks; ++k)
    {
        vthread[k] = std::thread([&, k]() {
            std::vector<void*> v;
            v.reserve(ntimes);

            for (size_t j = 0; j < rounds; ++j)
            {
                size_t begin1 = clock();
                for (size_t i = 0; i < ntimes; i++)
                {
                    v.push_back(malloc(16));
                    // v.push_back(malloc((16 + i) % 8192 + 1));
                }
                size_t end1 = clock();

                size_t begin2 = clock();
                for (size_t i = 0; i < ntimes; i++)
                {
                    free(v[i]);
                }
                size_t end2 = clock();
                v.clear();

                malloc_costtime += (end1 - begin1);
                free_costtime   += (end2 - begin2);
            }
        });
    }

    for (auto& t : vthread)
    {
        t.join();
    }

    printf("%u 个线程并发执行 %u 轮次, 每轮 malloc 了 %u 次, 花费 %u ms\n",
        nworks, rounds, ntimes, malloc_costtime.load());

    printf("%u 个线程并发执行 %u 轮次, 每轮 free 了 %u 次, 花费 %u ms\n",
        nworks, rounds, ntimes, free_costtime.load());

    printf("%u 个线程并发 malloc && free 了 %u 次, 总计花费 %u ms\n",
        nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}


// 测试自己写的高并发内存池
// ntimes:单轮次申请/释放次数 
// nworks:线程数 
// rounds:轮次
void BenchmarkConcurrentMalloc(size_t ntimes, size_t nworks, size_t rounds)
{
    std::vector<std::thread> vthread(nworks);
    std::atomic<size_t> malloc_costtime = 0;
    std::atomic<size_t> free_costtime = 0;

    for (size_t k = 0; k < nworks; ++k)
    {
        vthread[k] = std::thread([&]() {
            std::vector<void*> v;
            v.reserve(ntimes);

            for (size_t j = 0; j < rounds; ++j)
            {
                size_t begin1 = clock();
                for (size_t i = 0; i < ntimes; i++)
                {
                    v.push_back(ConcurrentAlloc(16));
                    // v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));
                }
                size_t end1 = clock();

                size_t begin2 = clock();
                for (size_t i = 0; i < ntimes; i++)
                {
                    ConcurrentFree(v[i]);
                }
                size_t end2 = clock();
                v.clear();

                malloc_costtime += (end1 - begin1);
                free_costtime   += (end2 - begin2);
            }
        });
    }

    for (auto& t : vthread)
    {
        t.join();
    }

    printf("%u 个线程并发执行 %u 轮次, 每轮 ConcurrentAlloc 了 %u 次, 花费 %u ms\n",
        nworks, rounds, ntimes, malloc_costtime.load());

    printf("%u 个线程并发执行 %u 轮次, 每轮 ConcurrentFree 了 %u 次, 花费 %u ms\n",
        nworks, rounds, ntimes, free_costtime.load());

    printf("%u 个线程并发 ConcurrentAlloc && ConcurrentFree 了 %u 次, 总计花费 %u ms\n",
        nworks, nworks * rounds * ntimes, malloc_costtime.load() + free_costtime.load());
}

int main()
{
    size_t n = 10000;
    cout << "==========================================================" << endl;
    BenchmarkConcurrentMalloc(n, 4, 10);
    cout << endl << endl;

    BenchmarkMalloc(n, 4, 10);
    cout << "==========================================================" << endl;

    return 0;
}

参数含义说明:

  • ntimes:单轮次中,每个线程申请和释放内存的次数;
  • nworks:并发线程数;
  • rounds:每个线程重复执行的轮次数。

在测试函数内部,我们用 clock() 分别记录每一轮申请阶段和释放阶段的耗时,并累计到两个原子变量中:

  • malloc_costtime:所有线程在所有轮次中,申请阶段总耗时之和;
  • free_costtime:所有线程在所有轮次中,释放阶段总耗时之和。

15.1 固定大小内存的申请与释放

首先测试固定大小的小对象:

cpp 复制代码
// malloc 版本
v.push_back(malloc(16));
// concurrent 版本
v.push_back(ConcurrentAlloc(16));

测试配置:

  • nworks = 4:4 个线程;
  • rounds = 10:每个线程执行 10 轮;
  • ntimes = 10000:每轮申请/释放 10000 次;

总共每个版本都会执行:4 × 10 × 10000 = 400000 次申请 + 400000 次释放

实际跑下来,可以看到:

  • 在这个 "固定 16 字节" 的场景下,系统的 malloc / free 效率反而略高;
  • 我们的 ConcurrentAlloc / ConcurrentFree 虽然是并发内存池,但在这个特定用例下优势并没有体现出来。

如下图所示:

原因在于:

1️⃣ 所有线程申请的是同一个大小(16 字节)

  • 每个线程的 ThreadCache 都会映射到自己本地的同一个 bucket(比如 "16 字节对应的那个桶");
  • 当本地 ThreadCache 需要从 CentralCache 补货,或者需要把多余对象还给 CentralCache 时:
    • 所有线程都会访问 CentralCache 的同一个桶。

2️⃣ CentralCache 的 "桶锁" 优势发挥不出来

  • 设计 CentralCache 时,我们用 "桶锁" 的目的,是希望:
    • 不同大小的对象分布到不同 bucket;
    • 不同线程访问不同 bucket 时,可以并行而不会互相加锁阻塞。
  • 但是在 "所有人都用 16 字节" 这个极端场景下:
    • 所有 ThreadCache 都集中去抢 CentralCache 中的同一个桶锁,
    • 相当于主动放弃了桶锁带来的 "多桶并行" 优势。

换句话说:在「所有线程只申请一种固定尺寸的小对象」这种极端场景下,我们的设计没有吃到 "多 size、多桶并行" 的红利,甚至还为多层结构付出了一定管理开销,所以跑起来略逊色于成熟的 malloc 是合理的。

15.2 不同大小内存的申请与释放

接下来我们测试一下不同大小的小对象:

cpp 复制代码
// malloc 版本
v.push_back(malloc((16 + i) % 8192 + 1));
// concurrent 版本
v.push_back(ConcurrentAlloc((16 + i) % 8192 + 1));

这里 (16 + i) % 8192 + 1 会让申请的大小在 [1, 8192] 区间内变化,相当于模拟了比较典型的 "真实业务场景":

  • 不同的线程、不同的时间点,申请的对象大小都可能不同;
  • 这些大小经过 SizeClass::RoundUp 对齐映射之后,会落到多个不同的 size class;
  • 从 ThreadCache → CentralCache 的访问,会分布到多个哈希桶。

这时的现象是:

  • 相比固定 16 字节的测试场景,ConcurrentAlloc/ConcurrentFree 的耗时明显下降(相对 malloc);
  • 也就是:我们的并发内存池在 "混合大小小对象" 的场景下,性能提升更加明显,但整体上仍然会比系统 malloc 稍慢一点点。

如下图所示:

原因可以解释为:

1️⃣ CentralCache 的桶锁真正发挥作用了

  • 不同大小的对象落在不同 bucket,
  • 多个线程可以同时访问 CentralCache 的不同桶,锁竞争大幅下降;
  • 每个 ThreadCache 也更容易从 local freelist 命中,减少跨层访问。

2️⃣ ThreadCache 命中率更高

  • 某一线程在一段时间内申请的 size 模式往往有一定局部性;
  • ThreadCache 层的批量获取 + 局部缓存策略,可以显著减少访问 CentralCache 的次数。

16. 性能瓶颈分析

经过前面的测试可以看到,我们的代码此时与 malloc 之间还是有差距的,那么我们可以借助 VS 编译器中自带的性能分析工具的来进行分析。

我们先将 BenchMark.cpp 代码中 n 的值由 10000 改为 1000,否则该分析过程可能会花费较多时间,并且将 malloc 的测试代码进行了屏蔽,因为我们要分析的是我们自己实现的高并发内存池。

cpp 复制代码
int main()
{
	size_t n = 1000;
	cout << "==========================================================" << endl;
	BenchmarkConcurrentMalloc(n, 4, 10);
	cout << endl << endl;

	//BenchmarkMalloc(n, 4, 10);
	cout << "==========================================================" << endl;

	return 0;
}

首先切换到 Debug 模式,点击【调试】→【性能探查器】进行性能分析

因为我们要分析的是各个函数的耗时时间,所以把【检测】勾选上,最后点击【开始】就可以等待分析结果了。

通过分析结果可以看到,光是 Deallocate 和 MapObjectToSpan 这两个函数就占用了一半多的时间。

点击【Deallocate】函数,可以看到调用 ListTooLong 函数时消耗的时间是最多的。

点击【ListTooLong】函数,可以看到调用 ReleaseListToSpans 函数时消耗的时间是最多的。

再点击【ReleaseListToSpans】函数,可以看到调用 MapObjectToSpan 函数时消耗的时间是最多的。

也就是说,最终消耗时间最多的实际就是 MapObjectToSpan 函数,这时我们再来看看为什么调用 MapObjectToSpan 函数会消耗这么多时间。

点击【MapObjectToSpan】函数,通过观察最终发现,调用该函数之所以会消耗这么多时间,是因为【锁】的原因。

因此当前项目的瓶颈点就在锁竞争上面,需要解决调用 MapObjectToSpan 函数访问映射关系时的加锁问题。tcmalloc 当中针对这一点使用了【基数树】进行优化,使得在读取这个映射关系时可以做到不加锁。

17. 针对性能瓶颈使用单层基数树进行优化

基数树(Radix Tree)本质上是一种分层索引结构,你可以简单地把它理解成 "分层的数组 / 哈希表"。

根据分层的层数不同,可以有:

  • 单层基数树(本质就是一个大数组:直接定址)
  • 二层基数树
  • 三层基数树

在 tcmalloc 中,它就是用来做:【页号 → Span 指针】的高效映射。

单层基数树其实就是直接定址表:

  • 每一个页号 page_id 对应的 Span* → 直接存到数组下标为 page_id 的位置;
  • 最坏情况:我们要为所有页号建立映射,因此数组长度 = 页数个数;
  • 数组每个元素存一个 Span*,即 void*

它的结构如下图所示:

代码实现如下:

cpp 复制代码
// 单层基数树:直接用一个大数组做 page_id -> Span* 的映射
template <int BITS>
class TCMalloc_PageMap1 {
private:
    // 数组长度 = 2^BITS
    static const int LENGTH = 1 << BITS;
    void** array_;

public:
    typedef uintptr_t Number;  // 页号类型

    explicit TCMalloc_PageMap1() {
        // 整个数组占用的字节数:sizeof(void*) * 2^BITS
        size_t size      = sizeof(void*) << BITS;
        // 向上按页大小对齐后再向系统申请
        size_t alignSize = SizeClass::_RoundUp(size, 1 << PAGE_SHIFT);
        array_           = (void**)SystemAlloc(alignSize >> PAGE_SHIFT);
        memset(array_, 0, sizeof(void*) << BITS);
    }

    // 读取:返回 key 对应的指针
    // key 超出范围 或 尚未 set 时,返回 nullptr
    void* get(Number k) const {
        if ((k >> BITS) > 0) {
            return nullptr;
        }
        return array_[k];
    }

    // 写入:要求 k 在 [0, 2^BITS - 1] 范围内,且调用前已确保空间有效
    void set(Number k, void* v) {
        array_[k] = v;
    }
};

其中:

  • 非类型模板参数 BITS 表示:页号最多需要多少比特来表示;
  • 在 32 位平台下,如果页大小为 8KB(2^13),页数为 2^32 / 2^13 = 2^19,所以 BITS = 32 - PAGE_SHIFT = 32 - 13 = 19
  • 指针大小为 4 字节,则数组大小为 2^19 * 4 = 2^21 = 2MB,内存可接受;
  • 但在 64 位平台下,如果简单照抄单层基数树:
    • 页数:2^64 / 2^13 = 2^51
    • 数组大小:2^51 * 8 = 2^54 = 16TB 级别,明显不可行,所以 64 位上必须用多层基数树,按层拆分索引空间。

18. 使用基数树优化 PageCache 中的页号映射

接下来就是把 PageCache 里原来的 unordered_map<PAGE_ID, Span*> 换成基数树。

核心思路:

  • 原本 PageCache 用 unordered_map 做:页号 → Span* 映射;
  • 现在改为使用 TCMalloc_PageMap1<32 - PAGE_SHIFT>(在 32 位平台下足够用);

所有对 _idSpanMap 的访问,统一从:map[k] / find(k)set(k, v) / get(k)

18.1 PageCache 结构修改

原来的 PageCache 大致是:

cpp 复制代码
class PageCache
{
public:
    // ... 省略 ...
private:
    SpanList _spanLists[NPAGES];               // 按页数映射的桶

    // std::unordered_map<PAGE_ID, Span*> _idSpanMap;   // 页号 -> Span 的映射

    // 使用 tcmalloc 源码中的基数树实现替代 unordered_map
    TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;

    // 使用定长内存池配合脱离使用 new
    ObjectPool<Span> _spanPool;

    // ... 省略 ...
};

说明:这里为了方便,直接用单层基数树 TCMalloc_PageMap1。在 32 位平台下,32 - PAGE_SHIFT 就是前面算过的 BITS = 19,空间约 2MB,可接受。

18.2 将所有 _idSpanMap 操作替换为基数树

下面是一些典型替换点:

1️⃣ 建立页号 → Span 映射时:

cpp 复制代码
// 原写法:
// _idSpanMap[span->_pageId] = span;
_idSpanMap.set(span->_pageId, span);   // 使用基数树优化

2️⃣ 给 kSpan 的每一页建立映射:

cpp 复制代码
// 原写法:
// _idSpanMap[kSpan->_pageId + i] = kSpan;

_idSpanMap.set(kSpan->_pageId + i, kSpan);   // 使用基数树优化

3️⃣ 给 PageCache 自己桶上的空闲 Span 建立首尾页映射:

cpp 复制代码
// 原写法:
// _idSpanMap[nSpan->_pageId] = nSpan;     // 首页
// _idSpanMap[nSpan->_pageId + nSpan->_n - 1] = nSpan;     // 末页

_idSpanMap.set(nSpan->_pageId, nSpan);   // 首页
_idSpanMap.set(nSpan->_pageId + nSpan->_n - 1, nSpan);   // 末页

4️⃣ 释放时合并用到的首尾页映射:

cpp 复制代码
// 原写法:
// auto ret = _idSpanMap.find(prevId);
// if (ret == _idSpanMap.end()) break;
// Span* prevSpan = ret->second;

// 基数树版本:
Span* prevSpan = (Span*)_idSpanMap.get(prevId);
if (prevSpan == nullptr) {
    break;
}

后面向后合并 nextSpan 的逻辑同理:

cpp 复制代码
Span* nextSpan = (Span*)_idSpanMap.get(nextId);
if (nextSpan == nullptr) {
    break;
}

5️⃣ 合并完成后重新建立首尾映射:

cpp 复制代码
// 原写法:
// _idSpanMap[span->_pageId]                     = span;
// _idSpanMap[span->_pageId + span->_n - 1]      = span;

_idSpanMap.set(span->_pageId,                     span);
_idSpanMap.set(span->_pageId + span->_n - 1,      span);

6️⃣ MapObjectToSpan 改为直接用基数树读:

cpp 复制代码
// 获取从对象到 span 的映射
Span* PageCache::MapObjectToSpan(void* obj)
{
    // 计算对象所在的页号
    PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

    // 使用基数树进行优化(无需加锁)
    Span* ret = (Span*)_idSpanMap.get(id);
    assert(ret != nullptr);
    return ret;
}

19. 基数树优化前后性能和内存对比

在完成所有逻辑不变的情况下,我们仅仅将 PageCache 中的页号 → Span 映射,从 unordered_map 替换为基数树(Radix Tree),然后重新运行相同的多线程测试代码,得到如下结果。

19.1 固定大小内存的申请与释放

下图展示了 4 个线程、执行 10 轮、每轮申请释放 10,000 次、对象大小固定为 16 字节 时的结果:

可以看到即便是 "固定大小对象"(这是 malloc 的强项场景),我们的高并发内存池依然达到了 malloc 的 2 倍以上性能;

原因在于:

  • ThreadCache 全程无锁;
  • CentralCache 在固定尺寸场景下冲突很少;
  • 页号映射从 unordered_map 改成基数树后,释放路径不再需要加锁,大幅减少了锁竞争开销。

19.2 不同大小内存的申请与释放

当我们将申请大小改为 (16 + i) % 8192 + 1,即每次申请 1~8192 字节之间的不同大小的对象时,CentralCache 中不同大小的桶开始进入高频竞争阶段,此时基数树的优势会进一步放大。

可以看到优化后版本直接达到 malloc 的数倍性能提升,这是因为不同大小的对象意味着 thread cache【放空 / 填满】的频率更高,CentralCache 的桶锁会频繁【锁 / 解锁】;

而在旧版实现中,释放时需要 unordered_map 查找 span → 存在额外加锁与哈希计算;

而在基数树优化后:

  • 映射查找变成 O(1) 的 "直接寻址";
  • 释放路径彻底去掉了锁;
  • 同时基数树没有 rehash / 扩容,也没有红黑树旋转,读取过程完全稳定。

20. 项目源码

Github:高并发内存池

相关推荐
我真不会起名字啊1 小时前
C、C++中的sprintf和stringstream的使用
java·c语言·c++
猿饵块1 小时前
ros2--图像/image
c++
威桑3 小时前
LLVM (Low Level Virtual Machine)全景机制解析
c++·gcc·llvm
小许学java3 小时前
数据结构-模拟实现顺序表和链表
java·数据结构·链表·arraylist·linkedlist·顺序表模拟实现·链表的模拟实现
一只小bit4 小时前
Qt 快速开始:安装配置并创建简单标签展示
开发语言·前端·c++·qt·cpp
雍凉明月夜4 小时前
c++ 精学笔记记录Ⅰ
开发语言·c++·笔记
小鹏编程5 小时前
C++ 周期问题 - 计算n天后星期几
开发语言·c++
繁华似锦respect5 小时前
C++ unordered_map 底层实现与详细使用指南
linux·开发语言·c++·网络协议·设计模式·哈希算法·散列表
稚辉君.MCA_P8_Java5 小时前
Gemini永久会员 C++返回最长有效子串长度
开发语言·数据结构·c++·后端·算法