内存管理主要包含两个层面的内容:
- 1、操作系统内核相关的内存管理:物理内存层
- 2、库函数层:主要是堆内存,即malloc实现层
如果用户还有需要会在用户层再做一次内存管理机制,例如SGI STL中的内存管理机制(二级配置器)。
由于篇幅有限,本文主要介绍库函数层的malloc实现机制。同时上述两层中由于操作系统等不同也存在差异,例如malloc层,Windows下VC++6.0的malloc实现和Linux下用的ptmalloc实现也存在差异,但是所使用的trick是类似的。通常有:1、采用自由链表进行维护不同块的分配与释放;2、采用内存池的方式,减少对操作系统内核的调用。因此,本文针对ptmalloc2的机制进行分析。
ptmalloc2是支持多线程分配内存的算法,因此将主线程分配区与子线程分配区分开,主线程分配区可通过系统调用brk()和内存映射系统调用mmap()来分配,而子线程分配区只能通过内存映射mmap()的方式获得内存。
- ps:ptmalloc为什么要增加非主分配区?答:如果没有非主分配区,所有的线程在主分配区上操作,互相竞争锁的过程十分影响分配效率。ptmalloc中增加了非主分配区支持,主分配区和非主分配区用环形链表进行管理,提高malloc的分配效率。 申请小块内存时会产生很多内存碎片,ptmalloc在整理时也需要对分配区做加锁操作。每个加锁操作大概需要5~10个cpu指令,而且程序线程很多的情况下,锁等待的时间就会延长,导致malloc性能下降。一次加锁操作需要消耗100ns左右,正是锁的缘故,导致ptmalloc在多线程竞争情况下性能远远落后于tcmalloc。最新版的ptmalloc对锁进行了优化,加入了PER_THREAD和ATOMIC_FASTBINS优化,但默认编译不会启用该优化,这两个对锁的优化应该能够提升多线程内存的分配的效率。
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)。
用户释放掉的内存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的容器内。
用户释放的较小(不大于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中。
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的一个缓冲区,增加它只是为了加快分配的速度。
对于非主分配区会预先从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的大小。
当需要分配的chunk足够大,而且fast bins和bins都不能满足要求,甚至top chunk本身也不能满足分配需求时,ptmalloc会使用mmap来直接使用内存映射来将页映射到进程空间。这样分配的chunk在被free时将直接解除映射,于是就将内存归还给了操作系统,再次对这样的内存区的引用将导致segmentation fault错误。这样的chunk也不会包含在任何bin中。
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。
为了避免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的效率降低。
- Ptmalloc不擅长管理长生命周期的内存块,ptmalloc设计的假设中就明确假设缓存的内存块都用于短生命周期的内存分配,因为ptmalloc的内存收缩是从top chunk开始,如果与top chunk相邻的那个chunk在我们NoSql的内存池中没有释放,top chunk以下的空闲内存都无法返回给系统,即使这些空闲内存有几十个G也不行。
- Glibc内存暴增的问题我们定位为全局内存池中的内存块长时间没有释放,其中还有一个原因就是全局内存池会不定期的分配内存,可能下次分配的内存是在top chunk分配的,分配以后又短时间不释放,导致top chunk升到了一个更高的虚拟地址空间,从而使ptmalloc中缓存的内存块更多,但无法返回给操作系统。
- 另一个原因就是进程的线程数越多,在高压力高并发环境下,频繁分配和释放内存,由于分配内存时锁争用更激烈,ptmalloc会为进程创建更多的分配区,由于我们的全局内存池的长时间不释放内存的缘故,会导致ptmalloc缓存的chunk数量增长得更快,从而更容易重现Glibc内存暴增的问题。在我们的ms上这个问题最为突出,就是这个原因。