malloc内存管理总结

内存管理

内存管理主要包含两个层面的内容:

  • 1、操作系统内核相关的内存管理:物理内存层
  • 2、库函数层:主要是堆内存,即malloc实现层

如果用户还有需要会在用户层再做一次内存管理机制,例如SGI STL中的内存管理机制(二级配置器)。

由于篇幅有限,本文主要介绍库函数层的malloc实现机制。同时上述两层中由于操作系统等不同也存在差异,例如malloc层,Windows下VC++6.0的malloc实现和Linux下用的ptmalloc实现也存在差异,但是所使用的trick是类似的。通常有:1、采用自由链表进行维护不同块的分配与释放;2、采用内存池的方式,减少对操作系统内核的调用。因此,本文针对ptmalloc2的机制进行分析。

ptmalloc2是支持多线程分配内存的算法,因此将主线程分配区与子线程分配区分开,主线程分配区可通过系统调用brk()和内存映射系统调用mmap()来分配,而子线程分配区只能通过内存映射mmap()的方式获得内存。

相关数据结构描述:

1. Main_arena与non_main_arena:主分配区和非主分配区

  • ps:ptmalloc为什么要增加非主分配区?答:如果没有非主分配区,所有的线程在主分配区上操作,互相竞争锁的过程十分影响分配效率。ptmalloc中增加了非主分配区支持,主分配区和非主分配区用环形链表进行管理,提高malloc的分配效率。 申请小块内存时会产生很多内存碎片,ptmalloc在整理时也需要对分配区做加锁操作。每个加锁操作大概需要5~10个cpu指令,而且程序线程很多的情况下,锁等待的时间就会延长,导致malloc性能下降。一次加锁操作需要消耗100ns左右,正是锁的缘故,导致ptmalloc在多线程竞争情况下性能远远落后于tcmalloc。最新版的ptmalloc对锁进行了优化,加入了PER_THREAD和ATOMIC_FASTBINS优化,但默认编译不会启用该优化,这两个对锁的优化应该能够提升多线程内存的分配的效率。

2.chunk

chunk是用户所需的内存块,ptmalloc为了便于管理在其前后加了一些控制信息,用来记录分配的信息,以便完成分配和释放工作。已被分配使用的chunk如图所示:

chunk指针指向一个chunk的开始,mem指针是真正返回给用户使用的指针。size of previous chunk和size of chunk(类似于VC++6.0中的上下cookie)用于内存释放时回收方便,从上往下回收时可以知道下一个块的起始地址。chunk的第二个域的最低一位为P,它表示前一个块是否正在使用,P为0则表示前一个chunk为空闲,这时chunk的第一个域pre_size才有效,pre_size表示前一个chunk的size,程序可以使用这个值来找到前一个chunk的开始地址。Chunk的第二个域的倒数第二个位为M,他表示当前chunk是从哪个内存区域获得的虚拟内存。M为1表示该chunk是从mmap映射区域分配的,否则是从heap区域分配的。Chunk的第二个域倒数第三个位为A,表示该chunk属于主分配区或者非主分配区,如果属于非主分配区,将该位置为1,否则置为0。
空闲chunk在内存中的结构如图所示:

当chunk空闲时,其M状态不存在,只有AP状态,原本是用户数据区的地方存储了四个指针,指针fd指向后一个空闲的chunk,而bk指向前一个空闲的chunk,ptmalloc通过这两个指针将大小相近的chunk连成一个双向链表。对于large bin中的空闲chunk,还有两个指针,fd_nextsize和bk_nextsize,这两个指针用于加快在large bin中查找最近匹配的空闲chunk。不同的chunk链表又是通过bins或者fastbins来组织的(bins和fastbins在后面介绍)。

  • chunk空间复用:是指一个chunk块或者正在被使用或者已经被释放掉,所以chunk中的一些域可以在使用状态和空闲状态表示不同的意义,来达到空间复用的效果。空闲时,一个chunk至少需要4个size_t(4B)大小的空间,用来存储prev-size,size,fd,bk,也就是16B。当一个chunk处于使用状态时,其大小计算公式:in-use-size=(用户请求大小+8-4)align to 8B,其中+8是用来存储prev-size和size,但又因为,该块处于使用状态,它的下一个chunk的pre-size域肯定是无效的,所以被借用过来作为该chunk的空间存储,所以-4。即最终的分配空间chunk-size=max(in-use-size, 16)。

3.bins

