侯捷老师 C++内存管理-第三讲 学习笔记

第三讲 malloc/free

还记得我们第一讲的第一张图么,内存的分配与释放,CRT(malloc/free)已经是相当底层的设计了

image-20220823154717051

一、VC6 内存分配

image-20220825135853655

首先来看,左边称为 call stack,显示的是 进入C++main()程序的前后行为 ,看到序号 8 ,调用的main() 函数,就是自己写的main()函数,也就是程序入口。发现在它之前之后,都做了相当一部分动作。

栈是从高地址向低地址扩展,所以下面是栈顶,程序是从下向上运行,图中缩进代表了它们之间的调用关系。

首先来看mainCRTStartup(),它其实是真正的程序入口,它调用了main()函数

我们先来看看_heap_alloc_base()函数,如果内存小于某个值(1016,在经过特殊处理后是1024),则自己处理;否则调用HeapAlloc()这个系统的函数来处理,发现它其实是处理SBH,也就是小区块内存,前面我们学习的分配器也是小区块处理。所以说SBH的设计是我们这次学习的重点。

image-20220825143338987

对比较新的VC10版本,发现它没有做一个小区块判断,而是直接调用操作系统的HeapAlloc()方法,那么是不是怀疑新版本抛弃了SBH的设计呢,非也,在新版本中将SBH小区块的设计添加到了HeapAlloc()方法中,也就是操作系统也在做同样的设计,所以我们更需要了解SBH的设计。

二、SBH(small block heap)

SBH 就是malloc()中小区块内存的一种精巧设计

下面我们就跟着 call stack 的调用顺序来分析SBH的设计

1._heap_init()

image-20220825144016817

分析前,我们先来简单了解win的内存管理,首先它本身维护了海量的内存,你可以先要求它帮你创建一个heap然后命名,然后你可以在申请的这块空间内操作其他行为;同样,你也可以再次要求它帮你创建另一个heap然后命名,然后再操作其他不同的行为,这就实现了一个逻辑上的分类。

在这了解之后,我们来看mainCRTStartup()调用的第一个函数,_heap_init(),它首先调用系统的HeapCreate()函数,请求它帮你分配一块4096bytes的内存(4096,4K,它会随着调用次数的增多而变大),然后命名为__crtheap,这样你就可以对这块内存进行操作,其实,我们申请的这块内存就是让真正的程序入口mainCRTStartup()来使用的。

然后_heap_init()调用了 _sbh_heap_init() 函数,所以说我们来看 _sbh_heap_init()函数,它也申请了一块内存,从什么地方申请的呢,从我们刚才申请那一块内存中( _crtheap )申请了一块内存,然后命名为__sbh_pHeaderList,申请了多大呢,16 * sizeof(HEADER) bytes 大小,就是上方的绿色链表,每一块就是一个Header,下面解释Header。

image-20220825150703562

我们可以看到,一个Header就是一个结构体,它里面定义了一些数据,如何使用呢,后续会解释

2._ioinit()

image-20220825151724354

接着我们来看下一个函数 _ioinit(),发现它调用 _malloc_crt() 进行了第一次内存分配,看起来很陌生的一个函数,跟着箭头再来看,发现,如果在非debug模式下,它其实就是malloc(嗯,很熟悉);在debug模式下,它就是 _malloc_dbg;再来看图中左侧 _ioinit() 下一次调用函数是 _malloc_dbg(), 所以接下来我们来看 _malloc_dbg(),它分配了 32 * 8 = 256 (100H) bytes 大小内存,命名为pio。

3._malloc_dbg()

image-20220825153408429

接着往下来看,_malloc_dbg 调用了 _nh_malloc_dbg(跳过),后者又调用了 _heap_alloc_dbg,

所以接下来我们来看 _heap_alloc_dbg,这个函数的目的就是为我们申请的内存再添加一块内存用来调试。

首先,nSize,就是我们前面调用 _malloc_dbg 传进来的申请内存的大小,

