【C++】高并发内存池架构与设计解析

文章目录

高并发内存池项目架构与设计理念深度解析

这是一个经典的三层缓存架构 高并发内存池实现(参考了 TCMalloc 的设计思想)。从系统架构师和工程设计的角度,我们可以将其拆解为架构分层、核心数据结构、设计理念、工程实践四个维度进行深度剖析。


零、数据结构设计

我为你绘制 1:1 还原代码逻辑、带内存地址/字段值、可直接面试画 的三层缓存数据结构「实际运行状态图」,包含具体数值、内存地址、链表指向,完全模拟真实运行场景。

整体说明

  • 物理页大小:8KB(0x2000 字节)
  • 内存块规格:8B、16B、32B...(按 2 倍增长)
  • 地址均为模拟虚拟地址,方便理解指向关系

1. ThreadCache 数据结构实际图(线程1专属)

复制代码
┌──────────────────────────────────────────────────────────────────────────────┐
│ ThreadCache(线程1 TLS私有,无锁)| 地址:0x7f0000000000                     │
├──────────────────────────────────────────────────────────────────────────────┤
│ FreeList[208] 数组(仅展示前3个桶)                                           │
├──────────────────────────────────────────────────────────────────────────────┤
│ FreeList[0](管理8B内存块)| _maxSize=200 | _size=10                         │
│ ├─ _freeList: 0x100000 → 0x100008 → 0x100010 → 0x100018 → ... → NULL        │
│ └─ 空闲块列表:8B*10块,地址连续(CentralCache分配的16B桶Span切分)          │
├──────────────────────────────────────────────────────────────────────────────┤
│ FreeList[1](管理16B内存块)| _maxSize=300 | _size=5                        │
│ ├─ _freeList: 0x200000 → 0x200010 → 0x200020 → 0x200030 → 0x200040 → NULL   │
│ └─ 空闲块列表:16B*5块,地址步长16B                                          │
├──────────────────────────────────────────────────────────────────────────────┤
│ FreeList[2](管理32B内存块)| _maxSize=400 | _size=0                        │
│ └─ _freeList: NULL(已空,需向CentralCache申请)                              │
└──────────────────────────────────────────────────────────────────────────────┘
  • 核心字段
    • _freeList:隐式链表头指针(直接指向内存块起始地址,复用块头4/8字节存next指针);
    • _size:当前空闲块数量;
    • _maxSize:慢开始阈值(超过则批量归还CentralCache);
  • 无锁关键:每个线程有独立的ThreadCache,TLS存储(地址0x7f开头的线程私有区)。

2. CentralCache 数据结构实际图(全局单例)

复制代码
┌──────────────────────────────────────────────────────────────────────────────┐
│ CentralCache(全局单例)| 地址:0x600000000000                               │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[208] 数组(带桶锁,仅展示前3个桶)                                  │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[0](8B桶)| mutex _mtx(未锁定)| 链表头:Span_001                 │
│ ├─ Span_001(管理8B小块)| 地址:0x600000001000                              │
│ │  ├─ _pageId: 100(起始页号,对应物理地址:100*8KB=0x800000)               │
│ │  ├─ _n: 1(占用1页=8KB)| _objSize:8 | _isUse:true                        │
│ │  ├─ _useCount: 500(已分配500块,剩余12块)                                │
│ │  ├─ _freeList: 0x800000 → 0x800008 → ... → 0x800078 → NULL                │
│ │  └─ _next: Span_002 | _prev: NULL                                          │
│ ├─ Span_002(管理8B小块)| 地址:0x600000001020                              │
│ │  ├─ _pageId: 101 | _n:1 | _objSize:8 | _isUse:true                        │
│ │  ├─ _useCount: 100 | _freeList: 0x802000 → ... → NULL                     │
│ │  └─ _next: NULL | _prev: Span_001                                          │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[1](16B桶)| mutex _mtx(未锁定)| 链表头:Span_010                 │
│ ├─ Span_010(管理16B小块)| 地址:0x600000002000                             │
│ │  ├─ _pageId: 200 | _n:1 | _objSize:16 | _isUse:true                       │
│ │  ├─ _useCount: 462(已分配462块,剩余50块)                               │
│ │  ├─ _freeList: 0x100000 → 0x100010 → ... → 0x200040 → NULL                │
│ │  └─ _next: NULL | _prev: NULL                                              │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[2](32B桶)| mutex _mtx(锁定中)| 链表头:NULL                     │
│ └─ 无空闲Span,正在向PageCache申请1页Span                                    │
└──────────────────────────────────────────────────────────────────────────────┘
  • 核心字段
    • 每个SpanList有独立mutex,仅锁当前桶;
    • Span的_freeList指向页内空闲小块链表(和ThreadCache的FreeList格式一致);
    • _useCount:8KB页切16B块共512块,已分配462块=剩余50块。

3. PageCache 数据结构实际图(全局单例)

复制代码
┌──────────────────────────────────────────────────────────────────────────────┐
│ PageCache(全局单例)| 地址:0x500000000000                                 │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[129] 数组(按页数分桶,仅展示前3个桶)                              │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[1](1页桶)| 链表头:Span_A → Span_B → Span_C → NULL               │
│ ├─ Span_A | 地址:0x500000001000                                            │
│ │  ├─ _pageId: 100 | _n:1 | _isUse:true(被CentralCache 8B桶占用)           │
│ │  └─ _next: Span_B | _prev: NULL                                            │
│ ├─ Span_B | 地址:0x500000001020                                            │
│ │  ├─ _pageId: 101 | _n:1 | _isUse:true(被CentralCache 8B桶占用)           │
│ │  └─ _next: Span_C | _prev: Span_A                                          │
│ ├─ Span_C | 地址:0x500000001040                                            │
│ │  ├─ _pageId: 200 | _n:1 | _isUse:true(被CentralCache 16B桶占用)          │
│ │  └─ _next: NULL | _prev: Span_B                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[2](2页桶)| 链表头:Span_X → NULL                                  │
│ └─ Span_X | 地址:0x500000002000                                             │
│    ├─ _pageId: 300 | _n:2 | _isUse:false(空闲)                             │
│    └─ _next: NULL | _prev: NULL                                              │
├──────────────────────────────────────────────────────────────────────────────┤
│ SpanList[3](3页桶)| 链表头:NULL(无空闲Span)                              │
├──────────────────────────────────────────────────────────────────────────────┤
│ PageMap(二级基数树)| 地址:0x500000003000                                  │
│ ├─ 页号100 → Span_A(0x500000001000)                                       │
│ ├─ 页号101 → Span_B(0x500000001020)                                       │
│ ├─ 页号200 → Span_C(0x500000001040)                                       │
│ ├─ 页号300 → Span_X(0x500000002000)                                       │
│ └─ 页号301 → Span_X(0x500000002000)(2页Span映射两个页号)                  │
└──────────────────────────────────────────────────────────────────────────────┘
  • 核心字段
    • PageCache的Span仅关注「连续页」,不关心页内切分(切分是CentralCache的事);
    • PageMap是「页号→Span指针」的映射表,2页Span会映射2个页号到同一个Span;
    • 全局锁仅在操作SpanList/PageMap时加锁,低频操作冲突极少。