用户释放掉的内存chunk并不都是马上归还给系统,ptmalloc会统一管理heap和mmap映射区域中的空闲chunk,当用户进行下一次分配请求时,ptmalloc会首先试图在空闲的chunk中挑选一块给用户,这样就避免了频繁的系统调用,降低内存分配的开销。ptmalloc将相似大小的chunk用双向链表链接起来,这样的链表被称为一个bin。ptmalloc维护了128个bin,并使用一个数组来存储这些bin,如图。

数组中的第一个为unsorted bin,数组中从2开始编号的前64个bin称为small bins,同一个small bin中的chunk具有相同的大小。两个相邻的small bin中的chunk大小相差8bytes。small bins中的chunk按照最近使用顺序进行排列,最后释放的chunk被链接到链表的头部,而申请chunk是从链表尾部开始,这样,每一个chunk 都有相同的机会被ptmalloc选中。Small bins后面的bin被称作large bins。large bins中的每一个bin分别包含了一个给定范围内的chunk,其中的chunk按大小序排列。相同大小的chunk同样按照最近使用顺序排列。ptmalloc使用“smallest-first,best-fit”原则在空闲large bins中查找合适的chunk。
当空闲的chunk被链接到bin中的时候,ptmalloc会把表示该chunk是否处于使用中的标志P设为0(注意,这个标志实际上处在下一个chunk中),同时ptmalloc还会检查它前后的chunk是否也是空闲的,如果是的话,ptmalloc会首先把它们合并为一个大的chunk,然后将合并后的chunk放到unstored bin中。要注意的是,并不是所有的chunk被释放后就立即被放到bin中。ptmalloc为了提高分配的速度,会把一些小的的chunk先放到一个叫做fast bins的容器内。

4.Fast Bins

用户释放的较小(不大于max-fast设定值,默认为64B)的内存空间均被放到fast-bins中,起到一个缓冲的作用。用户释放较小内存块后,首先被放到fast-bins中,并不改变它的使用标志P,这样就无法将它们合并,当用户再次申请小于等于max-fast值时,ptmalloc首先会在fast-bin中查找相应空闲块,找不到再去找bins(small-bins、unsorted-bins、large-bins)中的空闲块。在某个特定的时候,ptmalloc会遍历fast bins中的chunk,将相邻的空闲chunk进行合并,并将合并后的chunk加入unsorted bin中,然后再将unsorted bin里的chunk加入bins中。

5.unsorted Bin

unsorted bin的队列使用bins数组的第一个,如果被用户释放的chunk大于max_fast,或者fast bins中的空闲chunk合并后,这些chunk首先会被放到unsorted bin队列中,在进行malloc操作的时候,如果在fast bins中没有找到合适的chunk,则ptmalloc会先在unsorted bin中查找合适的空闲chunk,然后才查找bins。如果unsorted bin不能满足分配要求。malloc便会将unsorted bin中的chunk加入bins中。然后再从bins中继续进行查找和分配过程。从这个过程可以看出来,unsorted bin可以看做是bins的一个缓冲区,增加它只是为了加快分配的速度。

6.Top chunk

对于非主分配区会预先从mmap区域分配一块较大的空闲内存模拟sub-heap,通过管理sub-heap来响应用户的需求,因为内存是按地址从低向高进行分配的,在空闲内存的最高处,必然存在着一块空闲chunk,叫做top chunk。当bins和fast bins都不能满足分配需要的时候,ptmalloc会设法在top chunk中分出一块内存给用户,如果top chunk本身不够大,分配程序会重新分配一个sub-heap,并将top chunk迁移到新的sub-heap上,新的sub-heap与已有的sub-heap用单向链表连接起来,然后在新的top chunk上分配所需的内存以满足分配的需要,实际上,top chunk在分配时总是在fast bins和bins之后被考虑,所以,不论top chunk有多大,它都不会被放到fast bins或者是bins中。Top chunk的大小是随着分配和回收不停变换的,如果从top chunk分配内存会导致top chunk减小,如果回收的chunk恰好与top chunk相邻,那么这两个chunk就会合并成新的top chunk,从而使top chunk变大。如果在free时回收的内存大于某个阈值,并且top chunk的大小也超过了收缩阈值,ptmalloc会收缩sub-heap,如果top-chunk包含了整个sub-heap,ptmalloc会调用munmap把整个sub-heap的内存返回给操作系统。
由于主分配区是唯一能够映射进程heap区域的分配区,它可以通过sbrk()来增大或是收缩进程heap的大小,ptmalloc在开始时会预先分配一块较大的空闲内存(也就是所谓的 heap),主分配区的top chunk在第一次调用malloc时会分配一块(chunk_size + 128KB) align 4KB大小的空间作为初始的heap,用户从top chunk分配内存时,可以直接取出一块内存给用户。在回收内存时,回收的内存恰好与top chunk相邻则合并成新的top chunk,当该次回收的空闲内存大小达到某个阈值,并且top chunk的大小也超过了收缩阈值,会执行内存收缩,减小top chunk的大小,但至少要保留一个页大小的空闲内存,从而把内存归还给操作系统。如果向主分配区的top chunk申请内存,而top chunk中没有空闲内存,ptmalloc会调用sbrk()将的进程heap的边界brk上移,然后修改top chunk的大小。

