天天看点

Linux glibc内存管理:用户态内存分配器——ptmalloc实现原理ptmalloc设计假设ArenaChunkBins内存分配、释放流程总结

文章目录

  • ptmalloc
  • 设计假设
  • Arena
  • Chunk
  • Bins
  • 内存分配、释放流程
  • 总结

C++ STL : SGI-STL空间配置器源码剖析

Linux 内存管理 | 物理内存管理:物理内存、内存碎片、伙伴系统、slab分配器

Linux 内存管理 | 虚拟内存管理:虚拟内存空间、虚拟内存分配

在之前的几篇博客中,我曾经介绍过STL空间配置器、BuddySystem、Slab分配器等内存管理机制,也曾经简单的提及过linux用户态内存分配的策略,这一次就来深入讲一讲在用户态进行内存分配时,到底做了什么。

ptmalloc

在Linux中,当我们申请的内存小于

128K

时,

malloc

会使用

sbrk

或者

brk

在堆区分配内存。而当我们申请大于128K的大块空间时,会使用

mmap

在映射区进行分配。

但是由于上述的

brk/sbrk/mmap

都属于系统调用,因此当我们每次调用它们时,就会从用户态切换至内核态,在内核态完成内存分配后再返回用户态。

倘若每次申请内存都要因为系统调用而产生大量的CPU开销,那么性能会大打折扣。并且从上面的图我们也可以看出来,堆是从低地址往高地址增长,如果低地址的内存没有被释放,则高地址的内存就不能被回收,就会产生内存碎片的问题。

那么Linux中的malloc是如何实现解决这个问题的呢?

这就要提到大名鼎鼎的

ptmalloc

了,

ptmalloc

是glibc中默认的内存管理器。其底层采取了分箱式内存管理机制,也就是实现了一个类似哈希桶结构的内存池,当我们通过

malloc

free

申请和释放内存的时候,

ptmalloc

就会代替我们将这些内存进行管理,通过一系列的内存合并、申请策略,来让用户申请和释放内存的时候更加的高效且安全。

下面就来详细的介绍一下

ptmalloc

的工作原理

设计假设

ptmalloc

在设计时集合了高效率、高空间利用率、高可用等目标,因此有了如下几种设计假设

  1. 具有长生命周期的大内存分配使用

    mmap

  2. 特别大的内存分配总是使用

    mmap

  3. 具有短生命周期的内存分配使用

    brk

    ,因为用

    mmap

    映射匿名页,当发生缺页异常

    时,linux 内核为缺页分配一个新物理页,并将该物理页清 0,一个

    mmap

    的内存块

    需要映射多个物理页,导致多次清 0 操作,很浪费系统资源,所以引入了

    mmap

    分配阈值动态调整机制,保证在必要的情况下才使用

    mmap

    分配内存。
  4. 尽量只缓存临时使用的空闲小内存块,对大内存块或是长生命周期的大内存块在释

    放时都直接归还给操作系统。

  5. 对空闲的小内存块只会在

    malloc

    free

    的时候进行合并,

    free

    时空闲内存块可能放入 内存池中,不一定归还给操作系统。
  6. 收缩堆的条件是当前 free 的块大小加上前后能合并 chunk 的大小大于 64KB、,并且

    堆顶的大小达到阈值,才有可能收缩堆,把堆最顶端的空闲内存返回给操作系统。

  7. 需要保持长期存储的程序不适合用

    ptmalloc

    来管理内存。
  8. 为了支持多线程,多个线程可以从同一个分配区(arena)中分配内存,

    ptmalloc

    假设线程 A 释放掉一块内存后,线程 B 会申请类似大小的内存,但是 A 释放的内

    存跟 B 需要的内存不一定完全相等,可能有一个小的误差,就需要不停地对内存块

    作切割和合并,这个过程中可能产生内存碎片。

Arena

在老版本的glibc中使用的内存分配器是

dlmalloc

,其底层对于多线程的支持并不友好,因为所有线程共享同一个内存管理结构。所以当多个线程并发执行

malloc

时,为了保证线程安全,其通过使用互斥锁进行加锁,使得只能有一个线程能够访问临界资源,因此在并发环境下使用

dlmalloc

时会花费大量的时间在互斥锁的阻塞等待上,因此整个应用的效率极低。

而在