4. 三层缓存关联关系图(带地址指向)

复制代码
┌─────────────────────┐     批量申请(5块16B)     ┌─────────────────────┐
│ ThreadCache(线程1) │─────────────────────────→│ CentralCache        │
│ FreeList[1](16B)  │←─────────────────────────│ SpanList[1](16B)  │
│ _freeList:0x200000  │     返还5块16B            │ Span_010._freeList  │
└─────────────────────┘                           └─────────┬───────────┘
                                                            │
                                                            │ 申请1页Span
                                                            ↓
┌─────────────────────┐     返还1页Span(空闲时)  ┌─────────────────────┐
│ 操作系统(mmap)    │←─────────────────────────│ PageCache           │
│ 物理页:0x800000    │─────────────────────────→│ SpanList[1].Span_C  │
└─────────────────────┘                           │ PageMap:200→Span_C  │
                                                  └─────────────────────┘

核心总结

  1. ThreadCache:TLS私有,FreeList数组管理同规格空闲小块,无锁,地址直接指向内存块;
  2. CentralCache:全局单例,SpanList数组按小块规格分桶(带桶锁),Span管理切分后的页和空闲块;
  3. PageCache:全局单例,SpanList数组按页数分桶,PageMap映射页号到Span,负责页的申请/切分/合并;
  4. 三层缓存通过「批量申请/归还」联动,既保证无锁高并发,又解决内存碎片问题。

这张图完全还原代码运行时的实际状态,面试时按这个结构画,结合数值解释,能直接体现你对内存池的深度理解。

一、整体架构:三层缓存的协同设计

项目采用了经典的 ThreadCache → CentralCache → PageCache 三层缓存架构,每层职责清晰,通过"锁粒度分层"和"内存分层管理"解决高并发场景下的性能与内存碎片问题。

  1. 申请内存 2. ≤256KB 小内存 2. >256KB 大内存 3. 本地无内存,批量申请 4. 无空闲Span,申请连续页 5. 无空闲大页,向系统申请 释放内存
    ≤256KB
    >256KB 链表过长,批量归还
    Span全空闲,归还
    超大页释放
    用户层

业务线程/Benchmark/UnitTest
对外接口层

ConcurrentAlloc.h
ThreadCache

线程本地缓存

【无锁】
CentralCache

中心缓存

【桶锁】
PageCache

页缓存

【全局锁】
操作系统内核

虚拟内存管理

各层职责与设计意图

层级 职责 并发控制 解决的核心问题
ThreadCache 线程本地小内存分配(≤256KB) 无锁(TLS线程隔离) 高并发下的锁竞争瓶颈
CentralCache 平衡多线程间的内存供需,管理Span 桶锁(细粒度) 避免单个线程缓存过多内存
PageCache 管理系统物理页,处理大内存分配,合并碎片 全局锁(粗粒度) 内存碎片问题 + 系统调用开销

二、核心数据结构与设计亮点

1. SizeClass:内存块大小的"分类器"

核心作用:将任意内存大小对齐到预设规格,减少内碎片,同时映射到对应的自由链表桶。

设计细节

  • 分段对齐策略 :控制内碎片在 10% 左右
    • 1, 128\] → 8字节对齐(16个桶)

    • 1025, 8K\] → 128字节对齐(56个桶)

    • 64K+1, 256K\] → 8K字节对齐(24个桶)

2. FreeList:自由链表的"高效管理器"

核心作用:管理切分好的同规格小内存块,支持 O(1) 的插入/删除。

设计细节

  • 隐式链表 :利用内存块自身的前 sizeof(void*) 字节存储下一个节点的指针,无需额外内存开销。
  • 批量操作 :支持 PushRange/PopRange 批量操作,减少线程间交互次数。

3. Span & SpanList:连续物理页的"容器"

核心作用

  • Span:管理一组连续的物理页(是 CentralCache 和 PageCache 的核心单元)。
  • SpanList:带头节点的双向循环链表,用于管理同规格的 Span。

设计细节

  • Span 元数据 :记录起始页号 _pageId、页数 _n、切分的小对象大小 _objSize、已分配计数 _useCount 等。
  • 双向循环链表:支持 O(1) 的节点插入/删除,便于 Span 的切分与合并。

4. PageMap:页ID到Span的"高速映射表"

核心作用:通过内存地址快速找到所属的 Span(释放内存时的关键路径)。

设计亮点

  • 三种实现适配不同场景
    • TCMalloc_PageMap1:单层数组(适用于 32 位系统,O(1) 查找,内存占用可接受)。
    • TCMalloc_PageMap2:二级基数树(适用于 64 位系统,节省内存,同时保持高效查找)。
    • TCMalloc_PageMap3:三级基数树(进一步优化大地址空间的内存占用)。
  • 替代哈希表:避免了哈希冲突,查找性能更稳定。

5. ObjectPool:定长对象的"内存池"

核心作用:高效分配/释放固定大小的对象(如 Span、ThreadCache 对象)。

设计细节

  • 大块内存切分:一次向系统申请 128KB 大块内存,切分成固定大小的对象,减少系统调用。
  • 自由链表复用:释放的对象通过自由链表链接,供后续复用,避免内存碎片。

三、关键设计理念:性能与内存的平衡艺术

1. 并发优化:锁粒度的"分层控制"

这是高并发内存池的核心设计思想,通过"无锁 → 细粒度锁 → 粗粒度锁"的分层,将锁竞争降到最低。

  • ThreadCache 层:完全无锁

    • 利用 TLS(线程局部存储) 实现线程隔离(pTLSThreadCache 指针)。
    • 每个线程独立操作自己的 ThreadCache,无需任何锁。
  • CentralCache 层:细粒度桶锁

    • 每个自由链表桶(SpanList)都有自己的锁(_mtx)。
    • 不同大小的内存分配/归还操作互不阻塞,锁竞争范围缩小到"同一规格的内存块"。
  • PageCache 层:粗粒度全局锁

    • 整个 PageCache 用一把全局锁(_pageMtx)。
    • 合理性:PageCache 的操作频率很低(只有当 CentralCache 没有可用 Span 时才会调用),粗粒度锁的开销可以接受,同时简化了实现。

2. 内存碎片治理:"内碎片"与"外碎片"双管齐下

内碎片:通过 SizeClass 控制在 10% 以内
  • 分段对齐策略:小对象用小对齐(如 8 字节),大对象用大对齐(如 8KB),在"内存利用率"和"管理效率"之间取得平衡。
外碎片:通过 PageCache 的 Span 合并机制缓解
  • 前后向合并ReleaseSpanToPageCache 函数会尝试合并当前 Span 的前一个和后一个相邻 Span。
  • 合并条件:相邻 Span 空闲且合并后不超过 128 页。
  • 效果:将分散的小页合并成大页,减少外碎片,提高内存利用率。