接着, _CrtMemBlockHeader,还记得前面所说VC6在调试模式下的Debug Header么,就是它!我们来剖析它的内容,首先两个指针先不管它;接着一个char*,它指向的是文件名;接着一个int,代表文件的第nLine行;接着一个nDataSize,代表你所申请真正的数据大小,就是传入的nSize;接着是nBlockUse,代表内存类型(后续详解);接着是IRequest,它是一个流水号,这里是1;接着是一个数组,它后面会被填值,可以理解为一根“红线”;

最后,nNoMansLandSize,理解为“无人区”,大小为4,后面也会被填值,它其实也可以理解为一跟“红线”,怎么理解呢,这两根“红线”,将真正的内存“保护起来”,因为现在是debug模式,所以它可以帮我们检查我们是否越界访问,这就是两根“红线”。

这三者相加,就是在debug模式下调整过的内存大小blockSize

除此之外,我们可以来理解一下一些函数的命名含义,

  • _heap_alloc_dbg,这个dbg就是debug模式下的增添的内存,也就是Debug header,所以说这个函数就是为Debug Header分配内存。

  • _sbh_alloc_block,这个block指的是调整后的整个内存块,所以说这个函数是分配调整后的内存

4._heap_alloc_dbg()

image-20220825162053405

接上,继续 _heap_alloc_dbg,我们来认识两根指针, _pFirstBlock, _pLastBlock,它们的作用是,记录已经分配的内存块,方便管理。

接着看下面几行memset,就是在某些地方填值,填什么呢,“无人区” 0xFD,将要回收的 0xDD,将要清除的 0xCD

5._heap_alloc_base()

image-20220825163154644

再次学习_heap_alloc_base()函数,如果内存小于某个值(1016),则自己处理;否则调用HeapAlloc()这个系统的函数来处理

为什么是1016,我们知道malloc是会添加cookie的,并且上下cookie总共8bytes,我们需要知道,什么是小区块,申请的内存size加上cookie之后,如果小于1024就是小区块,因为现在没有加cookie,所以是1016

6._sbh_alloc_block()

image-20220825165220083

接着,下一函数 _sbh_alloc_block 的调用,我们前面说,这个函数是分配调整后的内存,是如何调整的呢,intSize也就是传入进来的blockSize,加上2个cookie,再加上后面的一串,调整到16的倍数,计算最终需要分配的内存大小为130H

7._sbh_alloc_new_region()

image-20220825175702618

继续,_sbh_alloc_new_region(),前面的各种调用,最终的目的是计算要分配的内存大小,现在来真正实现如何分配管理。

我们知道前面 _sbh_heap_init() 这个函数分配了16个Header,一个Header有2个指针,其中一个pHeapData指向一块虚拟地址空间(1M大小,现在还没有分配内存,等到使用的时候再分配),如何管理呢,通过另一个pRegion指针,指向一个“管理中心”。再来看Region的设计,黄色圈起来的部分,包含了一组Group。我们继续来看Group,里面有一组ListHead,每一个ListHead是一对指针,所以所可以想象,它是一条双向链表。

image-20220825180934869

8._sbh_alloc_new_group()

image-20220825170825671

进入 _sbh_alloc_new_group(),查看如何管理,因为有32个Group进行管理,所以把虚拟地址空间(1M)划分为32单元(每一单元就是 1M / 32 = 32K),每一单元再细分8块(4K大小),而一个Group就是对这8块进行管理。这就相当于什么呢,就是前面先挖一大块内存,然后在划分为小块,再将它们串接起来,用一根链表管理。

补充:
计算机中称 4K为一个page,16 为一个 paragraph

image-20220825185215922

细看,每一块的头尾设置为0xffffffff也就是-1,目的是为了将来回收时的合并,并且即使合并了还是依靠-1来划分每一块内存;然后两个4080记录的是每一块的大小(也就是cookie),如何计算的呢,前面说到1page是4K,也就是4096bytes,减去头尾8bites,还剩4088bytes,但因为4088不是16的倍数,所以有8bytes保留,剩余的就是4080bytes。