ptmalloc

中,为了解决多线程并发争抢锁的问题, 其设定了主分配区

main_arean

和非主分配区

non_main_arena

  • 每个进程有一个主分配区,以及多个非主分配区
  • 主分配区可以使用

    brk

    mmap

    来分配空间,而非主分配区只能使用

    mmap

  • 非主分配区的数量只能增加,不能减少
  • 主分配区和非主分配区使用环形链表进行管理,并使用互斥锁保证线程安全

通过引入多个非主分配区,就可以将线程分发到不同的分配区中,将原先多个线程共享一个分配区变为了多个线程共享多个分配区,在一定程度上减轻了并发的压力。

Chunk

和其他内存管理机制一样,

ptmalloc

通过一个名为

chunk

的数据结构来管理和组织内存单元。为了节约内存,在使用时它的结构分为空闲的chuck和使用中的chuck两个版本

使用中的chuck:

Linux glibc内存管理:用户态内存分配器——ptmalloc实现原理ptmalloc设计假设ArenaChunkBins内存分配、释放流程总结
  • chuck指针指向chuck的起始地址,mem指针指向用户使用的内存块的起始地址,而next chunk则指向下一个chuck。
  • 在第二个域中,最低的一位p表示前一个chuck是否空闲,主要用于合并内存块的操作。当p=1时代表着上一次chunk正在使用,此时prev_size无效。p=0时代表前一个chuck空闲,prev_size有效。在ptmalloc分配第一个chuck时,总会将p置为1,防止程序越界访问。
  • M用于表示内存所处的区域,当M=1时为mmap映射区域分配,M=0为heap区域分配。
  • A用于标示分配区,A=1为非主分配区分配,A=0为主分配区分配。

空闲的chuck:

Linux glibc内存管理:用户态内存分配器——ptmalloc实现原理ptmalloc设计假设ArenaChunkBins内存分配、释放流程总结
  • 空闲的chuck会被放到空闲的链表bins上,当用户申请内存时,其会先去bins上查找是否存在合适的内存。
  • 对于空闲的chuck,为了方便其在空闲链表上快速查找合适大小的chuck,他有指向上一个和下一个空闲chuck的指针,同时还有指向上一个和下一个空闲chuck的内存大小的指针。

Bins

对于空闲的shunk,ptmalloc采用分箱式内存管理,通过bins来维护这些空闲的chunk。ptmalloc一共维护了128个bin,同时每个bin中又维护了一个大小相近的chunk链表,如下图

Linux glibc内存管理:用户态内存分配器——ptmalloc实现原理ptmalloc设计假设ArenaChunkBins内存分配、释放流程总结

根据chunk的大小以及状态不同,bins又分为以下四个种类

  • fast bins:fast bins是small bins的高速缓冲区,享有最快的内存分配、释放速度。当用户释放一块小于等于MAX_FAST(默认为64字节)的chunk时,会默认将其存放到fast bins中。当用户需要分配小内存时,会首先查看fast bins中是否存在合适的内存块,如果有则直接返回,如果没有才会去查看small bins上的空闲chunk。同时,ptmalloc会遍历fast bins,查看是否有合适的chunk需要合并,则将其合并后放入unsorted bin中
  • unsorted bin:unsorted bin只有一个,是bins的第一个位置。当用户释放的内存大雨MAX_FAST或者fast bins合并后的chunk都会进入unsorted bin上,当用户malloc的时候会先到unsorted bin上查找是否存在合适的bin,如果没有合适的bin,ptmalloc则会将unsorted bin伤的chunk放入bins上,然后再到bins上查找合适的空闲chunk。
  • small bins:用于存放固定大小的chunk,总共有64个bin,bin的大小从16字节开始,以8字节不断递增。当我们需要分配小内存块时,会采用精确匹配的方式从small bins中查找到合适的chunk。
  • large bins:用于存放大于512字节的空闲chunk,共有64个bin,bin按照顺序不定长升序排列,同时每个bin中的chunk按照大小降序排列

从上面我们可以得出以下结论

  • fast bins相当于small bins的cache,用于提高小内存的分配、释放效率,但也同时可能加剧内存碎片化
  • unsorted bin其实就是最近释放的内存集合,它会保留最近释放的chunk,从而消除了寻找合适的bin的时间开销,提高了内存分配和释放的效率
  • small bins和large bins可以合并相邻的空闲chunk,减轻了内存碎片化,但也同时降低了效率