3. 性能调优:"慢开始"与"批量操作"

ThreadCache 的"慢开始反馈算法"
  • 核心思想 :根据线程的内存申请频率,动态调整批量申请的内存块数量。
    • 初始时 _maxSize = 1,每次申请后 _maxSize++(上限 512)。
    • 小对象(如 8 字节)批量数多(最多 512 个),大对象(如 256KB)批量数少(最少 2 个)。
  • 优势:避免线程一开始申请过多内存导致浪费,同时随着申请频率增加,提高批量申请效率。
批量操作减少交互
  • ThreadCache 从 CentralCache 批量获取内存块(FetchRangeObj)。
  • ThreadCache 向 CentralCache 批量归还内存块(ListTooLong)。
  • 效果:减少线程进入 CentralCache 的次数,降低锁竞争开销。

4. 跨平台兼容性:"宏定义"与"抽象封装"

  • TLS 跨平台 :通过宏定义适配 Windows(__declspec(thread))和 Linux(__thread)。
  • 系统内存申请SystemAlloc 封装了 Windows 的 VirtualAlloc 和 Linux 的 mmap
  • 页 ID 类型:通过宏定义适配 32 位和 64 位系统。

四、工程实践亮点

1. 可测试性设计

  • Benchmark.cpp :提供了与系统 malloc/free 的性能对比测试,直观展示内存池的性能优势。
  • UnitTest.cpp:覆盖了单线程、多线程、小内存、大内存等各种场景,便于回归测试。

2. 调试友好性

  • 断言(assert):关键路径都有断言,快速定位非法操作(如释放空指针、申请 0 字节等)。
  • 条件断点 :代码中预留了 int x = 0; 等条件断点位置,便于调试时追踪问题(如切分对象数量不匹配)。

3. 代码组织与职责分离

  • 模块化设计 :每个类/文件职责单一(如 PageCache 只负责页管理,SizeClass 只负责大小分类)。
  • 接口清晰 :对外只暴露 ConcurrentAllocConcurrentFree 两个接口,内部实现细节对用户透明。

五、总结:架构设计的"Trade-off"智慧

这个高并发内存池的设计精髓在于在"性能"、"内存利用率"、"实现复杂度"三者之间做了精妙的权衡

  1. 用三层架构换性能:增加了架构复杂度,但换来了高并发下的线性扩展能力。
  2. 用内碎片换管理效率:通过 SizeClass 对齐产生少量内碎片,但简化了自由链表的管理,提高了分配速度。
  3. 用粗粒度锁换实现简单:PageCache 用全局锁,牺牲了部分极端场景的性能,但大大简化了合并逻辑的实现。

这种设计思想不仅适用于内存池,也广泛适用于其他高并发系统(如数据库连接池、对象池等)。


如果你需要进一步深入某个具体模块(如 PageCache 的合并逻辑、ThreadCache 的慢开始算法),或者想了解如何在实际项目中集成这个内存池,我可以继续为你详细讲解。

高并发内存池核心模块与内存流程深度解析

为了让你彻底理解这个项目,我们将按照 「核心数据结构扫盲 → 难理解模块拆解 → 物理内存全流程」 的顺序,结合实际代码一步步讲解。


第一部分:核心数据结构"扫盲"

这是理解后续逻辑的基础,我们先明确几个最关键、也最容易混淆的概念。

1. Span:连续物理页的"元数据管家"

Span 不是内存本身,而是用来"描述"和"管理"一组连续物理页的元数据结构。

你可以把 Span 想象成"房产证":房产证本身不是房子,但它记录了房子的位置(起始页号)、面积(页数)、当前状态(是否在使用)等信息。

代码位置:Common.h
cpp 复制代码
struct Span {
  PAGE_ID _pageId = 0;  // 【关键】起始页号:物理地址 >> 13 (因为1页=8KB=2^13)
  size_t _n = 0;        // 【关键】这个 Span 管理了多少个连续的物理页

  Span* _next = nullptr; // 双向链表指针:用于把 Span 串起来挂在桶里
  Span* _prev = nullptr;

  size_t _objSize = 0;        // 如果这个 Span 被切分成小对象,这里记录小对象的大小(如8字节、16字节)
  size_t _useCount = 0;       // 已经分配给 ThreadCache 的小对象数量(用于判断 Span 是否完全空闲)
  void* _freeList = nullptr;  // 切分后剩余的空闲小对象链表(隐式链表)

  bool _isUse = false;  // 标记这个 Span 是否正在被使用
};
直观理解:

假设我们有一个管理 3 个连续物理页(页号 100、101、102)的 Span:

  • _pageId = 100
  • _n = 3
  • 如果这 3 页被切分成 8 字节的小对象,那么 _objSize = 8_freeList 指向这些小对象的链表头。

2. FreeList:用内存块自己存指针的"隐式链表"

FreeList 用来管理切分好的小内存块。最巧妙的地方在于:它不需要额外的内存来存链表指针,而是直接利用内存块的前 sizeof(void*) 个字节来存下一个块的地址。

关键辅助函数:NextObjCommon.h
cpp 复制代码
// 获取内存块的下一个指针(直接在内存块开头读写)
static void*& NextObj(void* obj) { 
  return *(void**)obj; 
  // 解释:把 obj 强转成 void**,然后解引用,这样就能在 obj 开头的 8/4 字节存指针了
}
图解隐式链表:

假设我们有 3 个 8 字节的内存块:

复制代码
[内存块1 (地址0x1000)] → 前8字节存 0x2000 (指向内存块2)
[内存块2 (地址0x2000)] → 前8字节存 0x3000 (指向内存块3)
[内存块3 (地址0x3000)] → 前8字节存 nullptr (链表尾)

3. PageMap:二级基数树(高效的页号→Span映射)

当我们释放内存时,需要通过内存地址快速找到它属于哪个 Span。PageMap 就是干这个的。

项目提供了三种实现,我们讲最常用、最巧妙的 TCMalloc_PageMap2(二级基数树)

代码位置:PageMap.h
cpp 复制代码
template <int BITS>
class TCMalloc_PageMap2 {
 private:
  static const int ROOT_BITS = 5;          // 根节点用5位:2^5=32个条目
  static const int ROOT_LENGTH = 1 << ROOT_BITS;

  static const int LEAF_BITS = BITS - ROOT_BITS; // 叶子节点用剩下的位
  static const int LEAF_LENGTH = 1 << LEAF_BITS;

  struct Leaf {
    void* values[LEAF_LENGTH]; // 叶子节点存真正的 Span*
  };

  Leaf* root_[ROOT_LENGTH]; // 根节点数组:32个指向叶子节点的指针

 public:
  // 根据页号 k 获取 Span*
  void* get(Number k) const {
    const Number i1 = k >> LEAF_BITS;      // 高几位:根节点索引
    const Number i2 = k & (LEAF_LENGTH - 1); // 低几位:叶子节点内偏移

    if ((k >> BITS) > 0 || root_[i1] == NULL) return NULL;
    return root_[i1]->values[i2];
  }

