C++内存管理机制——malloc_free

VC6 VS VC10

VC6内存分配

下面的图片展示了VC6内存分配的调用栈,操作系统会调用mainCRTStartup()做一些准备工作,然后才会调用程序编写者的main()函数。在_heap_alloc_base()函数中,如果申请的内存大小小于_shb_threshold,则会通过_sbh_alloc_block(size)申请内存,否则通过操作系统的HeapAlloc()申请内存。

SBH: Small Block Heap

VC6内存分配调用栈

VC10内存分配

VC10则无论什么大小,都通过系统的HeapAlloc()来申请内存。这是否意味着VC6的设计没有意义了呢?并不是,只是这些操作由操作系统来完成了。

VC10内存分配调用栈

SBH之始——_heap_init()_sbh_heap_init()

_heap_init()是crt的第一个动作,它会通过HeapCreate()向操作系统申请一块大内存,接着,调用_sbh_heap_init()进行初始化,从之前分配的_crtheap中申请16个HEADER大小的内存空间。

_heap_init()和_sbh_heap_init()
HEADER结构

VC6内存管理

_ioinit()

_malloc_crt()

_ioinit()内部调用_malloc_crt(IOINFO_ARRAY_ELTS * sizeof(ioinfo))分配内存。如果当前不是_DEBUG模式,则_malloc_crt就对应malloc,否则对应_malloc_dbg。申请的这块内存大小为32*8=256IOINFO_ARRAY_ELTS是32,ioinfo结构体本身是6,但会被对齐到8),在16进制时表示为100

_ioinit()

_heap_alloc_dbg()

_heap_alloc_dbg()则会在分配内存的基础上添加debug信息构建内存块,内存块的结构如下图所示。每个内存块的大小为sizeof(_CrtMemBlockHeader)+nSize+nNoMansLandSize。header部分是_CrtMemBlockHeader,包含指向上一个内存块和下一个内存块的指针,调用方的文件名,调用的代码行、申请的内存大小等debug信息。nSize是申请的内存大小,即上一小节提到的256。申请内存区域上下被无人区nNoMansLandSize包围,无人区写入的内容是固定的,用于判断是否越界。

_heap_alloc_dbg()1
_heap_alloc_dbg()2

_heap_alloc_base()

这里会对申请内存大小做一次判断,如果小于等于__sbh_threshold,则调用_sbh_alloc_block(size)分配内存。否则,通过操作系统的HeapAlloc()申请内存。__sbh_threshold的值为1016,因为此时的size还不是完整内存块的大小,后续还会加上cookie,而我们知道cookie的大小为8。

_heap_alloc_base()

__sbh_alloc_block()

到这个阶段,才开始为内存块加上cookie,并通过BYTES_PER_PARA做16字节的对齐。

__sbh_alloc_block()

__sbh_alloc_new_region()

一个header将会申请真正的内存,并在将来分割出去。分割出来的块有大有小,为了对其做管理。会new出来一块regionregion中有32个group,每个group是大小为64的双向链表。BITVECunsigned integerregion中有32组bitvGroup,一组是64位(32+32)。

__sbh_alloc_new_region

__shb_alloc_new_group()

对于申请到的1MB的大内存块,将其分为32个group,每个group大小为1024/32=32K。每个group还可以细分为8块,每块大小为4k,称之为page。单个page中用两块存放内容为0xfffffff的内存标记包含的区域,图中4080是两块0xffffffff中间的区域大小,而两块0xffffffff是8个字节,4096-8=4088,为了做16个字节的对齐,将其中8位留作保留位,剩下的4080供使用。

__sbh_alloc_new_group()

当前阶段是初始化阶段,8个page是直接由group链表中的最后一组来管理的,这组链表用来管理超过1k的内存。
__sbh_alloc_new_group()_page

切割

_ioinit()申请了256(16进制表示为100)字节的内存,加上debug信息和cookie,共计130字节。crt会根据需求申请对应的内存块,并向其中写入debug信息,其中00000002nBlockUse的值,表示这是_CRT_BLOCK,main函数结束前应检查_NORMAL_BLOCK是否还有使用的,而不是所有BLOCK。系统会返回实际可填充的fill 0xcd的地址给调用方。

切割

SBH行为分析

首次分配