我们知道,一个Group包含64个HeadList,而每一个HeadList又是两个指针,所以说总共有64组指针进行管理。类似于第二讲G2.9中讲的 16条链表,64组指针分别管理不同的字节,按照16的倍数进行管理。比如,第一组管理16 bytes,第二组32 bytes…不同的是,按照之前的想法最后一组应该是1024 ,但在这里是凡是大于等于1024的都归它管理。

image-20220825191712052

我们前面计算出,第一次分配的内存为130H,现在开始切割

剩余 ff0H(4080) - 130H = ec0H,切割后分为两部分,上面是留下的,下面是分配的,然后将红色地址的指针传出,并将cookie的最后一位设置为1,表示已经分配。 130H这一块的细节就是右边,跟前面我们分析细节的一样。

还记得前面所说的内存类型,这里是2,_CRT_BLOCK ,表示分配给CRT的,而1 _NORMAL_BLOCK,表示分配给main()函数的。

所以说什么时候会发生内存泄露呢,在main()函数结束前一刻,如果还有内存类型为1,就说明发生了内存泄漏,而如果是2,就不是内存泄漏,因为此时CRT还没有结束,在main()后面还有一些动作。

9.SBH行为分析-连续分配释放

上述过程是VC6内存管理的首次分配,现在来整理一下

image-20220826154416229

由前面知道,_ioinit(),申请了第一次内存分配,并且计算大小为130H,按理说由 第 18 组链表管理如果它身上有内存的话,计算方法 130H(16 * 16 + 16 * 3)/ 16 - 1 = 18;在此之前已经分配了16个Header,我们前面已经知道,Header有两个指针,一个指向虚拟地址空间,一个指向管理中心;而指向前者的那个会调用VirtualAlloc()从海量的内存空间来分配1M的虚拟地址空间,它是操作系统的函数,为什么说是虚拟地址空间呢的呢,看它的参数MEM_RESERVE,内存保留,就是说 “这块内存我预定了,其他人不能要,我什么时候用你什么时候再给我” ;另一个指针会调用HeapAlloc()从 _crtHeap 中分配一块内存,大小为一个REGION,里面存放一些数据来方便管理虚拟地址空间;而region中里面有32个Group,一个group又是64对指针,然后我们之前说将虚拟地址空间分为32个单元对应32个group,使其分别管理,现在我们真正需要一块内存,就调用VirtualAlloc()函数,并传参MEM_COMMIT,告诉它现在我需要这块内存了,你分配给我吧,大小是一个单元32K。再把这个单元分成更小的8块,并且以双指针串联起来,交个最后一组指针管理(因为分成八块一块4K,大于1K)。好现在我们需要的是130H,就开始从第1小块切割出130H给申请者。

现在我们再来看看之前没有说到的一些内容,我们知道region除了32个group之外,还有一些内容image-20220826163559182image-20220826163818062

intGroupUse,它表示正在使用哪一个group,现在是第0个;

cntRegionSize是一组字符,

bitvGroupHi与bitvGroupLo分别是32个unsigned int(32bit),而它们则组成了32组64bit,可以想象这64对应64组链表,32对应32个group,所以它们表示每组链表对应的状态,1说明已经挂了内存,0表示没有;细看上图最后1位为1,其余为0,这也就对应了上方,因为第18组链表没有悬挂内存,所以它会向后继续找,直到找到最后一组链表发现它有内存,所以就让它来分配管理。

image-20220826171047579

第2次内存分配,是谁请求的呢,call stack中的 _crtGetEnvironmentStringA()请求的,它的内存请求在经过一些计算后为240H,作用是用来收集环境变量的,我们这边只看如何分配。

按理说240H应该是由第(16 * 16 * 2 + 16 * 4)/ 16 - 1 = 19组链表管理,同样,由于第19组链表并没有悬挂内存,所以它只能交给当前唯一悬挂内存的最后一组链表管理,

现在我们再来看看,group中的一个int变量cntEntries,就是右上角的group的第一格,当前显示的是2,第一次显示的是1,它记录的是分配的内存块数,如果分配就+1,释放就-1,当为0的时候,就代表分配出来的内存全部被回收。

