C++ - 高性能内存池

发布于:2025-09-11 ⋅ 阅读:(20) ⋅ 点赞:(0)

1.什么是内存池

1. 池化技术

所谓“池化技术”,就是程序先向系统申请过量的资源,然后⾃⼰管理,以备不时之需。之所以要申 请过量的资源,是因为每次申请该资源都有较⼤的开销,不如提前申请好了,这样使⽤时就会变得⾮常快捷,⼤⼤提⾼程序运⾏效率。
在计算机中,有很多使⽤“池”这种技术的地⽅,除了内存池,还有连接池、线程池、对象池等。以
服务器上的线程池为例,它的主要思想是:先启动若⼲数量的线程,让它们处于睡眠状态,当接收到客⼾端的请求时,唤醒池中某个睡眠的线程,让它来处理客⼾端的请求,当处理完这个请求,线程⼜进⼊睡眠状态。

2. 内存池

内存池是指程序预先从操作系统申请⼀块⾜够⼤内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,⽽是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,⽽是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
 

3. 内存池主要解决的问题

内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的⻆度,还需要解决⼀下内存碎⽚的问题。
 
内存碎⽚分为外碎⽚和内碎⽚。
 
外部碎⽚是⼀些空闲的连续内存区域太⼩,这些内存空间不连续,以⾄于合计的内存⾜够,但是不能满⾜⼀些的内存分配申请需求。
 
内部碎⽚是由于⼀些对⻬的需求,导致分配出去的空间中⼀些内存⽆法被利⽤。
 

4. malloc 

C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,⽽malloc就是⼀个内存池。malloc() 相当于向操作系统“批发”了⼀块较⼤的内存空间,然后“零售”给程序⽤。当全部“售完”或程序有⼤量的内存需求时,再根据实际需求向操作系统“进货”。

 5. 设计一个定长内存池

申请内存使⽤的是malloc,malloc其实就是⼀个通⽤的⼤众货,什么场景下都可以⽤,但是什么场景下都可以⽤就意味着什么场景下都不会有很⾼的性能

 

VirtualAlloc