首次分配是为_ioinit()进行分配,申请的内存大小为100h,加上debug信息和cookie后,共计130h130hheader中对应编号为18的链表(链表对应的内存块大小为19*16=304(304+15)/16-1=18)。最开始初始化的时候,已经有16个HEADER了,编号为0的HEADER会开始处理,使用p=VirtualAlloc(0, 1Mb, MEM_RESERVE,...)申请1Mb的内存,这里的内存是虚拟内存,0表示不指定位置,MEM_RESERVE表示保留地址空间。接着,使用HeapAlloc(crtheap, sizeof(REGION))分配Region所需的内存,包括其管理的GroupGroup0指向一个大小为64的链表,然后从1Mb的预备内存中获取32k的内存,其中有8个page。用指针将page串起来,用Group0管理链表的最后一对指针进行管理(前面提到过page大小超过1k,使用最后一堆指针进行管理)。这32k的内存是通过VirtualAlloc(addr, 32Kb, MEM_COMMIT,...)获取的,其中MEM_COMMIT表示真的分配物理内存。接下来在page1中为调用方分配内存,并返回可填充的地址。Region中的bitvGroup共有32组,对应着Group中的64个链表,哪一条链表有区块哪一条对应的bit就被置为1,因此只有最后一位bit被置为1。因为Group0当前正在被使用,因此indGroupUse值为0。

SBH行为分析——首次分配

第2次及第3次,分配

第二次分配是为_crtGetEnvironmentStringA分配内存,该内存的大小由设置的环境变量决定,图中示例为加上debug等信息后240。240h=256*2+4*16=576(576+15)/16-1=35,因此去bitvGroup中查询第35号链表是否已经分配了。没有分配,能够找到最近的已经分配的是最后一个链表,因此去最后一个链表中分配内存。

SBH行为分析——第2次,分配
SBH行为分析——第3次,分配

第15次,释放

假设第15次进行释放操作,此时会先归还_crtGetEnvironmentStringA得到的240h的内存,因为这个节点已经处理完环境变量了,在没有执行main()之前就会归还这部分内存。

240h/10h结果是十进位的36,因此会归还到编号为35的链表。内存两端的0x00000241会被修改为0x00000240,表示这块内存已经被free了。并将内存块从数据转变成嵌入式指针,由35号链表进行管理,35号链表对应的bit位被置为1。 前14次都是分配,cntEntries值为14,此时也要减1变成13。

SBH行为分析——第15次,释放

第16次,分配

第16次,申请b0大小的内存。向右找到最近的已分配内存的链表——35号链表,为其分配内存,240h的内存还剩190h为空闲,190h/10h=25,因此应当放到编号为24的链表中管理。

SBH行为分析——第16次,分配

第n次,分配

Group0内存使用情况用02000014 00000000表示。申请230h的内存,如恰好存在可以分配的链表则标志位应为00000000 20000000,因此Group0无法满足要求。开始使用Group1indGroupUse值变更为1。

图中00000000 20000000中每个数字由4个二进制位组成,8个0就是4*8=32,2为0010,因此00000000 20000000就对应34号链表。

SBH行为分析——第n次,分配

区块合并

下图展示了区块合并的过程,当前计划回收灰色的区块,其内存大小为300h。在将其回收时,free(ptr)ptr应当指向的是可填充位置的首地址,可以通过读其上4个字节的cookie获取到这块内存的大小,然后加上内存大小跳转到下一块内存的cookie,检查下一块内存是否为空,如果是则将两块内存合并,直到下一个块内存不为空。接着,检查其上方的内存,如果为空则合并,直至不为空。将合并后的内存交由对应的链表进行管理。

区块合并

free(p)

SBH会维护一个__sbh_pHeaderList指向Header表格,而通过单个header可以找到对应的1Mb内存,可以直接计算地址是否在该内存中,通过这种方式我们可以定位到p在哪个Header内。至于Group,则通过(p-Header)/32Kb定位。定位所在的free_list则是通过读cookie知道自身内存大小,然后计算应该位于哪块free_list

free(p)

总结

  1. 为什么要把内存分成这么多段,并且用group进行管理?
    如果只用一个group进行管理,则切割成大大小小的块之后,则回收时需要全部为空才能回收,减小粒度之后更容易回收。

  2. 如何判断全回收
    通过cntEntries记录malloc和free的次数,malloc之后就加1,free之后就减1,这样就无需遍历链表,实现快速判断是否需要全回收。

分段与全回收

  1. 为什么8个Page都为空之后不进行合并再回收?
    因为SBH不是立即回收,它会保留一段时间,等到下次全回收的时候再回收,这样可以减少向系统申请内存的开销。维持原有的链表结构,可以更快的利用内存。

  2. 如何延缓全回收的动作?
    __sbh_heap_init()时将__sbh_pHeaderScan值置为NULL了,该指针指向一个全回收Group所属的Header,这个Group原本应该被释放,但暂时保留了。当有第二个全回收的Group时,才释放这个Defer Group,将新出现的全回收Group设为Defer。如果尚未出现第二个全回收而又从Defer Group中取出block完成分配,则Defer指针会被置为NULL,即该Group会再次投入使用,不会被全回收。__sbh_indGroupDefer是索引,指出Region中哪个GroupDefer

全回收策略

参考

侯捷-C++内存管理机制


C++内存管理机制——malloc_free
https://delta0406.github.io/2025/11/13/技术/语言/CPP/C-内存管理机制——malloc-free/
作者
执妄
发布于
2025年11月13日
许可协议