概述
内存分配器ptmalloc,即glibc中的malloc,实现了 malloc(),free()以及一组其它的函数,以提供动态内存管理的支持。分配器处在用户程序和内核之间,它响应用户的分配请求,向操作系统申请内存,然后将其返回给用户程序。 为了保持高效的分配,分配器一般都会预先分配一块大于用户请求的内存,并通过算法管理这块内存。来满足用户的内存分配要求,用户释放掉的内存也并不是立即就返回给操作系统,相反,分配器会管理这些被释放掉的空闲空间,以应对用户以后的内存分配要求。
内存管理结构
chunk
chunk是glibc内存管理的最小单位,其数据结构如下所示
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
chunk中几个关键的成员有prev_size、mchunk_size、fd和bk,其作用分别为:
prev_size:如果前一个chunk是free chunk,则这个内容保存的是前一个chunk的大小。 如果前一个chunk是allocated chunk,则这个区域保存的是前一个chunk的用户数据。
mchunk_size:当前chunk的大小。最后的 3 位作为标志位,具体为:
第0比特位用于表示前一个chunk是否为allocated chunk
第1比特位用于标记该chunk是否是通过系统调用申请的(子线程是mmap,主线程则是通过 brk)。如果是,则该chunk不再由内存管理数据结构来标记,申请释放流程将简化。
第2比特位用于标记该chunk是否属于主分配区,关于分配区将在下文详细介绍。
fd:前向指针,即指向当前chunk在同一个bin的下一个chunk的指针,仅chunk未使用的时候存在。
bk:后向指针,即指向当前chunk在同一个bin的上一个chunk的指针,仅chunk未使用的时候存在。
arena
arena一般称为分配区,是一个结构体,内含指向各自类型内存块的指针等元素,每个线程在申请内存时会获取一个。分配区分为主分配区和thread分配区,前者仅有一个,其余均为thread分配区。当新创建的线程需要申请内存时,将从一个全局的链表中获取一个空闲的分配区,如果没有得到且分配区数量没有超过最大值(M_ARENA_MAX),malloc将会新建一个。
具体逻辑可见下文 多线程下的竞争抢锁。
heap
heap包括帧头和内存块, glibc以heap为单位从操作系统批量申请和释放内存。 主分配区有一个heap,thread分配区在刚创建时也只有一个,当超过一定大小时会新增heap,heap直接以链表形式相连,数量没有限制,单个heap最大默认64M。新建heap时里面只有一个chunk,称为top chunk,每次申请内存时都会从top chunk中分裂出一块chunk,而top chunk本身则始终位于heap的末端。
下图是只有一个heap的main arena和thread arena的内存分布图:
下图是一个thread arena中含有多个heap的情况:
从以上两图可以看出,thread arena只含有一个arena,却有两个heap_info(即 heap header)。由于两个heap是通过mmap从操作系统申请的内存,两者在内存布局上并不相邻而是分属于不同的内存区间,所以为了便于管理,glibc的malloc将第二个heap_info结构体的prev成员指向了第一个heap_info结构体的起始位置(即ar_ptr成员),而第一个heap_info结构体的ar_ptr成员指向了arena,这样就构成了一个单链表,方便后续管理。
typedef struct _heap_info
{
mstate ar_ptr; /* Arena for this heap. */
struct _heap_info *prev; /* Previous heap. */
size_t size; /* Current size in bytes. */
size_t mprotect_size; /* Size in bytes that has been mprotected
PROT_READ|PROT_WRITE. */
/* Make sure the following data is properly aligned, particularly
that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
MALLOC_ALIGNMENT. */
char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;
mstate ar_ptr:ar_ptr 是一个指向 arena 结构(mstate)的指针。arena 是用于动态内存分配的内存区域。这个指针将堆与其关联的 arena 连接起来。
struct _heap_info *prev:prev 是一个指向前一个 heap_info 结构体的指针,用于形成一个链表。这使得可以管理多个堆段,每个堆段通过链表连接起来。
size_t size:size 存储当前堆的大小,以字节为单位。这表示由该堆段管理的总内存量。
size_t mprotect_size:指示已使用 mprotect 保护的堆的大小(以字节为单位),该保护具有 PROT_READ 和 PROT_WRITE 标志。mprotect 是一个系统调用,用于控制内存区域的保护,例如使其可读和可写。
char pad:pad 是一个填充字段,用于确保 heap_info 结构体的正确对齐。填充的大小通过按位与计算 -6 * SIZE_SZ 和 MALLOC_ALIGN_MASK 得出。
内存管理链表
对于空闲的 chunk,ptmalloc 采用分箱式内存管理方式,根据空闲 chunk 的大小和处于的状态将其放在不同的 bin 中。
glibc使用的内存池如下图示,glibc提供了几种链表来管理不同大小的chunk。其中,除tcache外,其余均为arena结构体中的成员变量。
内存池保存在bins这个长128的数组中,每个元素都是一双向个链表。其中:
- bins[0]目前没有使用
- bins[1]的链表称为
unsorted_list
,用于维护free释放的chunk。 - bins[2,63)的区间称为
small_bins
,用于维护<512字节的内存块,其中每个元素对应的链表中的chunk大小相同,均为index*8。 - bins[64,127)称为
large_bins
,用于维护>512字节的内存块,每个元素对应的链表中的chunk大小不同,index越大,链表中chunk的内存大小相差越大,例如: 下标为64的chunk大小介于[512, 512+64),下标为95的chunk大小介于[2k+1,2k+512)。同一条链表上的chunk,按照从小到大的顺序排列。
tcache
tcache是glibc为了提升小块内存申请释放性能引入的缓存机制。单个tcache有64个链表项,每一项里面最多可保存7块大小相同的chunk,tcache链表本身的数据结构从分配区管理的heap中申请,线程退出时释放回原heap,由于tcache是线程变量,每个线程都会有一个自己的tcache,因此理论上数量无上限。
fastbin
fastbin为管理小块chunk(64位为160字节)的链表,应对频繁申请小块内存的场景。链表项管理的chunk值按一定规律递增,可通过一定的算法算出指定大小的chunk所在的链表项索引,从而找到对应大小的chunk。
tcache 是一个每个线程独有的缓存机制。这意味着每个线程都有自己的 tcache,避免了多线程竞争而fastbin 是一个全局缓存机制,不是线程私有的。这意味着多个线程可能会竞争访问 fastbin。
unsortedbin
fastbin中整合的chunk和small chunk、 large chunk free之后的chunk被放入unsortedbin,加速内存申请释放,unsortedbin管理的chunk值无规律。
smallbin、largebin
smallbin和largebin管理的chunk值按一定规律递增,可通过一定的算法算出指定大小的chunk所在的链表项索引,从而找到对应大小的chunk。
内存分配策略
申请流程
glibc中malloc
内存分配大体逻辑:
- 分配内存 <
DEFAULT_MMAP_THRESHOLD
,走__brk,从内存池获取,失败的话走brk系统调用 - 分配内存 >
DEFAULT_MMAP_THRESHOLD
,走__mmap,直接调用mmap系统调用
其中,DEFAULT_MMAP_THRESHOLD
默认为128k,可通过mallopt
进行设置。 重点看下小块内存(size > DEFAULT_MMAP_THRESHOLD
)的分配,
glibc在内存池中查找合适的chunk时(此处不考虑fastbin和tcache),采用了最佳适应的伙伴算法。
1、如果分配内存<512字节,则通过内存大小定位到smallbins对应的index上(floor(size/8)
)
- smallbins[index]为空,进入步骤3
- smallbins[index]非空,直接返回第一个chunk
2、如果分配内存>512字节,则定位到largebins对应的index上
- largebins[index]为空,进入步骤3
- largebins[index]非空,扫描链表,找到第一个大小最合适的chunk,如size=12.5K,则使用chunk B,剩下的0.5k放入unsorted_list中
3、遍历unsorted_list,查找合适size的chunk,如果找到则返回;否则,将这些chunk都归类放到smallbins和largebins里面
4、index++从更大的链表中查找,直到找到合适大小的chunk为止,找到后将chunk拆分,并将剩余的加入到unsorted_list中
5、如果还没有找到,那么使用top chunk
6、或者,内存<128k,使用brk;内存>128k,使用mmap获取新内存
释放流程
free
释放内存到其内存池时,有两种情况:
- chunk和top chunk相邻,则和top chunk合并
- chunk和top chunk不相邻,则直接插入到
unsorted_list
中
内存碎片
按照glibc的内存分配策略,我们考虑下如下场景(假设brk其实地址是512k):
- malloc 40k内存,即chunkA,brk = 512k + 40k = 552k
- malloc 50k内存,即chunkB,brk = 552k + 50k = 602k
- malloc 60k内存,即chunkC,brk = 602k + 60k = 662k
- free chunkA。
此时,由于brk = 662k,而释放的内存是位于[512k, 552k]之间,无法通过移动brk指针,将区域内内存交还操作系统,因此,在[512k, 552k]的区域内便形成了一个内存碎片。 按照glibc的策略,free后的chunkA区域由于不和top chunk相邻,因此,无法和top chunk 合并,应该挂在unsorted_list
链表上。
多线程下的竞争抢锁
并发条件下,main_arena
引发的竞争将会成为限制程序性能的瓶颈所在,因此glibc采用了多arena机制,线程A分配内存时获取main_arena
锁成功,将在main_arena
所管理的内存中分配;此时线程B获取main_arena
失败,glibc会新建一个arena1,此次内存分配从arena1中进行。
这种策略,一定程度上解决了多线程下竞争的问题;但是随着arena的增多,内存碎片出现的可能性也变大了。例如,main_arena中有10k、20k的空闲内存,线程B要获取20k的空闲内存,但是获取main_arena锁失败,导致留下20k的碎片,降低了内存使用率。
普通arena结构
- 一个arena由多个Heap构成
- 每个Heap通过mmap获得,最大为1M,多个Heap间可能不相邻
- Heap之间有prev指针指向前一个Heap
- 最上面的Heap,也有top chunk
每个Heap里面也是由chunk组成,使用和main_arena完全相同的管理方式管理空闲chunk。
main arena和普通arena的区别
main_arena
是为一个使用brk指针的arena,由于brk是堆顶指针,一个进程中只可能有一个,因此普通arena无法使用brk进行内存分配。普通arena建立在mmap的机制上,内存管理方式和main_arena
类似,只有一点区别,普通arena只有在整个arena都空闲时,才会调用munmap
把内存还给操作系统。