C++内存管理
C++的内存分配
- malloc(), free() : C函数,不可重载
- new, delete: C++表达式,不可重载
- ::operator new(), ::operator delete(): C++函数(全局),可重载
- allocator<\T>::allocate(), allocator<\T>::deallocate(): C++标准库,可自由设计
直接使用标准库的内存分配器(一般不使用):
new与delete的实质
new表达式的解析:
delete表达式的解析:
所以new,delete底层是使用的operator new()和operator delete()。
内容丢失(关于定位new,array new)
重载operator函数
重载operator函数是进行内存管理的重要手段之一。new表达式是不可以重载的,但是它调用了operator new来间接调用malloc来分配内存,我们可以重载operator new来修改对象的内存分配行为。
重载operator new函数是可以自定义参数,但是第一个参数必须是一个size_t。当使用new表达式时,可以传入这些参数,一般第一个参数会自动计算类的大小。
同理operator delete也可以如此,但即使operator delete没有一一对应operator new,编译器也只会警告,不会报错。
内存管理的一个方法是减少malloc的调用次数(增加速度),并且减少cookie的内存占用(减少大小)。
一个示例(来自Effective C++):
查看operator delete函数,我们可以发现释放内存时并没有将内存还给操作系统,这会导致headOfFreeList的尺寸越来越大,这是这种重载方法的一点缺陷。
static allocator
针对上面的重载方式进行改进,我们不想为每一个需要内存管理的类都写一遍重载方法,可以将其抽取出来作为单独的类,这个类就叫做allocator。
有了这个allocator之后,如果有一个类要使用内存管理,可以直接集成这个类:
这种将allocator作为类的静态成员的方式称为static allocator。这个设计的精妙之处在于,allocator只要保存一个指针,他就可以将这个指针指向任何C++的对象,指针指向内存的大小取决于类的大小,即传入的size,调用完allocate后(在operator new中),会自动调用类的构造函数来在size大小的内存上创建一个对象。
在这里,就已经开始有内存分配器这个概念了。
macro for static allocator
针对static allocator进一步优化,我们发现,使用static allocator的代码基本是相同的,所以我们可以使用一个宏定义来简化代码。
这样的话,一个类中集成分配器就可以写成:
比原来的代码简化了很多。这种优化并不是技术上的优化,只是简化代码的编写。
new handler
当operator new无法分配内存时,会抛出异常 std::bad_alloc exception,但是在抛出异常之前,C++会执行一个函数。
一个new_handler示例:
这样,在operator new无法分配内存时,就会打印错误信息并中断程序。
default, delete
一般来说,只有类的构造函数,拷贝构造函数,赋值运算符,析构函数会使用这两个关键字(构造函数没有delete)。其实这两个关键字也可以用于operator new/new[]和operator delete/delete[],其中operator new和operator delete不能使用default,如果使用了delete,会导致new与delete表达式无法使用。
std::allocator
各种标准库的分配器
在C++中,一次分配内存时,会产生额外8字节的cookie和可能的32字节的debug信息(因不同的编译器而异)。对于一些占用内存小,使用次数多的类来说是一个比较大的浪费,所以需要使用内存管理来优化。
VC6标准库的分配器实现如下:
可以看出,VC6中的allocator并没有做任何内存管理,直接包装了一下operator new。
同样,BC5中的标准分配器也是如此。
G2.9的标准分配器实现如下:
看起来也是没有做任何内存管理,但是G2.9中提示不要使用这个标准分配器,它有一个自己的分配器,s这个分配器并未使用到任何一个容器。
而G2.9中使用的真正分配器叫做std::alloc,到了G4.9,又换成了__pool_alloc,它们的实现是相似的,都做了很好的内存管理。
G2.9的std::alloc
分配器中维护了16个链表,每个链表负责不同大小的内存分配,第一个是8字节,第二个是16字节,依次类推。当进行内存分配时,会自动将内存调整到8的倍数。
在上图中,如果需要分配32个字节,会由#3这个链表拿出32个字节的内存,如果没有,#3链表会使用malloc申请20 * 2 * 32 + RoundUp(累计申请量 >> 4)字节的内存,前20 * 32用于#3链表的使用,取余用于各个链表的备用(pool),下一次进行内存分配时,不论分配的内存大小是多少,都会先查看pool中的内存是否符合要求(可不可以切分成最多20*内存大小的链表并且至少满足此次的内存分配),不够再去使用malloc分配新的内存。同理释放内存时,会根据释放内存大小来把这块内存放到对应的链表上。
如果pool中的内存不满足一次内存分配的大小,就会去申请新的内存,此时会产生内存碎片。比如pool中还剩余80字节大小,现在要去分配108字节的内存,80字节不满足分配条件,std::alloc会去申请新的内存,这80个字节就成为了内存碎片,此时std::alloc会把这个内存碎片插入到对应的链表中,比如#9链表负责80字节的内存分配,这80字节就插入到#9链表后,以此来减少内存的分配。
当申请的内存大于16个链表所负责的内存内存大小时,比如分配256个字节时,即使#15负责的内存也没有这么大,此时就会使用malloc来分配内存。
每一个链表都使用了embedded pointers的设计方式和上面重载operator的示例相同。embedded pointers中有一个要求,分配内存的对象必须大于4个字节(32位系统),毕竟要借用这个对象的内存来储存一个指针。
系统内存消耗完毕时,std::alloc开始回收资源,它会将第一个大于此次内存申请的链表进行回收,把它的内存回填到pool中。比如内存已经消耗完了,现在在申请72字节的内存(负责72字节内存分配的链表已经没有节点了),std::alloc会先查看#7链表(负责80字节)中是否由空置的内存节点,如果有则从#7链表中回收80个字节(一个节点)到pool中,如果没有则继续寻找下一个链表。同时,将80字节回收到pool中后,进行72字节的内存分配,会留下8字节的内存碎片,这个内存碎片也会按照上面的流程回收。
源码
第一级分配器:
直接使用malloc申请内存。
第二级分配器
__default_alloc_template分析:
3个枚举定义:
ROUND_UP函数用于将bytes调整至8的倍数。
embedded pointer:
FREELIST_INDEX 计算bytes大小的内存对应几号链表。
free_list就是内存链表的数组,它包含了16个链表。
refill函数用于充值内存,向链表中加入内存节点。
chunk_alloc函数一次分配一大块内存。
其余的变量已经注释。
两个核心的函数 allocate 和 deallocate:
可以发现deallocate中并没有free内存,也就是说,释放内存时并没有把内存还给操作系统。
refill函数:
chunk_alloc函数:
std::alloc的定义:
std::alloc使用示例:
上面的示例中,直接push_back(Foo(1))时std::list会使用std::alloc分配内存,然后把Foo(1)copy到list的内存中,而使用new先在堆上创建内存后,再push_back到list中时,list同样使用std::alloc分配内存,然后把该对象不带cookie的部分copy到自己的内存中。
malloc和free
VC内存分配
上图为C++程序运行调用栈
_heap_alloc_base函数分配程序运行所需的内存,从函数中可以看到,当分配的内存小于1016字节时,使用的是__sbh_alloc_block来分配内存,否则使用HeapAlloc来分配内存。
而在VC10中,内存分配的函数为:
可以看到不论分配的内存大小为多少,都是用HeapAlloc来分配内存。
__heap_init()
观察上面的调用栈,这两个函数是程序运行的第一步,可理解为初始化,他的源码为:
申请了16个HEADER的内存。HEADER的定义:
ioinit()
初始化io相关内存,
在ioinit中,使用了_malloc_crt函数来分配内存,再dbg模式下,该函数的真正定义为_malloc_dbg。
它一共分配了32 * 8 = 256个字节的内存。
而在_malloc_dbg中,调用了_heap_alloc_dbg函数,其中有一段代码:
其中nSize为256字节(上面_malloc_crt的参数),nNoMansLandSize = 4, _CrtMemBlockHeader是debug模式下附加的内存,共32字节(cookie)。
其中涉及了两个指针 _pFirstBlock和_pLastBlock。
最后使用了memset函数填充了内存的值,0xFD, 0xDD…。调试器在不同的内存中填充不同的值,这样就可以追踪到该内存是否被使用。
上面使用的 _heap_alloc_base函数就是一开始介绍的内存分配函数(根据分配内存的大小选择使用__sbh_alloc_block 还是HeapAlloc)。
继续到 _sbh_alloc_block 函数,其中有一段:
用于将所分配的内存调整为16字节的倍数。
并且,其中调用了一个 _sbh_alloc_new_region()函数和 _sbh_alloc_new_group()函数。
首先看相关的数据定义:
Region管理内存分配细节,其中管理了32个Group,而一个Group中有64个LISTHEAD,可以看出LISTHEAD是双向链表头部,所以一个Group中有64个双向链表。
总体来说,进行分配内存的时候,除了分配出使用的虚拟地址空间(后续从这块内存中切割),还要分配出一个Region来管理那块内存,_sbh_alloc_new_region()就是用来分配出Region的。
每一个Group对应一块虚拟地址空间,虚拟地址空间大小为1024k(1M),所以一个Group管理32k内存。而一个Group中又分成了8个page,一个page对应4k,分配内存时就从这些page中取。
关于虚拟地址空间,其实操作系统并不会立即给它分配内存真正的内存,而是使用时才分配。
page示意:
上面的LISTHEAD有两个ENTRY指针,而ENTRY的定义在上图中,ENTRY就是双向链表的节点。
一个Group中有64个链表,每个链表同样负责不同大小的内存分配,以16字节递增。按照计算,最后一个链表应该负责1024字节(1K)大小的内存分配,但是最后一个链表比较特殊,它也负责1k以上大小的内存分配。
初始化完成后,由于每一个page都是4K大小,所以他们都由最后一个链表来管理。
内存分配示意:
上面是从一个page中为io分配0x130字节的内存分配示意图,从0xff0(4k-16)大小的内存中划出0x130字节,剩余0xec0大小。
当内存释放时,释放的内存会挂到对应的链表上,初始化时所有内存都是由最后一个链表(负责1K以上内存)来管理,此时释放内存,比如释放了0x240字节的内存,这块内存会挂到 0x240 / 0x10 = 0x24 = 36 =#35号链表上(链表编号从0开始),由于每个链表都是双向链表,实现起来比较简单。
当应用程序再次申请内存时,如果申请了比#35号链表所负责的内存更小的内存(35号链表负责 36 * 16 = 576字节的内存),那么会优先从#35号链表分配内存,这么做可以最大化地减少内存碎片,提升内存的使用率。然后如果有剩余的内存,再把它们挂到对应的链表上,就像上一次挂到#35号链表上一样,以此类推。
当不断申请内存,第一个Group剩余的内存不满足需求之后,就开始使用第二个Group的内存。
随着使用内存的次数越来越多,不可避免的会出现许多内存碎片,所以需要将相邻的内存碎片合并成一块,注意每一块内存有两个cookie,上下各一个,当释放一块内存时,会通过cookie判断它的上下两块内存是否在使用中,如果没有使用,它就会和未使用的内存合成一个大区快,然后挂到合适的链表下(这里会通过指针的地址计算它应该落到哪一个Header上,然后通过偏移计算应该落到哪一个Group里,最后才是根据内存的大小计算落到哪个链表)。
在这个内存分配的管理系统中,一个header管理1M内存,然后它又将这块内存切割成8个Group,每个Group管理32K,一个Group中又分了8个page,每个page负责4K,然后用了64个链表来管理这8个page。所以,判断内存是否可以全回收是很困难的,因为经过了很多次内存分配和回收之后,当前的内存可能挂在了很多个不同的链表里面,为了解决这个问题,在每一个header中都维护了一个cntEntries变量,分配内存时它就加一,释放内存时他就减一,如果他的计数是0,就说明这块内存可以全回收了。
在进行全回收时,并不是马上执行的,有一个__sbh_pHeaderDefer指针指向可以回收内存的header,但是它不会马上被回收,当有第二个可回收的header出现时它才会被回收,然后又把Defer指针指向这第二个header。当然,如果在它defer期间又发生了内存分配,就会从这个header中再次分配内存,Defer指针又会被置空。
Loki Allocator
Loki Allocator中重要的3个类,Chunk包含了一个pData指针和两个内存块管理的数据,FixedAllocator包含多个Chunk及这些Chunk的管理数据,SamllObjAllocator又包含了多个FixedAllocator及这些FixedAllocator的管理数据。3个类之间是一个层层包含的关系。
Chunk
初始化时,传入blockSize和blocks,先使用new分配一大块内存,然后使用reset初始化firstAvailableBlock_ 和blocksAvailable_ , firstAvailableBlock_ 表示第一块可用的block序号,blocksAvailable_ 表示可用的block数量,下面在每个block中借用了一个字节(unsigned char)来保存它的序号,注意序号是从1开始的,也就是说最多有256个区块。Release时释放它所申请的所有内存。
Allocate函数:
分配内存时冲pData_ 偏移到 firstAvailableBlock_ 处取出可用的区块,然后可用区块减一,注意后面吧pResult中的数字赋给了firstAvailableBlock_ ,这表示被分配出去的区块中其实保存的是可分配的下一个区块索引,但之前为每个区块确定序号时明明是顺序分配的,为什么现在又变了?
其实,最开始分配时,每个区块中保存的不是序号,而是指向下一区块的索引。最开始firstAvailableBlock_ 是0,第一次分配必然是从第0块开始的,而第0块保存的索引是1,刚好指向下一块可用的block,后面的以此类推。当进行内存释放时,由于释放的内存并不是按照分配的顺序来的,比如分配了5块内存(0, 1, 2, 3, 4),现在释放第3块内存,firstAvailableBlock_ 必然要等于3,以保证下一次分配内存从这里开始(注意这里从第0块开始计数),然后第3块内存中应该把之前保存的4改为5,因为第4块内存正在使用中,所以随着不断的内存分配和释放,block中的索引并不是顺序的,而是每一块内存中保存了下一块的索引,整个索引数据形成了一个特殊的链表。
理解了这一点之后,再看Deallocate函数:
释放内存时,把firstAvailableBlock_ 保存的下一个可用的block索引写进了toRelease中,然后firstAvailableBlock_ 回退到这次释放的block处,这里通过指针相对于pData_ 的偏移来计算。
FixedAllocator
Allocate函数:
在上面FixedAllocator的介绍中,有allocChunk_ 和deallocChunk_ 两个成员指针,其中allocChunk_ 指向上一个分配内存使用的Chunk,deallocChunk_ 指向上一次内存释放使用的Chunk,这样设计可以比较快速地找到合适的Chunk来进行内存的管理,因为在程序使用中,往往下一次使用的内存会和上一次使用的内存相邻,当然,没有这两个变量也不影响功能,只是在进行内存管理时要遍历一遍chunks_ 来寻找合适的Chunk,更加麻烦。
在进行内存分配时,如果FixedAllocator中的allocChunk_ 已使用完毕,就需要再找一个合适的Chunk来分配内存,遍历所有Chunk,如果还有未使用的Chunk,那就使用它,并使用allocChunk_ 将其记录下来,注意上面的代码中有一行
将i解引用再取地址,看起来很奇怪,这是因为i是一个迭代器,不是一个指针,不能将其直接赋给allocChunk_ (迭代器解引用获得它指向的元素)。
如果所有的Chunk都已使用完毕,那就再新建一个Chunk来使用,这里处理完新Chunk后,不但要设置allocChunk_ ,还要设置deallocChunk_ ,这是因为vector新增元素后可能导致内存发生变化,比如移动到另一个地方,指向原来内存的指针都会失效,需要重新设置。
Deallocate函数:
Deallocate函数分为两步,第一步,找到p位于哪个Chunk,并记录到deallocChunk_ ;第二步,释放内存。
VicinityFind函数:
寻找deallocChunk_ 时,会从上一个deallocChunk_ 开始,向chunks两端搜寻,也是考虑到这次释放的内存也许就在上一次释放的内存周围。
DoDeallocate函数:
这里只提取了核心部分的实现,调用Chunk的Deallocate函数释放内存后,还需要判断这个Chunk是不是可以全回收。这个全回收也使用了延时回收,当有两个可以全回收的Chunk时,才开始回收第一个。
每次进行全回收时都会把需要回收的Chunk放到chunks的最后,进行全回收判断时有3种情况:
- 当前可以回收的deallocChunk_ 就是最后一个Chunk:先判断前一个Chunk是否也可以全回收,如果可以,就把chunks的最后一个Chunk回收,并重置allocChunk_ 和deallocChunk_ 为第一个Chunk。
- 当前可以回收的deallocChunk_ 不是最后一个Chunk:如果最后一个Chunk可以回收,回收最后一个Chunk,将allocChunk_ 置为deallocChunk_ ,防止指针失效。
- 当前可以回收的deallocChunk_ 不是最后一个Chunk:如果最后一个Chunk还不能回收,交换当前Chunk和最后一个Chunk,将allocChunk_ 置为最后一个Chunk,其实也就是deallocChunk_ 。
这个实现看起来有逻辑漏洞,假设chunks中只有一个Chunk,明显属于第一种情况,但这时它是没有前一个Chunk的,所以不会触发到回收流程,Chunk就无法回收(chunks是否存在只有一个Chunk的情况?)。
按理来说,既然每一次都会先把可以回收的Chunk放到chunks最后,判断一个Chunk的回收时应该只用判断后两种情况,为什么还有第一种情况的判断呢?这是因为把可以回收的Chunk放到chunks最后只在内存释放操作中触发,如果申请内存时加入了新的Chunk,chunks的最后一个元素就又变为新Chunk了。
SamllObjAllocator
这是Loki Allocator中最上层的分配器,其中有多个FixedAllocator,主要是把不同大小的内存管理分配到每一个FixedAllocator上,比如第一个负责20K,第二个负责40K,和之前std::allocator的free_list机制相似,不再赘述。
GUN C++的Allocator
GUN C++的Allocator用于为容器进行内存管理,和上面的std::allocator一样,std::allocator不过为旧版本。
其中主要有一下几个分配器:
- __gun_cxx::new_allocator:直接使用operator new和operator delete。
- __gun_cxx::malloc_allocator:直接使用std::malloc和std::free。
- __gun_cxx::bitmap_allocator:使用了Cache机制,就是最常使用的一次申请一大块内存,然后慢慢切割使用的机制;使用bit_map追踪使用和未使用的内存块。
- __gun_cxx::pool_allocator:就是前面的std::allocator。
- __gun_cxx::mt_allocator:也使用了Cache机制,用于多线程环境下的内存管理。
- __gun_cxx::debug_allocator:本身不做内存管理,是一个wrapper,可以覆盖在任何allocator之上,在申请的内存上加入size信息,保证释放内存时与申请的内存大小相同。
- __gun_cxx::array_allocator:分配固定大小的内存块,使用这个allocator后,大小固定的容器就不需要使用operator new和operator delete了。
new_allocator 和 malloc_allocator 过于简单,这里不再记录。
array_allocator
使用array_allocator时需要传入一格std::array类型的参数,这个std::array参数的地址会用于初始化_M_array指针, _M_used用来记录已使用的内存块个数,再看它的allocate函数:
传入需要分配内存的元素个数__n, __ret使用 _M_array头部加上 _M_used来获得可以分配的内存块,这里每个内存块的大小与每个元素的大小是保持一致的,然后更新 _M_used。非常简单易懂,就是预先申请很多个元素的内存,然后需要时一个一个拿出。
array_allocator是没有deallocate函数的(有这个函数声明但什么也不干),也就是说内存只能取,不能回收。
array_allocator的使用示例:
这里声明并初始化了一个array_allocator类型的对象myalloc,它包含了65536 * sizeof(int)大小的内存,然后我们需要保存int类型的数据时,就可以从它那里申请内存。
debug_allocator
debug_allocator初始化时必须传入一个其他分配器,extea计算bytes想当于几个元素,比如extea(int)计算4个字节相当于几个元素。
内存的分配与释放:
分配内存时需要增加_M_extra个元素大小的内存以记录这次分配的元素数量,并且在释放时检查。
bitmap_allocator
可以从这两个函数看出,如果内存分配和释放超过了一个元素,就直接使用了::operatoe new和::operatoe delete,只有申请一个元素时,才会使用它的内存管理策略。回想起来,容器的很多操作都是针对一个元素的,比如插入,删除,这时候bitmap_allocator就起作用了。
bitmap_allocator分配内存时,会分配2的n次方的block,比如64个,然后用一个bitmap记录这些block的使用情况,比如64个block,需要64个bit来记录(使用2个unsigned int),如果block使用了,bit就置1。前面还会使用一个use count(一个unsigned int)来记录已经使用了多少个block。
super block size用来记录总共使用了多少字节,包括bitmap和use count,eg:
peer block size = 8 bytes
super block size = 4 + 2 * 4 + 64 * 8 = 524 bytes
_ _ min_vector_ _用来保存一个super block,它的每一个元素包含了这几个成员:
_M_start指向第一个block;
_M_finsh指向最后一个block;
bitmap_allocator中block是成倍增长的,第一个super block使用完后,再使用第二个super block时,会申请出2倍的block,同理第三个super block会申请4倍的block。显而易见,如果一直不进行内存的全回收,bitmap_allocator使用的内存会越来越大,指数级增长。
super block之间是不会出现混用情况的,分配内存时一定是向同一个super block申请。
当一个super block的内存释放完毕时,它就可以进行全回收,将所有内存都还给操作系统,但是并不是马上归还,可以全回收的super block会被记录到另一个mini_vector,并且从原来的mini_vector删除,当这个free mini_vector中记录了共64个super block时,再有一个可以全回收的super block进来,才会把这65个super block中最大的那个还给操作系统。一旦有一个super block被标记可以全回收,申请新的super block时block就会减半,比如现在申请一个新的super block已经有128个block了,然后有一个super block被标记全回收,那么下一次申请新的super block只有64个block。
已经被记录可以回收的super block会尽量不被使用,比如说一个super block可以全回收了,再需要分配内存时,会优先从其他的super block中取,不会重新使用这个super block,除非其他的super block也使用完了,才会又把这个super block拿出来使用。