void* ptr = VirtualAlloc(0, kpage<<13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

各参数含义如下:

  1. 第一个参数(lpAddress)0
    指定要分配的内存起始地址。传入 0 表示让系统自动选择合适的地址,这是最常用的方式。

  2. 第二个参数(dwSize)kpage<<13
    表示要分配的内存大小(字节)。kpage<<13 等价于 kpage * 8192(因为 2^13 = 8192),即分配大小为 kpage 乘以 8KB。

  3. 第三个参数(flAllocationType)MEM_COMMIT | MEM_RESERVE
    内存分配类型,这里是两个标志的组合:

    • MEM_RESERVE:在虚拟地址空间中保留一块区域(不实际分配物理内存)
    • MEM_COMMIT:为已保留的区域分配物理内存或页面文件(交换文件)
      组合使用表示 "既保留地址空间,又提交物理内存"。
  4. 第四个参数(flProtect)PAGE_READWRITE
    内存保护属性,表示该内存区域可读可写,但不可执行(Windows 会启用 DEP 保护)。

ObjectPool.h

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

1. 性能问题。

2. 多线程环境下,锁竞争问题。

3. 内存碎⽚问题。
 
concurrent memory pool主要由以下3个部分构成:
 
1. thread cache:线程缓存是每个线程独有的,⽤于⼩于256KB的内存的分配,线程从这里申请内存不需要加锁,每个线程独享⼀个cache,这也就是这个并发线程池高效的地方
 
2. central cache:中⼼缓存是所有线程所共享,thread cache是按需从central cache中获取的对
象。central cache合适的时机回收thread cache中的对象,避免⼀个线程占⽤了太多的内存,⽽其
他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的⽬的。central cache是存在竞
争的,所以从这⾥取内存对象是需要加锁,⾸先这里用的是桶锁,其次只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。
 
3. page cache:⻚缓存是在central cache缓存上⾯的⼀层缓存,存储的内存是以⻚为单位存储及分配的,central cache没有内存对象时,从page cache分配出⼀定数量的page,并切割成定⻓⼤⼩的⼩块内存,分配给central cache。当⼀个span的⼏个跨度⻚的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。
 

Common.h

7. 高并发内存池 -- thread cache

 thread cache是哈希桶结构,每个桶是⼀个按桶位置映射⼤⼩的内存块对象的⾃由链表。每个线程都会有⼀个thread cache对象,这样每个线程在这⾥获取对象和释放对象时是⽆锁的。

申请内存:

 1. 当内存申请size<=256KB,先获取到线程本地存储的thread cache对象,计算size映射的哈希桶⾃由链表下标i。

2. 如果⾃由链表_freeLists[i]中有对象,则直接Pop⼀个内存对象返回。

3. 如果_freeLists[i]中没有对象时,则批量从central cache中获取⼀定数量的对象,插⼊到⾃由链表并返回⼀个对象。
 
释放内存:
 
1. 当释放内存⼩于256k时将内存释放回thread cache,计算size映射⾃由链表桶位置i,将对象Push到_freeLists[i]。
 
2. 当链表的⻓度过⻓,则回收⼀部分内存对象到central cache。
 

ThreadCache.h

 

ThreadCache.cpp

_RoundUp函数,高效的向上取整算法,用位运算实现

static inline size_t _RoundUp(size_t bytes, size_t alignNum)
{
    return ((bytes + alignNum - 1) & ~(alignNum - 1));
}

核心是利用位运算的特性快速找到 "比目标大的最小对齐数"

关键步骤拆解(以 8 字节对齐为例)

假设 alignNum=8(我们要按 8 的倍数对齐),bytes 是任意需要对齐的数字(比如 10):

 
  1. 计算 alignNum - 1
    8-1=7,二进制是 000...0111(末尾 3 个 1,因为 8 是 2³)。
    这一步的作用是得到 "对齐数的掩码",末尾的 1 的个数等于对齐数的幂次(8=2³,所以 3 个 1)。

  2. 计算 bytes + (alignNum - 1)
    比如 bytes=10 时,10+7=17(二进制 10001)。
    这一步的作用是:如果 bytes 不是 8 的倍数,就 "凑够" 到下一个 8 的倍数的差值。
    比如 10 离 16 差 6,加 7 后就超过了 16,相当于 "进位" 到了下一个区间。

  3. 计算 ~(alignNum - 1)
    对 7 取反(二进制 111...1000),这是一个 "清除末尾低 n 位" 的掩码(n 是对齐数的幂次)。
    作用是:只保留高位,把末尾可能产生的余数部分清零。

  4. 最后做与运算 &
    17(10001) & 111...1000 = 16(10000),正好是 8 的倍数且大于 10 的最小数。

为什么这样能实现向上取整?

  • 对齐数(如 8、16)都是 2 的幂次方(2ⁿ),它们的二进制只有一位是 1(8=1000,16=10000)。
  • alignNum - 1 则是 "末尾 n 个 1"(7=0111,15=01111),取反后就是 "高位全 1,末尾 n 个 0"。
  • 加 alignNum-1 确保了如果有余数,就会 "进位" 到下一个区间;再与取反后的掩码做与运算,就会把余数部分清零,只保留完整的对齐数。

再举两个例子

  1. bytes=8(正好对齐)
    8+7=15 → 15 & 111...1000 = 8(正确,本身就是 8 的倍数)。

  2. bytes=15(需要对齐到 16)
    15+15=30(假设 alignNum=16,16-1=15) → 30 & 111...11110000 = 16(正确)。

 

简单说,这个算法的本质是:先把数字 "顶" 到下一个对齐区间,再把多余的零头 "切掉",用位运算实现比除法 / 乘法更高效。

_Index函数:单个区间内的偏移索引计算

((bytes + (1 << align_shift) - 1) >> align_shift) - 1

关键参数说明:

  • bytes:当前区间内需要计算索引的内存大小(已根据区间做过范围调整,比如第二个区间会用bytes-128)。
  • align_shift:对齐粒度的 “幂次”(因为对齐数都是 2 的幂,比如 8=2³,所以align_shift=3)。
    1 << align_shift 等价于 “当前区间的对齐数”(比如align_shift=3时,1<<3=8)。

公式拆解(以 “8 字节对齐,align_shift=3” 为例):

假设在第一个区间(bytes<=128,8 字节对齐),我们要计算bytes=9对应的局部索引:

 
  1. 计算1 << align_shift:即对齐数,1<<3=8
  2. 计算bytes + (1 << align_shift) - 1
    即 9 + 8 - 1 = 16。这一步和_RoundUp的逻辑类似,是对bytes做 “向上对齐到对齐数” 的预处理(9 对齐到 8 的倍数是 16)。
  3. 计算>> align_shift:右移 3 位(等价于除以 8),16 >> 3 = 2。这一步得到 “对齐后的大小是对齐数的多少倍”(16 是 8 的 2 倍)。
  4. 最后减 12 - 1 = 1。得到该bytes在当前区间内的局部偏移索引。

Index函数:全局索引拼接

Index函数的作用是将 “局部偏移索引” 转换为全局的FreeList索引(因为整个内存池有 208 个FreeList,分属不同区间)。

核心逻辑:

  1. 区间划分:将MAX_BYTES(256KB)按对齐粒度分为 5 个区间(和RoundUp的对齐规则对应)。
  2. 局部索引计算:在每个区间内用_Index计算局部偏移索引。
  3. 全局索引拼接:加上 “前面所有区间的FreeList数量”,得到最终的全局索引。

8. 高并发内存池 -- central cache

central cache也是⼀个哈希桶结构,他的哈希桶的映射关系跟thread cache是⼀样的。不同的是他的每个哈希桶位置挂是SpanList链表结构,不过每个映射桶下⾯的span中的⼤内存块被按映射关系切成了⼀个个⼩内存块对象挂在span的⾃由链表中。
 

 

 申请内存:

1. 当thread cache中没有内存时,就会批量向central cache申请⼀些内存对象,这⾥的批量获取对象的数量使⽤了类似⽹络tcp协议拥塞控制的慢开始算法;central cache也有⼀个哈希映射的
spanlist,spanlist中挂着span,从span中取出对象给thread cache,这个过程是需要加锁的,不
过这⾥使⽤的是⼀个桶锁,尽可能提⾼效率。
 
2. central cache映射的spanlist中所有span的都没有内存以后,则需要向page cache申请⼀个新的
span对象,拿到span以后将span管理的内存按⼤⼩切好作为⾃由链表链接到⼀起。然后从span中
取对象给thread cache。
 
3. central cache的中挂的span中use_count记录分配了多少个对象出去,分配⼀个对象给thread
cache,就++use_count
 
 
释放内存:
 
1. 当thread_cache过⻓或者线程销毁,则会将内存释放回central cache中的,释放回来时--
use_count。当use_count减到0时则表⽰所有对象都回到了span,则将span释放回page cache,
page cache中会对前后相邻的空闲⻚进⾏合并。
 

CentralCache.h

CentralCache.cpp

9. 高并发内存池 -- page cache

申请内存:
 
1. 当central cache向page cache申请内存时,page cache先检查对应位置有没有span,如果没有则向更⼤⻚寻找⼀个span,如果找到则分裂成两个。⽐如:申请的是4⻚page,4⻚page后⾯没有挂 span,则向后⾯寻找更⼤的span,假设在10⻚page位置找到⼀个span,则将10⻚page span分裂为⼀个4⻚page span和⼀个6⻚page span。
 
2. 如果找到_spanList[128]都没有合适的span,则向系统使⽤mmap、brk或者是VirtualAlloc等⽅式
申请128⻚page span挂在⾃由链表中,再重复1中的过程。
 
3. 需要注意的是central cache和page cache 的核⼼结构都是spanlist的哈希桶,但是他们是有本质区别的,central cache中哈希桶,是按跟thread cache⼀样的⼤⼩对⻬关系映射的,他的spanlist中
挂的span中的内存都被按映射关系切好链接成⼩块内存的⾃由链表。⽽page cache 中的spanlist则
是按下标桶号映射的,也就是说第i号桶中挂的span都是i⻚内存
 
释放内存:
 
1. 如果central cache释放回⼀个span,则依次寻找span的前后page id的没有在使⽤的空闲span,
看是否可以合并,如果合并继续向前寻找。这样就可以将切⼩的内存合并收缩成⼤的span,减少内存碎⽚。
 

锁的设计遵循 “成本 - 收益” 原则

维度 CentralCache 的锁 PageCache 的锁
粒度 细粒度(每个 SpanList 一把锁) 粗粒度(全局一把锁)
竞争频率 低(不同大小内存分配并行) 较高(但访问频率低)
实现复杂度 较高(需管理多把锁) 低(单锁易于维护)
适用场景 高频访问,需最大化并发 低频访问,需保证操作安全性

PageCache.h

PageCache.cpp

10. 内存分配释放接口:

ConcurrentAlloc.h

11.映射结构

二级基数树

三级基数树

PageMap.h

12.测试代码

Benchmark.cpp

 


网站公告

今日签到

点亮在社区的每一天
去签到