7.mmaped chunk

当需要分配的chunk足够大,而且fast bins和bins都不能满足要求,甚至top chunk本身也不能满足分配需求时,ptmalloc会使用mmap来直接使用内存映射来将页映射到进程空间。这样分配的chunk在被free时将直接解除映射,于是就将内存归还给了操作系统,再次对这样的内存区的引用将导致segmentation fault错误。这样的chunk也不会包含在任何bin中。

8.Last remainder

Last remainder是另外一种特殊的chunk,就像top chunk和mmaped chunk一样,不会在任何bins中找到这种chunk。当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chuk。

考虑线程安全:

每次分配都需要获得分配区(arena)的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区的锁。线程先查看线程私有对象中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有分配区均已加锁,则判断分配区的个数是否到达系统上限(2*CPU核心数+1),若未达到上限,则新建一个分配区,并把该分配区加入到全局分配区循环链表和线程的私有对象中并加锁,然后使用该分配区进行分配操作。新建的分配区一定是非主分配区,因为主分配区是从父进程那里继承的。新建非主分配区时会调用mmap()创建一个sub-heap,并设置好top-chunk。

分配过程:

  1. ptmalloc在开始时,若请求的空间小于mmap分配阈值(mmap threshold,默认值为128KB)时,主分配区会调用sbrk()增加一块大小为 (128 KB + chunk-size) align 4KB的空间作为heap,若大于mmap分配阈值,则ptmalloc直接使用mmap()映射一块大小为chunk的内存作为heap。非主分配区会调用mmap映射一块大小为HEAP-MAX-SIZE(32位系统上默认为1MB,64位系统上默认为64MB)的空间作为sub-heap。当用户请求内存分配时,首先会在这个区域找一块合适的chunk给用户。当用户释放heap中的chunk时,ptmalloc又会使用fast bins和bins来组织空闲chunk。
  2. 若brk!=brk-start,若用户申请内存,先判断所需分配chunk的大小是否满足chunk-size<=max-fast(max-fast默认为64B),如果是的话则转到下一步。
  3. 首先尝试在fast bins中取一个所需大小的chunk分配给用户。如果可以找到,则分配结束。否则转到下一步。
  4. 判断所需大小是否在small bins中,即判断chunk-size < 512B是否成立。如果chunk大小处在small bins中,则转下一步,否则转6步
  5. 根据所需分配的chunk的大小,找到具体所在的某个small bin,从该bin的尾部摘取一个恰好满足大小的chunk。若成功,则分配结束,否则,转到下一步。
  6. 到了这一步,说明需要分配的内存较大。ptmalloc首先遍历fast bins中的chunk,并将相邻的chunk进行合并,并链接到unsorted bin中,然后遍历unsorted bin中的chunk,如果unsorted bin只有一个chunk,并且这个chunk在上次分配过程中被使用过,并且所需分配的chunk大小属于small bins,并且chunk的大小大于等于需要分配的大小,这种情况下就直接将该chunk进行切割,分配结束,否则将根据chunk的空间大小将其放入small bins或是large bins中,遍历完成后,转入下一步
  7. 到了这一步,说明需要分配的内存较大。或者small bins和unsorted bins中都找不到合适的chunk,并且fast bins和unsorted bins中所有的chunk都清除干净了。从large bins中按照“smallest-first,best-fit”原则,找一个合适的chunk,从中划分一块所需大小的chunk,并将剩下的部分链接回到bins中。若操作成功,则分配结束,否则转到下一步。
  8. 如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。否则转到下一步。
  9. 到了这一步,说明top chunk也不能满足分配要求,所以,于是就有了两个选择: 如果是主分配区,调用sbrk(),增加top chunk大小;如果是非主分配区,调用mmap来分配一个新的sub-heap,增加top chunk大小;或者使用mmap()来直接分配。在这里,需要依靠chunk的大小来决定到底使用哪种方法。判断所需分配的chunk大小是否大于等于 mmap分配阈值,如果是的话,则转下一步,调用mmap分配,否则跳到第11步,增加top chunk 的大小。
  10. 使用mmap系统调用为程序的内存空间映射一块chunk_size align 4kB大小的空间。 然后将内存指针返回给用户。
  11. 判断是否为第一次调用malloc,若是主分配区,则需要进行一次初始化工作,分配一块大小为(chunk_size + 128KB) align 4KB大小的空间作为初始的heap。若已经初始化过了,主分配区则调用sbrk()增加heap空间,非主分配区则在top chunk中切割出一个chunk,使之满足分配需求,并将内存指针返回给用户。