  // 设置页号 k 对应的 Span*
  void set(Number k, void* v) {
    const Number i1 = k >> LEAF_BITS;
    const Number i2 = k & (LEAF_LENGTH - 1);
    root_[i1]->values[i2] = v;
  }
};
为什么用二级基数树?
  • 比单层数组省内存 :如果是 32 位系统,单层数组需要 2^19 个指针(约 4MB 内存),而二级基数树只在需要时分配叶子节点。
  • 比哈希表快:没有哈希冲突,查找是纯计算,O(1) 时间。

4. ObjectPool:定长对象的"专用内存池"

项目里频繁创建销毁 SpanThreadCache 对象,为了避免每次都调 new/delete(慢且有碎片),实现了一个定长内存池。

核心逻辑:ObjectPool.h
cpp 复制代码
template <class T>
class ObjectPool {
 private:
  char* _memory = nullptr;   // 指向当前大块内存的剩余部分
  size_t _remainBytes = 0;   // 大块内存还剩多少字节
  void* _freeList = nullptr; // 释放的对象串成的链表

 public:
  T* New() {
    // 1. 优先从自由链表拿已经释放的对象
    if (_freeList) {
      void* next = *((void**)_freeList);
      T* obj = (T*)_freeList;
      _freeList = next;
      new (obj) T; // 定位new:在已有的内存上调用构造函数
      return obj;
    }

    // 2. 自由链表没了,从大块内存切
    if (_remainBytes < sizeof(T)) {
      _remainBytes = 128 * 1024; // 一次申请 128KB 大块内存
      _memory = (char*)SystemAlloc(_remainBytes >> 13); // 按页申请
    }

    T* obj = (T*)_memory;
    // 保证对象至少能存下一个指针(用于自由链表)
    size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
    _memory += objSize;
    _remainBytes -= objSize;

    new (obj) T; // 定位new
    return obj;
  }

  void Delete(T* obj) {
    obj->~T(); // 显式调用析构函数
    // 把释放的对象头插到自由链表
    *((void**)obj) = _freeList;
    _freeList = obj;
  }
};

第二部分:难理解模块深度拆解(结合代码)

1. PageCache::NewSpan:切分大页、递归申请

这是最复杂的函数之一,核心逻辑是:如果没有 k 页的 Span,就找更大的 Span 切分;如果都没有,就向系统申请 128 页,然后递归切分。

代码位置:PageCache.cpp

我们分四步看:

第一步:处理超大页(>128页)

cpp 复制代码
if (k > NPAGES - 1) { // NPAGES=129,所以 >128 页
  void* ptr = SystemAlloc(k); // 直接向系统申请 k 页
  Span* span = _spanPool.New(); // 从对象池拿 Span 对象(不用 new)
  
  span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT; // 地址转页号:右移13位
  span->_n = k;
  
  _idSpanMap.set(span->_pageId, span); // 只映射起始页(因为超大页不合并)
  return span;
}

第二步:检查 k 页桶有没有现成的

cpp 复制代码
if (!_spanLists[k].Empty()) {
  Span* kSpan = _spanLists[k].PopFront();
  
  // 【关键】映射这个 Span 的所有页号
  // 因为 CentralCache 回收小对象时,需要通过任意页号找到 Span
  for (PAGE_ID i = 0; i < kSpan->_n; ++i) {
    _idSpanMap.set(kSpan->_pageId + i, kSpan);
  }
  return kSpan;
}

第三步:找更大的页切分(核心切分逻辑)

cpp 复制代码
for (size_t i = k + 1; i < NPAGES; ++i) {
  if (!_spanLists[i].Empty()) {
    Span* nSpan = _spanLists[i].PopFront(); // 拿出一个 i 页的大 Span
    Span* kSpan = _spanPool.New();           // 创建一个新 Span 装 k 页

    // 切分:从大 Span 头部切出 k 页
    kSpan->_pageId = nSpan->_pageId; // 新 Span 起始页 = 大 Span 起始页
    kSpan->_n = k;                    // 新 Span 页数 = k

    // 更新大 Span:起始页后移 k 页,页数减 k
    nSpan->_pageId += k;
    nSpan->_n -= k;

    // 把切剩的大 Span 挂回对应页数的桶里
    _spanLists[nSpan->_n].PushFront(nSpan);

    // 【映射策略】
    // 1. 切剩的大 Span:只映射首尾页(用于后续合并时快速查找)
    _idSpanMap.set(nSpan->_pageId, nSpan);
    _idSpanMap.set(nSpan->_pageId + nSpan->_n - 1, nSpan);

    // 2. 要返回的 k 页 Span:映射所有页(供 CentralCache 查找)
    for (PAGE_ID i = 0; i < kSpan->_n; ++i) {
      _idSpanMap.set(kSpan->_pageId + i, kSpan);
    }

    return kSpan;
  }
}

第四步:向系统申请 128 页,然后递归

cpp 复制代码
Span* bigSpan = _spanPool.New();
void* ptr = SystemAlloc(NPAGES - 1); // 申请 128 页
bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
bigSpan->_n = NPAGES - 1;

_spanLists[bigSpan->_n].PushFront(bigSpan); // 挂到 128 页的桶里

return NewSpan(k); // 递归调用:此时 128 页桶有数据了,会走第三步切分

2. PageCache::ReleaseSpanToPageCache:前后向合并

释放 Span 时,会尝试合并前面和后面的相邻 Span,缓解内存碎片。

代码位置:PageCache.cpp

第一步:处理超大页(直接还系统)

cpp 复制代码
if (span->_n > NPAGES - 1) {
  void* ptr = (void*)(span->_pageId << PAGE_SHIFT); // 页号转地址
  SystemFree(ptr);
  _spanPool.Delete(span); // 回收 Span 对象到对象池
  return;
}

第二步:向前合并(找前一个相邻页)

cpp 复制代码
while (1) {
  PAGE_ID prevId = span->_pageId - 1; // 前一个页的页号
  auto ret = (Span*)_idSpanMap.get(prevId);

  // 前一页没映射 / 前一页的 Span 正在用 / 合并后超过128页 → 不能合并
  if (ret == nullptr || ret->_isUse == true || (ret->_n + span->_n > NPAGES - 1)) {
    break;
  }

  Span* prevSpan = ret;
  // 合并:把前一个 Span 合并到当前 Span
  span->_pageId = prevSpan->_pageId; // 起始页变成前一个 Span 的起始页
  span->_n += prevSpan->_n;           // 页数相加

  // 把前一个 Span 从桶里移除,并回收对象
  _spanLists[prevSpan->_n].Erase(prevSpan);
  _spanPool.Delete(prevSpan);
}

第三步:向后合并(找后一个相邻页)

逻辑和向前合并几乎一样,只是计算 nextId = span->_pageId + span->_n,这里不再重复贴代码。

第四步:挂回桶里,建立映射

