目录
2.1 SBH之始------- _heap_init()和__sbh_heap_init()
5 VC6,Heap State Reporting Functions
胸中自有丘壑
触类旁通
1 VC6和VC10的malloc比较
1.1 VC6内存分配:
- 左边的图就是core stack,调用栈;
- CRT: C run time,即C的标准库;
- heap_alloc_base函数进行了小区块的阈值判断,小于等于1016使用__sbh_alloc_block函数进行内存分配,否则使用系统函数HeapAlloc进行内存分配;
1.2 VC10内存分配:
- 划掉的是VC10中不存在的部分;
- heap_alloc_base函数没有对小区块的阈值判断了,而是统一使用系统函数HeapAlloc进行内存分配;
- VC10中没有SBH相关的操作了;
2 VC6内存分配
2.1 SBH之始------- _heap_init()和__sbh_heap_init()
- 调用的是win32的API;
- 初始化一大块向CRT要的Heap;
- 分配了 16 个头,即HEADER:
- pHeadData指向内存;
- pRegion指向管理中心;
2.2 ioinit函数:
- ioinit函数发出了第一次内存分配请求;
- heap_init只是分配 16 个头,头里面(即HEADER) 是什么东西是不清楚的;
- ioinit函数注意此处的申请32∗8=256Bytes 大小的内存;
2.3 _heap_alloc_dbg函数:
- Debug模式下,heap_alloc_dbg函数是在调整内存块的大小,此处的nSize 就是上面提到的 256Bytes;
- 也即是说,在Debug模式下,你需要的大小会被调整得更大一些(如右侧的图所示);
- 此时还没分配,只是在调整(扩大空间),调整好之后分配就要分配这些东西;
- _CrtMemBlockHeader结构体变量说明:
- szFileName:记录是文件的哪一行发出来的申请;
- nDataSize: 对象实际的大小;
- 1Request: 流水号;
- heap_alloc_dbg函数此时是在调整指针;
- 所有经过malloc分配的内存块都用链表串起来了,即使这块内存块已经给用户了,仍然在它(sbh)的掌控之中,这是在调试模式下;
- 之所以在调试器能追踪内存,因为在调试模式下,多了很多东西,反映到图上就是多了深灰色之外的东西;
- 此处调用了memset给特定位置设置初值,以便观察后续的内存块变化情况;
2.4 _heap_alloc_base函数:
- 此处的size是经过扩充调整后的大小,将这个大小与阈值进行比较;
- 这个size目前还没加cookie(8bytes),如果加上cookie后这个size小于1024,它就是小区块,而现在还没加cookie,所以此处是小于 1016;
2.5 __sbh_alloc_block函数:
- intSize 就是之前得到的内存块大小;
- 2 * sizeof(int)就是加 2 个cookie;
- 最后的部分是在做RoundUp,调整到 16 的倍数;
- 也就是说通过malloc分配的内存的实际大小,也是真正消耗掉的内存大小,是:要分配的大小经过调整补充(32bytes,给调试器使用的)再加上cookie,最后调整为16的倍数;
- 图中cookie记录的值是实际内存大小(图中一整块的大小),本来是0x130,但是记录的却是0x131,结尾的 1 表示这块内存已经被占用了,一旦被sbh回收,就会变成 0x130;
- 从ioinit->_malloc_dbg->_nh_malloc_dbg->_heap_alloc_dbg->_heap_alloc_base->__sbh_alloc_block都是在计算内存的大小,还没真正进行内存分配,图中的那些值都还没设置;
2.6 __sbh_alloc_new_region函数:
- 此处真正进行内存分配;
- 1个HEADER负责管理1MB,通过管理中心进行管理;
- 通过LISTHEAD知道,每个GROUP一共有64条双向链表;
- 总结:1个HEADER将会申请真正的内存1MB,将来要分割出去的时候就从这块内存中进行分割;为了对这块内存切割后的内存块进行管理,又建立了REGION;REGION的大小是16k;
2.7 __sbh_alloc_new_group函数:
- 从HEADER指向的内存从中分割内存块;
- 32个Group逻辑上对应HEADER指向的内存(虚拟空间),将该内存切分为32个单元,每个单元就是32k;每个单元又细分为8个page,每个page的大小为4k(计算机中通常将4k称为1个page);sbh设置一些指针,将这些page串起来;
- 这8个page在内存中是连续的;
- 64条链表,管理的最大的区块是1k,那么每条链表负责的任务是什么呢? 类比于GNU编译器,每条链表负责的是8的倍数的内存大小,这里的最后一条链表负责 1kB,通过计算可得第一条链表负责 16B,第二条链表负责 32B,…;
- 当切割的内存块的大小大于1k的时候,就归最后一条链表管理,小于1k的时候就计算应该归哪条链表管理;
- 这就是从page中切割内存块的操作;
- 图中 0 x 130 0x1300x130 的就是切割出去的,红色的地址 007 d 0 f f 8 007d0ff8007d0ff8 是传出去的地址,但是这是在debug模式下,所以这个地址还会继续调整,扣除debug header,只将真正需要的内存地址传出去,这才是使用者真正拿到的地址,这个长度(100h)就是当初使用者申请的大小,这里的使用者就是当初的ioinit;
- 这个page还剩 e c 0 = f f 0 − 130 ec0 = ff0 - 130ec0=ff0−130,其中 f f 0 ff0ff0 就是4080;
- 切割只是cookie的调整;
- 展开的切割好的内存块中,前两个数据有错误,此处不是0了,而是对应的两个指针;第三个数据(0042 e e 08 0042ee080042ee08)指向发出内存申请的文件名ioinit.c;第4个数据(00000081 0000008100000081)是文件的哪一行发出的内存申请;第5个数据(0000100 00001000000100) 表示使用者真正需要的数据大小;第6个数据(00000002 0000000200000002)表示_CRT_BLOCK,表示这一块是给 CRT 用的;
- main执行结束后,可能还有区块,这并不一定是内存泄漏,因为这可能是CRT在使用,查看nBlockUse变量是否为_CRT_BLOCK,那么这就是合理的;
- 在main结束之前的一刻,发现有_NORMAL_BLOCK 的内存块,才说明存在内存泄漏;
- 像130h 这一个区块应该由第 304 / 16 - 1 = 18号链表供应;
3 SBH行为分析
分配
首次分配
需求:ioinit.c的 line#81 申请 100h,经过调整区块大小为130h;
sbh面对这样的内存申请,在初始化的时候已经有16个HEADER,现在第0个HEADER,先通过VirtualAlloc(0, 1Mb, MEM_RESERVE,...)分配1Mb的空间(从操作系统海量的内存中获得的空间);
0:表示don’t care,不在意从什么地方分配的空间;
1Mb:表示需要的大小;
MEM_RESERVE: 保留,保留这个地址空间,不需要真的有内存在这个地址;
另一个指针通过HeapAlloc 函数从_crtheap中获取到一块大小为sizeof(REGION)的内存空间;REGION中包含的东西在之前已经看过其结构体了,其中还包含了32个Group,每个Group包含64对指针;
从1Mb中通过VirtualAlloc(addr, 32Kb, MEM_COMMIT)真正地划分出32K的内存(此处的MEM_COMMIT表示真的给我,可以想象1Mb的空间里除了32K有内存,其它的都是空的、虚的,没有内存,只有号码),1Mb空间中划分出了32个32K,对应于32个Group;将32K切成更小的单元即8个page,放大了看就是上图中最下面的8个page,这8个page各有两个指针,通过指针将这些page串起来,最后串回到64个链表的最后一个(之所以串回到最后一个链表,是因为每个page的大小为4080,大于1k;64条链表分别管理的区块大小为16B、32B、48B、…,而最后一个链表管理所有1k以上的区块,而目前这些page都是1k以上的,所以全部都归第64条链表管理);
以上就是为了第一次分配准备的内存;
接下来开始切割,为了应付第一次的内存申请,8个page,从第一个page开始切,图中第二个大图就是page放大后的图,第一个图就是切割后给出去的130h大小的内存的具体内容,其中包含debug header以及无人区,而客户实际得到的地址是指向实际需要的大小100h的地址;在实际需要的内存大小100h的前后都有fdfdfdfd,当用户获得指向100h的地址后,会往下写,可能会写到后面的fdfdfdfd中,而在回收的时候,调试器会检测fdfdfdfd是否被修改,如果被修改了,就会发出警告⚠️,这就是无人区,有隐患存在,是绝对不可以被改的内容;
申请100h,调整后为130h,理应由Group0的#18 list供应,但是现在只有 #63 list链接着内存块,其他链表都是自己链接到自己(为空),当用户发出申请的时候,供应端会将自己的状况告诉用户端 ,REGION中的64bits变量,对应于64条链表,哪条链表有链接着区块,对应的bit就会被设置为1,否则为0;当前的情况只有最后一条链表挂着区块,所以只有最后一个bit是1,其他都是0;每一行bits变量表示一个Group,所以有32行bits变量;
第2次分配
某个申请x字节的内存,经过添加Debug header、cookie,以及调整为16的倍数后需要的内存大小为240h;通过计算得到应该由#35 list供应,接着就去检查Group0的64bit变量中的第35号对应的bit是0还是1,目前只有最后一个bit对应的值为1,其他都是0,也就是说应该供应的#35 list为空,只能退而求其次,找比较大的,目前只有最后一条链表,从之前的page1中剩余的内存中切割;
Group结构体中的cntEntries变量,当需要分配的时候+1,回收的时候-1;当值为0的时候,表示8个page可以全部收回来,还给操作系统;
图中Region区域的红色的0表示正在使用Group0;如果Group0的8个page都使用完后,就继续往下使用Group1,…;
第3次分配
申请的70h,在sbh先检查应该由第几号链表供应刚刚好,结果发现其对应的链表的bit是0,于是只好找最靠近的有区块链接的链表,找到了最后一个链表;
从最后一个链表中找到page1,从剩下的内存中划分70h;
第15次分配,释放
并不是每个应用程序都是在第15次,这里只是作为观察选取的一次;
14->13,释放,要先减一;
这次还的是第2次分配的 240h,调用free进行释放,归还到#35 list,挂到35号链表上;回收的方式就是将这块内存的cookie里的241修改成240,就表示进行了回收,相关的数字进行修改(可能会做);
修改64bit变量中对应的第 36 个bit(表示35号链表)的数字为 1;(00000000 10000001,其中每一位表示4bit);
需要分配b0h,应该由#10 list供应,但是检查bit位发现第11个bit值为0,就要往比较大的区块进行查找,#35 list有区块,所以应该由 #35 list供应,#35 list刚刚回收了240h的内存,所以从这块内存里切;
240h切出去b0h,还剩 190h,这个内存块变小了,就要进行移动,通过计算 190 h / 10 h − 1 = 24 190h/10h - 1= 24190h/10h−1=24,应该挂在 #24 list上,所以第 25 个bit应该从0 修改为 1;
这个过程就是第15次的时候刚刚回收了 240h 的内存,第16次分配的时候就要从刚刚回收的内存中进行切割,剩下的内存块(190h) 比较小,就进行移动,对应的bit也要进行调整;
第 n 次分配设计的是Group1的区块不足够,相对应的要划分一块32k的内存,将它划分为8个page,这是一个新的Group,之前的Group1中的32k的使用状态是 02000014 00000000,里面有3个链表挂了区块,有几块不知道;
第 n 次分配需要 230h的内存大小,之前 Group1上的链表挂的区块不能满足这个要求,于是新启动一个Group2(图中的数字变成了1),其他的操作都是一样的;
区块合并
如果回收的内存是相邻的,是不是应该合并呢?好的设计应该是要合并的。
图中空白的区块表示已经回收了的,阴影部分表示可以进行回收的区块。
目前图一中的待回收的内存块前后都是已经回收的300h大小的内存块,这两个内存块都落在#(300h/10h - 1) 这条链表上,要归还目前这个阴影内存块,就要去判断上面和下面是不是都是已经是回收的内存块,这就谈到为什么要有上下cookie。直观地想,cookie是记录整个内存块的大小,应该只需要一个就好了,为什么上下都有一个一模一样的cookie呢?
回收的步骤:
先将待回收的内存块的cookie中的 1 修改为 0;
图中弓箭所在的地方的指针往上4个字节,知道了长度为300h,从这个地址开始加上300h到达了下一块内存的起点,即cookie,能够去检查最后一个bit,发现是0,所以这两块内存可以合并,得到了如图二所示的样子;
因为上下都有cookie,所以从图一的弓箭处的位置往上4个字节,再往上4个字节,就到达了上一个内存块的cookie,知道了上一个内存块的大小,且知道了最后一个bit是0,于是可以继续往上调 300h到达了上一个内存块的上cookie位置,将它们进行合并,就得到了图三的样子;
sbh 系统计算 900h 应该链到哪条链表上;
所以,如果没有下cookie的设计,就无法管理上方区块的合并。
free(p)
首先要知道落在哪一个1Mb之中(一个Header对应一个1Mb的内存),在这1Mb中又要知道落在32段的哪一段之中,知道是哪一段就知道了对应于哪一个Group,然后才能去除以16再减一,确定链在哪个链表上。
指针p如何知道是哪个Header?最开始有 16 个Header,__sbh_pHeaderList指向这16个Header,每个Header的大小是固定的。回收的时候知道内存块的大小,通过p+内存块的大小,计算属于哪个Header,如果找不到,则说明当初不是从这里分配出去的,找到属于哪个Header后,将该指针减去这个1Mb的头指针再除以32k,计算得到位于1Mb的哪个段(如果从0算起,还要减1);
p 花落谁家?
Q:落在哪个 Header 内?
A:每个Header都有指针指向1Mb的内存块,且这个内存块的大小也知道了,于是通过计算头+内存块大小,可以知道 p 是落在哪个Header内了。
Q:落在哪个 Group 内?
A:p 减去 1Mb的头指针,除以32k,就知道落在第几段,也就落在哪个Group内。
Q:落在哪个 free-list 内?(被哪个 free-list 链住?)
A:指针往上看就是cookie,通过cookie知道了内存块的大小,然后除以10h再减去1,就知道落在哪个链表;
分段管理之妙
一段是32k,切成8大块。
如何判断全回收?
如果链表全部变成0就表示全部给出去了,那么如何判断全回收呢?Group中有cntEntries变量,只要这个值变成0,就表示全回收。
不要躁进!
全回收的时候回到了初始的状态(首次分配),8个page不能再进行合并,因为并不急着还给os,方便下一次的分配,等到下一次全回收才会归还给os。只有手上有两个全回收的时候才会归还给系统。
defer是延缓的意思,通过defer来完成不要躁进的目标。图中已经说明了Defer。
恢复到初始状态。图中的8个page是不会合并的。
5 VC6,Heap State Reporting Functions
- 调试模式下才有Debug Header,才可以去追踪,图中的函数就是可以利用的。
6 VC malloc + GCC allocator
- GCC的allocator的原理和VC 的 malloc 是相似的,allocator中有16条链表,管理的区块最高到128B,每次需要的时候向malloc要内存,allocator中的16条链表的设计不是为了速度快,因为malloc已经很快了,目的是为了去除cookie。
7 叠床架屋,有必要吗?
- 浪费,但是有必要。
- CRT(malloc/free) 是 C 的层次,是跨平台的,并不依附于哪个操作系统,所以它并不能预设下面的操作系统有没有做内存管理,同样的道理,C++ Library(std::allocator) 最终要调用到 CRT(malloc/free),它也不能去预设 malloc 有没有做内存管理,因为它是C++的标准库,不能依赖于底层C的东西。
- 每个层次都不敢去依赖下面,所以自己来做内存管理。