我们先看malloc,malloc是创建在堆上的,虽然malloc可以申请内存,但也有限制,windows下用VirtualAlloc可以直接向堆申请内存,Linux中则是brk,不过这两个效率一般。
malloc向堆申请函数,它本身是个内存池,只是这个内存池面向整个程序。空间配置器运行在malloc之上,是一个小的内存池,面向STL的容器。STL的容器可以使用空间配置来开辟空间,但空间配置器是20多年前的代码了,到现在也没有更新多少,核心功能,一点细节变一变就是了。空间配置器不能用于别的结构,只能用于STL容器。
为什么STL容器需要空间配置器?因为它需要频繁地申请和释放内存,如果只用malloc,效率不是很高,malloc是服务于所有程序的,并不是专用于STL容器的。频繁使用malloc,容易造成系统碎片,虽然空间配置器也没很好地解决这个问题;使用malloc时,会有额外空间来记录当前空间大小。
主要问题是效率问题
如果用户所需字节数大于128,就使用一级空间配置器,如果不是,就使用二级配置器。
在STL的源码中,一级配置器是__malloc_alloc_template,二级配置器是__default_alloc_template。
一级空间配置器中
allocate就是malloc的工作,deallocate就是free的工作,不过如果malloc失败,就会调用oom_malloc的函数,reallocate也是一样。这几个函数时public的,这些代码上面,是oom_malloc的声明,以及一个宏。
在代码下方
oom_malloc先去调用了一个函数指针__malloc_alloc_oom_handler,如果失败,就抛异常,是一个宏__THROW_BAD_ALLOC,如果成功,那就再去调用这个函数,也就是(*my_malloc_handler),最后返回结果,调用的这个函数里应当是做了释放空间操作,不过这个调用的函数默认为空。
所以这个一级空间配置器是malloc和free的封装,如果失败就抛异常,整体上和operator new的实现相似。
二级空间配置器
C++中提倡用枚举,const,内联替代宏,如果大于128,就去调用一级空间适配器了。如果不是,那就是二级,二级里主要内容是一个内存池和哈希桶,对应下图的start_free,end_free,heap_size,和free_list两个。
哈希桶是一个指针数组,__VOLATILE就是volatile。实际上,空间配置器所操作的内存都源于malloc,前面也看到,malloc失败就去找空间配置器,大于128就走一级,小于则来到二级。二级空间配置器里,有两个指针start和end指向一块空间的头尾,这块空间是提前准备好的,如果请求十字节内容,start就往后走,让出十字节空间,然后给到外面,走完了就重开一块空间。那么程序归还空间的时候,这些借出去的空间应当如何管理?每一块空间基本都是不一样的大小,我们可以把它们用链表连接起来,当再使用这些空间的时候,要如何找到合适大小的空间?所以如果这样管理就有些麻烦。源码中对归还的空间是利用哈希桶来管理的。
这里的办法和磁盘往外IO时的策略相似,向上对齐到8字节。如果申请了9到16个字节,那就去找1号桶。最开始的时候,所有的桶都没有字节数,start和end是空,如果申请了10字节,走二级空间配置器,然后找到了1号桶,如果发现1号桶是空0,就会去找内存池要,也就是start和end之间的内存池,进行malloc,malloc不会只获取16字节,会多给一点。申请10字节,因为在1号桶,所以申请了16字节,并且继续申请了字节,具体是多少个看代码实现。现在申请的是10字节,代码可能申请了30字节,把10字节返回后,就把剩余的空间挂在1号桶这里,并且有指针指向这个桶,那么下次再找1号桶时1号桶就有空间了,头删链表来把空间送出去。
释放内存时,需要传空间大小,大于128就走一级空间配置器,小于就走二级空间配置器,把这块空间给头插进对应的链表,这里找桶的位置就是通过空间大小来找的,使用前和使用后空间大小不变,所以就能找到正确的桶。等再次申请时,还是找这些已经开好的空间,去对应的桶拿空间就好,如果一个桶空间没有了,那就去找内存池分配一些空间。
每个桶都是8字节,128字节的话总共需要14个8字节,也就是数组是14个元素。每个桶是如何存空间的?源码中使用了联合体,联合体有一个指针,占4个字节,每个空间都会被强转成这个联合体类型,每次都只用去看前4个字节就可以,64位下指针大小是8字节,所以每个桶都是8字节。被连接起来的空间,前一个空间的4个字节存储下一个空间的前4个字节,用的时候就拿出去第一个空间,然后对应桶指向第二个空间,归还时又重新回到之前的连接。哈希桶结构可以很好地找到对应的空间。
假设代表48字节的桶没有空间,内存池也没有空间,malloc也失败了,那么这条路线上全部失败,应该抛异常了,但空间配置器没有这么做,而是去找后面几个位置是否有空间,有的话就切割出大于48字节的一部分空间,给48号桶。
每一个桶连接的空间,不同程序都可以使用同样的空间,因为既然是一个桶,那么使用的字节都够用。从桶中拿空间这个操作是在加锁后进行的,这个加锁通过类来实现,完成这些加锁操作,桶指向下一个空间后,就出了作用域,就析构了。这就是一个RAII。
一个进程只有一个空间配置器,所以空间配置器其实可以设计单例模式的,源码中虽然没有设计成单例模式,但基本上所有成员函数都是静态的,是间接的单例。
空间配置器能一定程度上解决内存碎片问题。内存在频繁申请释放后,一些小块内存夹杂在很多内存之间,导致越来越难以申请连续的大块内存。空间配置器是申请一大块内存后,自己拿来反复拿取,通过上面的操作,能解决一点内存碎片问题。这些是外碎片问题,还有内碎片问题。比如申请12个字节,按照空间配置器,我就得找1号桶,申请16字节,所以也是申请了多余的内存,这就是内碎片问题。
比如列表,除了传T模板参数,还会传一个alloc模板参数
默认传一个二级配置器,一级嵌套在二级里面,如果大于128,就走一级。
这个图的前两行就是专门针对list_node——allocator的配置器,get_node函数里针对link_type类型对象的其中一个data值进行了单独调用了一个函数,而析构也是调用了一个单独的destroy的函数
而上面的几个图中有个simple_alloc函数,也有专门的类
所以链表申请节点就是调用的create_node函数,里面再调用get_node函数,simple_alloc就是对二级空间配置器的封装,里面有二级的申请和释放。如果没有内存,那就申请一个节点大小的内存,按照类型去new一个空间。
看上面的源码就能逐渐明白原理。
结束。