image-20220826174959231

image-20220826175457335

假设第三次内存分配70H,也是同样的分配过程,可以自己过一下流程,以及一些变量的变化

image-20220826180116231

假设前面已经分配了14次,现在开始释放回收,并且假设回收第2次分配的240H这一块内存,先来看右上角intEntries,由14变为13,首先计算它应该是由第35组链表管理回收,怎么收呢,就是把这一块内存的cookie的最后一位改为0即可(前面说过,1代表分配,0代表回收),然后挂回第35组链表,这样就说明回收了,但是此时还没有结束,还需要改region中对应的bit,说明它悬挂了内存。

image-20220826182113085

现在再来进行一次分配,假设分配b0H,计算应该由第10组链表管理,但是第10组链表没有悬挂内存,所以它会往后找,找到第一块能够满足需求的内存,就是第35组链表,刚才释放的那一块,从中切除它需要的b0H,剩余190H,然后计算190H应该由第24组链表管理,所以修改由第24组链表管理,然后更改bit的状态将第35位改为0,第24位改为1。

image-20220826183302686

现在到了第n次内存分配,假设第一块分配的内存无法满足需求,则开始分配使用第二块内存,所以group变化了,intGroupUse也变为1

image-20220826185253518

对于一个好的内存管理系统来说,应该具备内存的合并,就是说如果回收的几块内存是连续的,就应该它他们合并,否则内存会越来越碎。

现在来看如何合并,此时cookie的另一个作用就体现出来了,来看左图红框的内存,它是刚刚回收的内存,而它连续的上下刚好也已经被回收,所以开始合并,从当前上cookie可知当前内存的长度,然后移动到最后一个位置,向下看4个字节,也就是它后面内存的上cookie,发现它最后一位是0,并且根据它所记录的大小,开始合并,得到中间图;但此时还没完,它又开始向上找4个字节,是上一块内存的下cookie,发现它最后一位也是0,根据它所记录的大小,开始合并,计算合并后的总内存,再交给对应的链表管理,得到右图。所以说cookie的设计是必须的。

三、总结

image-20220827214621496

刚才我们计算了要释放的内存p应该归哪组链表管,除此之外,SBH还需要知道更详细的,比如前面一级一级到达64组链表中,链表之上有group,group之上有header,所以说我们现在来看它是落到哪一个header的哪一个group的那个链表。header是由一个指针指向并且header的大小已知,所以我们就可以直到一块header所包含的地址范围,将p与地址范围进行比较就知道是落在哪一个header中了,我们只知道一个header切割为32块交给32个group管理,将p的地址减去header首地址,再除以每一块的大小,就可以知道是落在哪一块中也就是哪一个group中,实现了分段管理内存

image-20220827215741171

我们知道链表是可以无限添加的在内存充足的情况下,而且只使用一根链表管理好像也可以,那为什么还要设计这么复杂进行分段管理呢?

如果只使用一根链表的话,只有当内存全部释放后,才归还给操作系统,归还的时机太远,不好管理;并且如何判断内存全部释放呢,不好判断。

而分段管理是当一段内存释放完之后就可以归还给操作系统,还记得group中的一个int变量intEntries么,它是来记录当前有多少块内存分配了,如果为0,表示全部释放,此时就可以归还给操作系统。

还有一点,不要躁进,什么意思呢,当只有一块全回收的时候,它不着急归还给操作系统,万一下次又需要到它,就不需要在重新分配,直接使用可以。那什么时候回收呢,当有2块全回收的内存的时候就可以归还给操作系统。那它是怎么实现延缓回收的呢?

image-20220827221732632

我们就来说一点,前面在_sbh_heap_init(),中有一根指针Defer,它就是用来管理延缓回收的,具体细节看中间文字即可,这里不做赘叙。

image-20220827222508555

当全回收一段内存之后,我们知道此时还没有归还给操作系统,所以说它会以首次分配前的状态呈现,以便再次被分配。

最后:
欢迎指正不足或错误的地方。如果文章对你有所帮助,欢迎点赞支持。欢迎转载。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值