当然,如果在上面的任何一个bin中都没办法找到合适的内存块,ptmalloc中还预留了其他的一些后备方式。

  • top chunk:在分配区中最顶部的chunk被称为top chunk,它不属于任何一个bin。当所有bin中都没有合适的内存时,就由它来响应用户请求。当用户申请的内存小于top chunk的内存时,其会将自己分割为两部分,一部分返回,另一部分则成为新的top chunk。如果用户申请的内存大于top chunk的内存,则top chunk会根据分配区的不同,分别调用

    sbrk

    或者

    mmap

    来进行扩容。
  • last remainder chunk:即最后一次small request中因分割而得到的剩余部分,它有利于改进局部性,当在small bins中找不到合适的chunk时,如果last remainded chunk的大小大于所需要的small chunk大小时,其就会将用户需要的那一部分分出去,剩余的部分则成为新的last remainded chunk,因此后续请求的chunk最终将被分配得彼此接近。
  • mmaped chunk:当分配的内存非常大的时候(大于128K),此时就需要通过

    mmap

    来申请内存,通过mmap申请的内存会被放到mmaped chunk上。同时,在释放的时候会通过判断chunk中的M是否为1来判断是否属于mmaped chunk,如果是则直接将内存交还给操作系统

内存分配、释放流程

分配流程:

  1. 获取分配区的锁
  2. 计算出需要分配内存的chunk大小
  3. 判断chunk的大小,如果小于

    MAX_FAST

    则到fast_bins上查找,如果小于512字节,则到small bins上查找
  4. 如果还没有找到,此时则进入unsorted bin上查找,如果找到合适的chunk并进行分配后还有剩余的空间,则根据其空间大小将其放入small bins或者large bins中。
  5. 进入large bins中查找,找到合适的bin后反向进行遍历,找到第一个大小满足的chunk进行分配,如果chunk分配完后还有剩余的空间,则将其放入unsorted bin中。
  6. 如果查找了所有的bin都没有找到合适的chunk,此时就需要操作top chunk来进行分配,如果满足大小小于top chunk,则切割top chunk来进行分配。如果大小大于top chunk,则此时会申请内存来满足分配需求。

释放流程:

  1. 获取分配区的锁
  2. 判断free的参数是否为空节点,如果是则什么都不做
  3. 判断当前chunk是否是

    mmap

    映射出的大块内存(M=1),如果是的话直接

    munmap

    释放掉
  4. 判断当前chunk是否和top chunk连续,如果连续则直接和top chunk合并
  5. 判断chunk大小是否大于

    MAX_FAST

    ,如果大于则放入unsorted bin中,并检查是否能够进行合并,如果不能合并则直接

    free

    ,如果能则进入步骤8
  6. 如果chunk大小小于

    MAX_FAST

    ,则放入fast bins中,并判断是否有合并的情况,如果没有合并则free,如果有则进入下一步骤
  7. 在fast bin中,如果当前当前chunk能够进行合并,则将合并后的chunk放入unsorted bin中。
  8. 判断top chunk的大小是否大于

    mmap

    收缩阈值,如果满足则试图归还多出的那一部分给操作系统

总结

从上面的内容我们可以看出,ptmalloc虽然能够很好的进行内存管理,但是仍然存在着一系列的小问题,如:

  • 由于ptmalloc收缩内存是从top chunk开始,如果与top chunk相邻的chunk不能够释放,则top chunk以下的chunk都无法释放(即使后分配的内存先释放,也无法及时归还给系统)
  • 每个chunk至少为8字节(可能导致内存内碎片)
  • 虽然采用了主分配区+非主分配区的策略来优化多线程环境下的并发问题,但是在内存分配和释放的时候仍会首先进行加锁(影响性能)
  • 由于采用了非主分配区,导致内存不能在不同分配区间移动,内存使用不均衡(内存浪费)
  • 不定期分配长生命周期的内存(不利于回收,容易导致内存碎片)

所以后来各大厂商为了能够提升效率,开发出了如

tcmalloc

jemalloc

等内存分配器,但作为linux默认内存管理器的

ptmalloc

,以其稳定性始终占据着一席之地。

继续阅读