释放过程

  1. 判断传入的指针是否为0,如果为0,则什么都不做,直接return。否则转下一步。
  2. 判断所需释放的chunk是否为mmaped chunk,如果是,则调用munmap()释放mmaped chunk,解除内存空间映射,该该空间不再有效,访问该区域会报错。如果开启了mmap分配阈值的动态调整机制,并且当前回收的chunk大小大于mmap分配阈值,将mmap分配阈值设置为该chunk的大小,将mmap收缩阈值设定为mmap分配阈值的2倍(??没看懂为什么),释放完成,否则跳到下一步。
  3. 判断chunk的大小和所处的位置,若chunk_size <= max_fast,并且chunk并不位于heap的顶部,也就是说并不与top chunk相邻,则转到下一步,否则跳到第5步。(因为与top chunk相邻的小chunk也和 top chunk进行合并,所以这里不仅需要判断大小,还需要判断相邻情况)
  4. 将chunk放到fast bins中,chunk放入到fast bins中时,并不修改该chunk使用状态位P。也不与相邻的chunk进行合并。只是放进去,如此而已。这一步做完之后释放便结束了,程序从free()函数中返回。
  5. 判断前一个chunk是否处在使用中,如果前一个块也是空闲块,则合并。并转下一步。
  6. 判断当前释放chunk的下一个块是否为top chunk,如果是,则转第8步,否则转下一步。
  7. 判断下一个chunk是否处在使用中,如果下一个chunk也是空闲的,则合并,并将合并后的chunk放到unsorted bin中。注意,这里在合并的过程中,要更新chunk的大小,以反映合并后的chunk的大小。并转到第9步。
  8. 如果执行到这一步,说明释放了一个与top chunk相邻的chunk。则无论它有多大,都将它与top chunk合并,并更新top chunk的大小等信息。转下一步。
  9. 判断合并后的chunk 的大小是否大于FASTBIN-CONSOLIDATION-THRESHOLD(默认64KB),如果是的话,则会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中。fast bins将变为空,操作完成之后转下一步。
  10. 判断top chunk的大小是否大于mmap**收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。但是最先分配的128KB空间是不会归还的,ptmalloc 会一直管理这部分内存,用于响应用户的分配请求;如果为非主分配区,会进行sub-heap收缩,将top chunk的一部分返回给操作系统,如果top chunk为整个sub-heap,会把整个sub-heap还回给操作系统。做完这一步之后,释放结束,从 free() 函数退出。可以看出,收缩堆的条件是当前free的chunk大小加上前后能合并chunk的大小大于64k,并且要top chunk的大小要达到mmap收缩阈值,才有可能收缩堆。,ptmalloc 会一直管理这部分内存,用于响应用户的分配请求;如果为非主分配区,会进行sub-heap收缩,将top chunk的一部分返回给操作系统,如果top chunk为整个sub-heap,会把整个sub-heap还回给操作系统。**做完这一步之后,释放结束,从 free() 函数退出。可以看出,收缩堆的条件是当前free的chunk大小加上前后能合并chunk的大小大于64k,并且要top chunk的大小要达到mmap收缩阈值,才有可能收缩堆。

使用注意事项:避免内存暴增问题