cpp 复制代码
_spanLists[span->_n].PushFront(span);
span->_isUse = false; // 标记为空闲

// 只映射首尾页(用于后续合并)
_idSpanMap.set(span->_pageId, span);
_idSpanMap.set(span->_pageId + span->_n - 1, span);

3. CentralCache::GetOneSpan:锁优化 + 切分小对象

这里有个经典的锁粒度优化:先释放 CentralCache 的桶锁,再加 PageCache 的全局锁,避免阻塞其他线程。

代码位置:CentralCache.cpp
cpp 复制代码
Span* CentralCache::GetOneSpan(SpanList& list, size_t size) {
  // 1. 先遍历当前桶,找有没有带空闲对象的 Span
  Span* it = list.Begin();
  while (it != list.End()) {
    if (it->_freeList != nullptr) return it;
    else it = it->_next;
  }

  // 【关键锁优化】先释放 CentralCache 的桶锁!
  // 因为接下来要去 PageCache 申请,可能很慢,不能堵着其他线程归还内存
  list._mtx.unlock();

  // 2. 向 PageCache 申请 Span
  PageCache::GetInstance()->_pageMtx.lock();
  Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
  span->_isUse = true;
  span->_objSize = size;
  PageCache::GetInstance()->_pageMtx.unlock();

  // 3. 把 Span 切分成小对象链表(这里不需要锁,因为 Span 还没加入桶)
  char* start = (char*)(span->_pageId << PAGE_SHIFT); // 页号转起始地址
  size_t bytes = span->_n << PAGE_SHIFT;               // 总字节数
  char* end = start + bytes;

  span->_freeList = start;
  char* tail = start;
  start += size;

  // 串成链表
  while (start < end) {
    NextObj(tail) = start;
    tail = start;
    start += size;
  }
  NextObj(tail) = nullptr;

  // 4. 重新加桶锁,把切好的 Span 加入桶
  list._mtx.lock();
  list.PushFront(span);

  return span;
}

4. ThreadCache::FetchFromCentralCache:慢开始算法

核心思想:一开始不要向 CentralCache 申请太多内存(怕浪费),如果这个大小的内存持续被申请,再慢慢增加批量申请的数量。

代码位置:ThreadCache.cpp
cpp 复制代码
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size) {
  // 1. 计算本次批量申请的数量
  // 取"当前链表允许的最大数量"和"SizeClass建议数量"的较小值
  size_t batchNum = std::min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));

  // 2. 慢开始增长:如果这次达到了当前最大值,下次就把最大值+1
  if (_freeLists[index].MaxSize() == batchNum) {
    _freeLists[index].MaxSize() += 1; // 上限是 512
  }

  void* start = nullptr;
  void* end = nullptr;
  // 3. 向 CentralCache 申请 batchNum 个对象
  size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);

  if (actualNum == 1) {
    return start; // 只申请到一个,直接返回
  } else {
    // 4. 申请到多个:把除了第一个之外的,都插入到自己的自由链表
    _freeLists[index].PushRange(NextObj(start), end, actualNum - 1);
    return start; // 返回第一个
  }
}

第三部分:物理内存的"一生"(分场景全流程)

我们通过三个最典型的场景,看物理内存是如何从系统申请、被使用、最后释放/合并的。


场景一:小内存申请与释放(以申请 16 字节为例,≤256KB)

这是最常见的场景,流程最长,涉及所有三层缓存。

第一步:申请内存(用户调用 ConcurrentAlloc(16)
  1. ThreadCache 层

    • 检查自己的 _freeLists[16字节对应的桶] 有没有空闲对象。
    • 假设是第一次申请,没有。调用 FetchFromCentralCache
  2. CentralCache 层

    • 找到 16 字节对应的 SpanList 桶,加桶锁。
    • 遍历桶里的 Span,假设都没有空闲对象。
    • 锁优化:先释放桶锁,然后向 PageCache 申请。
  3. PageCache 层

    • 计算需要申请的页数(假设 NumMovePage(16) 返回 1 页)。
    • 检查 1 页的桶,假设没有。找更大的桶,假设也没有。
    • 向系统申请 128 页 (通过 mmapVirtualAlloc)。
    • 创建一个 Span 管理这 128 页,挂到 128 页的桶里。
    • 递归切分:从 128 页里切出 1 页,剩下的 127 页挂回 127 页的桶。
    • 为这 1 页的 Span 建立所有页号的映射(只有 1 页,所以映射页号 x)。
    • 返回这个 1 页的 Span 给 CentralCache。
  4. CentralCache 层(继续)

    • 把这 1 页 Span 切分成 8192 / 16 = 512 个 16 字节的小对象,串成链表。
    • 把这个 Span 加入 16 字节对应的桶里。
    • 从 Span 的自由链表中取出 batchNum 个对象(假设慢开始第一次是 1 个,后来慢慢增加到 512 个)。
    • 返回给 ThreadCache。
  5. ThreadCache 层(继续)

    • 把除了第一个之外的对象,插入到自己的自由链表。
    • 把第一个对象返回给用户。

此时,物理内存的状态

  • 系统分配了 128 页(1MB)给内存池。
  • 其中 1 页被切分成 16 字节的小对象,1 个给了用户,剩下的在 ThreadCache。
  • 剩下的 127 页作为一个 Span 挂在 PageCache 的 127 页桶里。

第二步:释放内存(用户调用 ConcurrentFree(ptr)
  1. ThreadCache 层

    • 找到 16 字节对应的桶,把 ptr 头插到自由链表。
    • 检查链表长度:如果超过 MaxSize(比如 512),调用 ListTooLong
  2. CentralCache 层

    • ThreadCache 批量归还 512 个对象。
    • 加桶锁,遍历这些对象,通过 MapObjectToSpan 找到它们所属的 Span。
    • 把对象逐个插回 Span 的自由链表,_useCount 逐个减 1。
    • _useCount 减到 0 时(说明这个 Span 的所有对象都回来了):
      • 把这个 Span 从 CentralCache 的桶里移除。
      • 释放桶锁,然后向 PageCache 归还。
  3. PageCache 层

    • 拿到这个 1 页的 Span,尝试向前合并(看页号 x-1 有没有空闲 Span),假设没有。
    • 尝试向后合并:看页号 x+1,发现是之前切剩的 127 页 Span!
    • 合并这两个 Span:变成 128 页的 Span。
    • 把这个 128 页的 Span 挂到 PageCache 的 128 页桶里。
    • 只映射首尾页(页号 x 和 x+127)。

此时,物理内存的状态

  • 128 页又合并成了一个完整的 Span,挂在 PageCache 里,等待下次被申请。
  • 没有释放给系统,而是留着复用,减少系统调用。

场景二:大内存申请与释放(以申请 300KB 为例,>256KB 且 ≤128页)

300KB 约等于 38 页(300*1024 / 8192 ≈ 38),直接走 PageCache。

申请流程:
  1. 用户调用 ConcurrentAlloc(300*1024)
  2. 发现 size > MAX_BYTES(256KB),直接走 PageCache。
  3. 加 PageCache 全局锁
  4. 调用 NewSpan(38)
    • 假设 38 页桶没有,找更大的,比如从 128 页桶切出 38 页。
    • 剩下的 90 页挂回 90 页桶。
    • 为这 38 页的 Span 建立所有页号的映射。
  5. 把 Span 的 _objSize 设为 300KB。
  6. 释放全局锁
  7. 把页号转成地址(_pageId << 13),返回给用户。
释放流程:
  1. 用户调用 ConcurrentFree(ptr)
  2. 通过 MapObjectToSpan 找到 Span,拿到 _objSize = 300KB
  3. 加 PageCache 全局锁
  4. 调用 ReleaseSpanToPageCache(span)
    • 尝试前后合并。
    • 挂回对应页数的桶里。
  5. 释放全局锁

场景三:超大内存申请与释放(以申请 130 页为例,>128页)

超过 128 页,PageCache 不管理,直接和系统交互。

申请流程:
  1. 用户调用 ConcurrentAlloc(130 * 8192)
  2. 计算页数 kpage = 130,发现 kpage > 128
  3. 加 PageCache 全局锁
  4. 调用 NewSpan(130)
    • 直接调用 SystemAlloc(130),向系统申请 130 页。
    • _spanPool 拿一个 Span 对象。
    • 设置 _pageId_n = 130
    • 只映射起始页(因为不合并)。
  5. 释放全局锁
  6. 返回地址给用户。
释放流程:
  1. 用户调用 ConcurrentFree(ptr)
  2. 找到 Span,发现 _n > 128
  3. 加 PageCache 全局锁
  4. 调用 ReleaseSpanToPageCache(span)
    • 把页号转成地址,调用 SystemFree(ptr) 直接释放给系统。
    • 把 Span 对象回收给 _spanPool
  5. 释放全局锁

总结

现在你应该能理解:

  1. Span 是管理连续物理页的元数据,不是内存本身。
  2. PageCache 用切分和合并解决外碎片,CentralCache 用桶锁平衡并发,ThreadCache 用 TLS 实现无锁。
  3. 物理内存 从系统申请后,会在三层缓存之间流转,尽量复用,减少系统调用,只有超大页才会直接释放给系统。

如果还有具体的代码行或逻辑想深入了解,可以继续问我!

高并发内存池 新人友好型架构图全套

以下所有图均采用 Mermaid 标准语法,可直接在支持Mermaid的工具(如Typora、语雀、Github、VS Code插件)中渲染,同时附带详细的看图说明、代码对应关系,新人能直接对应到代码文件,快速理清项目脉络。


一、整体系统架构总图(核心必看)

这张图完整呈现了项目的分层结构、模块职责、代码对应关系、锁机制、内存流向,新人看代码前先看懂这张图,能直接建立全局认知。
申请内存
≤256KB 小内存
>256KB 大内存 本地无空闲内存,批量申请
无空闲Span,申请连续页
无空闲大页,向系统申请
释放内存
≤256KB 小内存
>256KB 大内存 链表过长,批量归还
Span全空闲,归还
超大页/主动释放,还给系统
基础数据结构/工具函数
基础数据结构/工具函数
基础数据结构/工具函数
用户层
业务线程

调用内存接口
性能测试

Benchmark.cpp
单元测试

UnitTest.cpp
对外统一接口层

【对应文件:ConcurrentAlloc.h】
核心入口函数

ConcurrentAlloc(size)
核心释放函数

ConcurrentFree(void*)
分流逻辑

≤256KB走线程缓存

>256KB直接走页缓存
ThreadCache 线程本地缓存层

【对应文件:ThreadCache.h/ThreadCache.cpp】
线程1专属ThreadCache

【TLS线程隔离,无锁】
线程2专属ThreadCache

【TLS线程隔离,无锁】
线程N专属ThreadCache

【TLS线程隔离,无锁】
自由链表数组 FreeList

每个桶对应一个固定大小的内存块
自由链表数组 FreeList
自由链表数组 FreeList
核心能力

  1. 小内存无锁分配/释放

  2. 慢开始算法动态调整批量申请数

  3. 链表过长时批量归还中心缓存
    CentralCache 中心缓存层

【单例模式,对应文件:CentralCache.h/CentralCache.cpp】
SpanList桶数组

和ThreadCache桶一一对应
桶0:8字节内存块

【独立桶锁】
桶1:16字节内存块

【独立桶锁】
桶...:对应规格内存块

【独立桶锁】
桶207:256KB内存块

【独立桶锁】
核心结构:Span

管理连续物理页,切分为小对象
核心能力

  1. 批量给ThreadCache提供内存块

  2. 回收ThreadCache归还的内存块

  3. 空闲Span全归还时,还给PageCache

  4. 细粒度桶锁,仅同规格内存竞争锁
    PageCache 页缓存层

【单例模式,对应文件:PageCache.h/PageCache.cpp】
SpanList桶数组

每个桶对应连续页数
桶1:1页内存

8KB
桶2:2页连续内存

16KB
桶...:对应页数连续内存
桶128:128页连续内存

1MB
核心映射:PageMap

页号 → Span 高速查找
核心能力

  1. 向系统申请/释放物理内存

  2. 给CentralCache提供连续页Span

  3. 大Span切分为小Span

  4. 空闲Span前后合并,解决外碎片

  5. 全局锁,仅底层低频操作触发
    底层支撑工具模块

【对应文件:Common.h / ObjectPool.h / PageMap.h】
SizeClass 大小分类器

  1. 内存大小对齐规则

  2. 桶索引映射

  3. 批量申请数计算
    FreeList 自由链表

  4. 小内存块O(1)插入/删除

  5. 隐式链表,无额外内存开销
    Span/SpanList 结构

  6. Span:连续物理页的元数据

  7. SpanList:双向循环链表,管理Span
    ObjectPool 定长对象池

  8. 高效创建/销毁Span/ThreadCache对象

  9. 替代new/delete,减少碎片
    PageMap 基数树映射

  10. 内存地址→所属Span的O(1)查找

  11. 替代哈希表,无冲突性能稳定
    SystemAlloc/SystemFree

跨平台系统调用封装

Linux:mmap/munmap

Windows:VirtualAlloc/VirtualFree
操作系统内核层
虚拟内存管理系统

物理内存分配/释放

新人看图&看代码顺序指南

  1. 第一步 :先看ConcurrentAlloc.h,对应图中的【对外统一接口层】,搞懂用户调用的入口,以及大/小内存的分流逻辑。
  2. 第二步 :看Common.h,对应图中的【底层支撑工具模块】,先搞懂SpanFreeListSizeClass这三个核心数据结构,不然看核心层代码会一头雾水。
  3. 第三步 :按ThreadCache → CentralCache → PageCache的顺序,从上到下看三层核心缓存,对应图中的三层架构,搞懂每一层的职责和上下游调用关系。
  4. 第四步 :看ObjectPool.hPageMap.h,搞懂辅助优化模块的实现。
  5. 第五步 :跑Benchmark.cppUnitTest.cpp,验证功能和性能。

二、核心流程时序图(申请+释放)

新人看代码时,经常搞不懂函数调用的先后顺序,这两张时序图把最常用的小内存申请/释放全流程,按代码执行顺序一步步拆解,每一步都对应到代码文件和函数。

1. 小内存申请时序图(以申请16字节为例)

PageCache CentralCache ThreadCache 对外接口 操作系统 PageCache PageCache.cpp CentralCache CentralCache.cpp ThreadCache ThreadCache.cpp 对外接口 ConcurrentAlloc.h 用户线程 PageCache CentralCache ThreadCache 对外接口 操作系统 PageCache PageCache.cpp CentralCache CentralCache.cpp ThreadCache ThreadCache.cpp 对外接口 ConcurrentAlloc.h 用户线程 【核心场景】≤256KB小内存申请,高并发无锁核心路径 alt [找到大Span] [所有桶都无空闲Span] alt [有对应页数的Span] [无对应页数的Span] alt [桶里有空闲Span] [桶里无空闲Span] alt [本地有空闲块] [本地无空闲块(首次申请/用完了)] 调用 ConcurrentAlloc(16) 判断大小≤256KB,走线程缓存 懒初始化:TLS线程本地ThreadCache(首次调用创建) 调用 ThreadCache::Allocate(16) SizeClass计算对齐后大小、桶索引 检查对应桶的FreeList是否有空闲块 直接Pop空闲块返回,无锁完成 调用 FetchFromCentralCache(索引, 16) 慢开始算法计算批量申请数batchNum 调用 CentralCache::FetchRangeObj(batchNum,16) 加对应桶的【桶锁】 调用 GetOneSpan() 找带空闲对象的Span 直接拿到可用Span 【锁优化】先释放桶锁,避免阻塞其他线程 加PageCache【全局锁】,调用 NewSpan(需要的页数) 检查对应页数桶是否有空闲Span 直接取出Span 遍历更大页数的桶,找大Span切分 切分大Span,剩余部分挂回对应桶 调用 SystemAlloc(128) 申请128页(1MB) 返回虚拟内存地址 创建Span管理128页,挂回128页桶 递归调用NewSpan,切分需要的页数 建立页号→Span的映射 释放全局锁,返回Span 把Span切分为16字节的小对象链表 重新加桶锁,把Span挂入对应桶 从Span中取出batchNum个对象 释放桶锁,返回实际申请到的对象 把除第一个外的对象,插入本地FreeList 返回第一个内存块,完成申请

2. 小内存释放时序图(以释放16字节为例)

CentralCache ThreadCache PageCache 对外接口 操作系统 PageCache PageCache.cpp CentralCache CentralCache.cpp ThreadCache ThreadCache.cpp 对外接口 ConcurrentAlloc.h 用户线程 CentralCache ThreadCache PageCache 对外接口 操作系统 PageCache PageCache.cpp CentralCache CentralCache.cpp ThreadCache ThreadCache.cpp 对外接口 ConcurrentAlloc.h 用户线程 【核心场景】≤256KB小内存释放,碎片治理核心路径 alt [Span的_useCount == 0(所有对象都归还,完全空闲)] loop [遍历归还的每个内存块] alt [未超过阈值] [超过阈值(链表太长)] 调用 ConcurrentFree(ptr) 调用 MapObjectToSpan(ptr),通过地址找到所属Span 返回Span,拿到内存块大小_objSize=16字节 判断大小≤256KB,走线程缓存释放 调用 ThreadCache::Deallocate(ptr,16) 计算桶索引,把ptr插入对应FreeList 检查FreeList长度是否超过阈值MaxSize 留在本地缓存,等待下次复用 调用 ListTooLong(),批量归还内存 调用 ReleaseListToSpans(内存块链表,16) 加对应桶的【桶锁】 调用 MapObjectToSpan() 找到所属Span 把内存块插回Span的空闲链表 Span的_useCount(已分配计数)减1 把Span从当前桶中移除 【锁优化】释放桶锁,避免阻塞其他线程 加全局锁,调用 ReleaseSpanToPageCache(Span) 【向前合并】检查前一个相邻页是否有空闲Span 【向后合并】检查后一个相邻页是否有空闲Span 合并相邻空闲Span,缓解外碎片 把合并后的Span挂回对应页数的桶 更新页号→Span的映射 释放全局锁 重新加桶锁,继续处理剩余内存块 释放桶锁,完成归还


三、核心数据结构关系图

新人最容易搞混SpanFreeListPageMap这些结构的关系,这张图把它们的包含关系、使用场景、关联逻辑讲得清清楚楚。
包含208个,每个桶一个
依赖,计算对齐和索引
依赖,创建自身实例
包含208个,每个桶一个
每个SpanList管理多个Span
包含,管理切分后的小对象
依赖,计算页数和桶索引
依赖,释放时查找Span
包含129个,每个页数桶一个
每个SpanList管理多个Span
包含,维护页号→Span的全局映射
依赖,创建Span对象
依赖,向系统申请/释放内存
全局通用
全局通用
全局通用
ThreadCache
FreeList
SizeClass
ObjectPool
CentralCache
SpanList
Span
PageMap
PageCache
SystemAlloc


四、新人快速记忆口诀

看完图后,用这个口诀快速巩固核心逻辑,面试/写代码都能用:

三层缓存两方向,无锁桶锁全局锁
小内存本地拿,没了批量找中央
中央没了找页仓,切分大页来帮忙
释放归还看计数,全空合并碎片光
超大页直接找系统,用完就还不私藏

真正管理内存的是pagecache?central,thread的cache只是逻辑上分走了一页?

我给你一句最本质、最透彻、面试官一听就懂的结论,然后用一张图彻底讲透:

一句话终极结论(背下来)

真正"拥有"物理内存、真正管理内存生命周期的,只有 PageCache。
ThreadCache 和 CentralCache 本质上
只是在逻辑上"借用、切分、搬运"小块内存,它们不拥有内存,只管理指针链表。**


用最直白的话讲清楚

1. 谁真正拥有内存?

只有 PageCache 拥有内存

  • PageCache 向操作系统真正申请内存(mmap / VirtualAlloc)
  • PageCache 向操作系统真正释放内存
  • PageCache 负责合并、切分、管理连续物理页
  • 只有 PageCache 知道:哪块内存是空闲、哪块被占用、哪块能合并

内存本体 → 全在 PageCache 手里。


2. CentralCache 到底在干嘛?

不拥有内存

它只是把 PageCache 给的一整页物理页,切成小对象,管理起来

  • CentralCache 向 PageCache 要:给我 1 页!
  • PageCache 给它一个 Span(代表这 1 页)
  • CentralCache 把这 1 页切成:
    • 512 个 16B 小块
    • 1024 个 8B 小块
  • 然后用 FreeList 串起来

👉 CentralCache 只管理:切好的小内存块链表(逻辑结构)
不拥有内存,只是"代理管理"。


3. ThreadCache 又在干嘛?

它更"虚",连页都不碰

  • ThreadCache 向 CentralCache 批量借:给我 50 块 16B!
  • 它拿到的只是一堆指针
  • 它把这些指针串成自己的 FreeList
  • 分配的时候直接从链表取

👉 ThreadCache 完全不管理内存,只管理"指针链表"。
内存本体还在 PageCache 分配的那一页里。


终极结构图(一眼看懂谁在真正管内存)

复制代码
【操作系统】
   ↓ 真正分配物理页
【PageCache】(真正拥有内存)
   ↓ 管理:连续物理页、合并、切分、释放
   │  给出 1 页 = 给出一个 Span
   ↓
【CentralCache】(逻辑管理层)
   ↓ 把 1 页切成 N 个小内存块
   ↓ 管理:切好的小块链表
   ↓ 批量借给 ThreadCache
【ThreadCache】(纯逻辑缓存)
   ↓ 管理:指针链表
   ↓ 给用户分配
【用户】

再用租房类比(100%理解)

  • 操作系统 = 国家土地局
  • PageCache = 大地产商
    • 真正从国家拿地
    • 真正拥有土地(内存)
    • 负责规划、合并地块
  • CentralCache = 建筑商
    • 从地产商(PageCache)拿一整块地
    • 盖楼、切成单间
    • 不拥有地,只负责管理单间
  • ThreadCache = 楼管/前台
    • 从建筑商手里拿一批单间
    • 直接租给用户
    • 完全不碰地,只管钥匙(指针)

最终三句金句(面试必背)

  1. PageCache 是真正的内存管理者,拥有物理页,负责申请、释放、合并。
  2. CentralCache 只是逻辑层:把页切成小对象,管理小块链表。
  3. ThreadCache 只是纯缓存:只管理指针,不拥有任何内存。

如果你愿意,我能再给你画一张
"谁真正拥有内存、谁只是管理指针"的极简对比图

面试画出来,面试官直接觉得你底层原理非常通透

不太理解pagecache,centralcache的spanlist,比如spanlist(0),挂的是一个个span,而这些span均是8b的,用一个链表不行?还用多个span的多个freelist?

用最简单、最直白、最贴近真实内存 的方式,给你讲透:
为什么不能只用一条链表,必须搞成:SpanList[桶] → 挂很多 Span → 每个 Span 内部一条 FreeList

你现在的困惑是:

都是 8B 的内存块,我直接用一条大链表串起来不就完了?

干嘛要搞:

SpanList[0] → 挂 N 个 Span → 每个 Span 自己又一条 FreeList?

这不脱裤子放屁?

我告诉你:不是多余,是必须这么设计!

我保证你看完这一页,彻底通透。


1. 先回答你最核心的疑问:

为什么 8B 的块,不能只用一条链表?
为什么要分成很多个 Span?

因为:

**内存不是一整块无限大的!

内存是一页一页来的(8KB/页)!**

你申请 8B,我不能只给你 8B,

操作系统最小单位是一页(8KB)

所以:

  • 我向系统要 1 页 = 8KB
  • 切成 1024 个 8B 小块
  • 这 1024 块,属于同一个页
  • 这一整页,我用 一个 Span 来管理

当这 1024 块用完了,

我必须再向系统要一页 → 再生成一个新的 Span

所以:
SpanList[0] 下面,一定会有很多个 Span,
每个 Span 对应一页,每页切成 1024 个 8B 块。


2. 为什么不能把所有 8B 块串成一条链表?

你想的结构:

复制代码
8B块 → 8B块 → 8B块 → 8B块 → ...(无限长)

真实内存不允许!

因为:

  • 第 1~1024 块 → 来自 页A
  • 第 1025~2048 块 → 来自 页B
  • 第 2049~3072 块 → 来自 页C

来自不同页的块,绝对不能混在一起管理!

为什么?

因为未来释放、合并、回收页 ,必须以为单位!


3. 真正的关键:回收!

你必须记住一句话:

**内存释放,必须以"页"为单位,还给操作系统。

不能只还 8B!**

所以:

  • 当一页里 所有 1024 个 8B 块都空闲了
  • 我才能把 这一整页还给操作系统
  • 我不能只还几个 8B

那我怎么知道:
哪一页的所有块都空了?

答案:

靠 Span!

每个 Span 管理 一整页

Span 里有个计数器:

  • _useCount:正在使用的块数量

_useCount == 0

→ 这一页完全空了

→ 可以还给 PageCache

→ PageCache 可以合并、还给系统

如果你把所有 8B 块串成一条链表

永远不知道哪一页空了
永远不能回收内存给系统

→ 内存泄漏 → 内存爆炸


4. 现在我给你画「真实结构」,你瞬间懂

SpanList[0](8B 桶)

复制代码
SpanList[0] (8B 专用桶)
   │
   ├─ Span A(管理页A)
   │    └─ 内部 FreeList:页A切出来的 1024 个 8B 块
   │
   ├─ Span B(管理页B)
   │    └─ 内部 FreeList:页B切出来的 1024 个 8B 块
   │
   └─ Span C(管理页C)
        └─ 内部 FreeList:页C切出来的 1024 个 8B 块

每一个 Span = 一页 = 1024 个 8B 块

每一个 Span 有独立的 useCount

只有 useCount=0 才能回收整页


5. 终极总结(你一定要背)

为什么不能一条链表?

因为内存是按页分配、按页释放

为什么必须 SpanList[桶] 挂很多 Span?

  • 一个 Span = 一页
  • 一页只能整体回收
  • 必须用 Span 记录:这页里用了多少块、空了多少块

为什么每个 Span 内部一个 FreeList?

  • FreeList 管理这一页里的空闲小块
  • 不同页的块绝不混串

最直白的三句话:

  1. 8B 的块虽然一样,但来自不同的页,必须分开管理
  2. 一个 Span 管理一整页,不能多页混在一个 Span
  3. 只有按页管理,未来才能整页回收、合并、还给系统

相关推荐
唠玖馆2 小时前
c++ 类和对象(全)
java·开发语言·c++
echome8882 小时前
Python 异步编程实战:async/await 从入门到精通
开发语言·python·php
Morwit2 小时前
【力扣hot100】 85. 最大矩形
c++·算法·leetcode·职场和发展
薛定猫AI2 小时前
【技术干货】Cloud Code vs Codex:架构对比、性能评估与实战混合方案
架构
小杍随笔2 小时前
【Rust 语言编程知识与应用:自定义数据类型详解】
开发语言·后端·rust
m0_528174452 小时前
C++中的代理模式变体
开发语言·c++·算法
皙然2 小时前
深入理解 Java HashMap:从底层原理、源码设计到面试考点全解析
java·开发语言·面试
蜗牛会飞 20242 小时前
大数据时代个人信息保护五大挑战
开发语言·华为云·个人开发·c5全栈
mjhcsp3 小时前
C++ 折半搜索(Meet in the Middle):突破指数级复杂度的分治策略
开发语言·c++