为了避免Glibc内存暴增,使用时注意以下几点:

  • 后分配的内存先释放(堆特性),防止内存泄漏,因为ptmalloc收缩内存是从top chunk开始,如果与top-chunk相邻的chunk不能释放,top-chunk以下的chunk都无法释放,针对heap而言。因为mmap分配的内存都可以单独释放。图中chunk A、chunk D之间的chunk虽然释放了,但是top chunk并不能收缩该chunk;等chunk D释放后,才能被top-chunk释放。
  • Ptmalloc不适合用于管理长生命周期的内存,特别是持续不定期分配和释放长生命周期的内存,这将导致ptmalloc内存暴增。长时间不释放的内存将占据top-chunk导致无法回收给操作系统。所以通过长生命周期的内存经常大于1MB进行申请,这样可以保证是通过mmap来进行分配,回收时可直接回收,而不用等待其他chunk的回收。
  • 尽量使用mmap分配阈值动态调整机制(ps:何时调整动态阈值?:在释放mmap区域的内存时,并且当前释放的chunk大小大于mmap分配阈值,则将mmap分配阈值设置为该chunk的大小,将mmap收缩阈值设定为mmap分配阈值的2倍。默认情况下mmap分配阈值与mmap收缩阈值相等,都为128KB。在64位系统上,mmap分配阈值最大值为32MB,所以收缩阈值的最大值为64MB,在32位系统上,mmap分配阈值最大值为512KB,所以收缩阈值的最大值为1MB。收缩阈值可以通过函数mallopt()进行设置。),这样可以保证短生命周期的内存分配尽量从ptmalloc缓存的内存chunk中分配,更高效,浪费更少的物理内存。如果关闭了该机制,对大于128KB的内存分配就会使用系统调用mmap向操作系统分配内存,使用系统调用分配内存一般会比从ptmalloc缓存的chunk中分配内存慢,特别是在多线程同时分配大内存块时,操作系统会串行调用mmap(),并为发生缺页异常的页加载新物理页时,默认强制清0。频繁使用mmap向操作系统分配内存是相当低效的。使用mmap分配的内存只适合长生命周期的大内存块。
  • 多线程分阶段执行的程序不适合用ptmalloc,这种程序的内存更适合用内存池管理,就像Appach那样,每个连接请求处理分为多个阶段,每个阶段都有自己的内存池,每个阶段完成后,将相关的内存就返回给相关的内存池。Google的许多应用也是分阶段执行的,他们在使用ptmalloc也遇到了内存暴增的相关问题,于是他们实现了TCMalloc来代替ptmalloc,TCMalloc具有内存池的优点,又有垃圾回收的机制,并最大限度优化了锁的争用,并且空间利用率也高于ptmalloc。Ptmalloc假设了线程A释放的内存块能在线程B中得到重用,但B不一定会分配和A线程同样大小的内存块,于是就需要不断地做切割和合并,可能导致内存碎片。
  • 尽量减少程序的线程数量和避免频繁分配/释放内存,Ptmalloc在多线程竞争激烈的情况下,首先查看线程私有变量是否存在分配区,如果存在则尝试加锁,如果加锁不成功会尝试其它分配区,如果所有的分配区的锁都被占用着,就会增加一个非主分配区供当前线程使用。由于在多个线程的私有变量中可能会保存同一个分配区,所以当线程较多时,加锁的代价就会上升,ptmalloc分配和回收内存都要对分配区加锁,从而导致了多线程竞争环境下ptmalloc的效率降低。

Glibc内存暴增的原因:

  • Ptmalloc不擅长管理长生命周期的内存块,ptmalloc设计的假设中就明确假设缓存的内存块都用于短生命周期的内存分配,因为ptmalloc的内存收缩是从top chunk开始,如果与top chunk相邻的那个chunk在我们NoSql的内存池中没有释放,top chunk以下的空闲内存都无法返回给系统,即使这些空闲内存有几十个G也不行。
  • Glibc内存暴增的问题我们定位为全局内存池中的内存块长时间没有释放,其中还有一个原因就是全局内存池会不定期的分配内存,可能下次分配的内存是在top chunk分配的,分配以后又短时间不释放,导致top chunk升到了一个更高的虚拟地址空间,从而使ptmalloc中缓存的内存块更多,但无法返回给操作系统。
  • 另一个原因就是进程的线程数越多,在高压力高并发环境下,频繁分配和释放内存,由于分配内存时锁争用更激烈,ptmalloc会为进程创建更多的分配区,由于我们的全局内存池的长时间不释放内存的缘故,会导致ptmalloc缓存的chunk数量增长得更快,从而更容易重现Glibc内存暴增的问题。在我们的ms上这个问题最为突出,就是这个原因。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章