介绍ptmalloc之前,我们先了解一下内存布局,以x86的32位系统为例:
从上图可以看到,栈至顶向下扩展,并且栈是有界的。堆至底向上扩展,mmap 映射区域至顶向下扩展,mmap 映射区域和堆(heap)相对扩展,直至耗尽虚拟地址空间中的剩余区域,这种结构便于 C 运行时库使用 mmap 映射区域和堆(heap)进行内存分配
上节提到 heap 和 mmap 映射区域是可以提供给用户程序使用的虚拟内存空间,如何获得该区域的内存呢?操作系统提供了相关的系统调用来完成相关工作。对 heap 的操作,操作系统提供了 brk()函数,C 运行时库提供了 sbrk()函数;对 mmap 映射区域的操作,操作系统提供了 mmap()和 munmap()函数。sbrk(),brk() 或者 mmap() 都可以用来向我们的进程添加额外的虚拟内存。Glibc 同样是使用这些函数向操作系统申请虚拟内存。
这里要提到一个很重要的概念,内存的延迟分配,只有在真正访问一个地址的时候才建立这个地址的物理映射,这是 Linux 内存管理的基本思想之一。Linux 内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。
Heap 操作函数主要有两个,brk()为系统调用,sbrk()为 C 库函数。系统调用通常提供一种最小功能,而库函数通常提供比较复杂的功能。Glibc 的 malloc 函数族(realloc,calloc 等)就调用 sbrk()函数将brk 向上移动,sbrk()函数在内核的管理下将虚拟地址空间映射到内存,供 malloc()函数使用。
内核数据结构 mm_struct 中的成员变量 start_code 和 end_code 是进程代码段的起始和终止地址,start_data 和 end_data 是进程数据段的起始和终止地址,start_stack 是进程堆栈段起始地址,start_brk 是进程动态内存分配起始地址(堆的起始地址),还有一个 brk(堆的当前最后地址),就是动态内存分配当前的终止地址。C 语言的动态内存分配基本函数是malloc(),在 Linux 上的实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用,只是简单地改变 mm_struct 结构的成员变量 brk 的值。
sbrk函数的定义如下:
#include
void* sbrk(intptr_t increment);
//返回上次调用brk/sbrk后的末尾地址,失败返回-1。
increment取值:内存增量(以字节为单位)
0 -获取末尾地址。
>0 -增加内存空间。
>0 -释放内存空间。
例子:
#include
#include
int main()
{
char* p1 = sbrk(0); //获取brk的值
printf("%p\n", p1);
char* p2 = (char*)sbrk(1); //申请1个字节的内存空间
*p2 = 'A';
char* p3 = sbrk(0); //再次获取brk的值
printf("%p\n", p3);
printf("%d\n", p3-p1); //两次brk的差值一定为1个字节
printf("%c\n",*p2); //打印填充的字符
sbrk(-1); //释放这个字节
printf("%p\n", sbrk(0));
return 0;
}
虽然我们只申请了一个字节,但是在Linux下,默认会申请(映射)一页(4kb),以下代码就很好的证明了这一点
#include
#include
int main()
{
char* p1 = (char*)sbrk(1);//申请一个字节
int i = 0;
for(i = 0; i < 4096; ++i)
{
p1[i] = 'A';
}
printf("第一个字符:%c\n", p1[0]);
printf("最后一个字符:%c\n", p1[4095]);
sbrk(-1);//释放
return 0;
}
如果在for循环结束后,加上p1[4096] = ‘A’;
编译运行,则会出现 Segmentation fault,因为p1[4096]的位置已经在4kb之外了
brk函数定义如下:
#include
int brk(void* addr);
//addr为内存末尾块地址
//如果addr>brk,则为申请内存,如果addr
//成功返回0,失败返回-1
int main()
{
char* p1 = (char*)sbrk(0); //获取brk的位置
brk(p1+1);//申请一个字节
*p1 = 'A';
printf("%c\n", *p1);
brk(p1);//释放
return 0;
}
注意:sbrk/ brk增量方式管理动态内存,申请内存是在动态内存后面申请释放也是释放最后面,不能选择性地释放增量动态内存,只会从后往前释放
mmap()函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap 执行相反的操作,删除特定地址区域的对象映射。
mmap()函数的定义如下:
#include
void *mmap(
void *addr, //映射区内存起始地址,NULL系统自动选定
size_t length, //字节长度,自动按页(4K)对齐
int prot, //映射权限
int flags, //映射标志
int fd, //文件描述符
off_t offset //文件偏移量,自动按页(4K)对齐
);
成功返回映射区内存起始地址,失败返回MAP_FAILED(-1)
prot取值:
PROT_EXEC //页内容可以被执行,ptmalloc 中没有使用
PROT_READ //页内容可以被读取,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设置该标志
PROT_WRITE //页可以被写入,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设置该标志
PROT_NONE //页不可访问,ptmalloc 用 mmap 向系统“批发”一块内存进行管理时设置该标志
flags取值:
MAP_FIXED //使用指定的映射起始地址,如果由 start 和 len 参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上Ptmalloc 在回收从系统中“批发”的内存时设置该标志。
MAP_SHARED //对映射区域的写入操作直接反映到文件中。
MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。Ptmalloc每次调用mmap都设置该标志。
MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。Ptmalloc 向系统“批发”内存块时设置该标志。
MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。Ptmalloc 每次调用 mmap都设置该标志。
MAP_LOCKED //锁定映射区域,保证不被置换
int munmap(void *addr, size_t length);
对于flags的取值,有一个需要注意的点,就是MAP_SHARED 和 MAP_PRIVATE 必须二选一
munmap函数定义如下:
int munmap(
void* start, //映射区域的起始地址
size_t length, //字节长度,自动按页(4k)对齐
);
成功返回0,失败返回1
虚拟映射物理内存,而非文件
#include
#include
#include
#include
int main()
{
char* str = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS , 0, 0);
//如果申请失败,就会返回MAP_FAILED
if(str == MAP_FAILED)
{
printf("%s\n", strerror(errno));
return -1;
}
strcpy(str, "hello fl");
printf("%s\n", str);
munmap(str, 4096);
return 0;
}
虚拟内存映射到文件
#include
#include
#include
#include
#include
#include
#include
int main()
{
int fd = open("fl.txt", O_RDWR|O_CREAT, 0644);
if(fd == -1)
{
printf("%s\n", strerror(errno));
return -1;
}
//更改文件的大小为1024个字节,因为才创建的文件大小为0
truncate("fl.txt", 1024);
// 映射到文件
char* p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED , fd, 0);
if(p == MAP_FAILED)
{
printf("%s\n", strerror(errno));
close(fd);
return -1;
}
//如果文件大小为0,则映射的虚拟内存大小也为0,如果强行写入数据,则会出现 Bus error
//表示CPU试图访问的内存位置不可用或者无效,导致总线错误
strcpy(p, "hello fl\n");
//取消映射
munmap(p, 1024);
return 0;
}
总的来说:所谓内存分配与释放,其本质就是建立或取消虚拟内存和物理内存间的映射关系。
因为brk、sbrk、mmap都属于系统调用,若每次申请内存,都调用这三个,那么每次都会产生系统调用,影响性能;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果高地址的内存没有被释放,低地址的内存就不能被回收
最初的内存分配器中只有一个主分配区(main arena),每次分配内存都必须对主分配区加锁,分配完成后释放锁,在 SMP 多线程环境下,对主分配区的锁的争用很激烈,严重影响了 malloc 的分配效率。于是为了更好的支持多线程,增加了非主分配区(non main arena)支持,主分配区与非主分配区用环形链表进行管理。每一个分配区利用互斥锁(mutex)使线程对于该分配区的访问互斥。
每个进程只有一个主分配区,但可能存在多个非主分配区,ptmalloc 根据系统对分配区的争用情况动态增加非主分配区的数量,分配区的数量一旦增加,就不会再减少了。主分配区可以访问进程的 heap 区域和 mmap 映射区域,也就是说主分配区可以使用 sbrk 和 mmap向操作系统申请虚拟内存。而非主分配区只能访问进程的 mmap 映射区域,非主分配区每次使用 mmap()向操作系统“批发”HEAP_MAX_SIZE(32 位系统上默认为 1MB,64 位系统默认为 64MB)大小的虚拟内存,当用户向非主分配区请求分配内存时再切割成小块“零售”出去,毕竟系统调用是相对低效的,直接从用户空间分配内存快多了。所以 ptmalloc 在必要的情况下才会调用 mmap()函数向操作系统申请虚拟内存。
主分配区可以访问 heap 区域,如果用户不调用 brk()或是 sbrk()函数,分配程序就可以保证分配到连续的虚拟地址空间,因为每个进程只有一个主分配区使用 sbrk()分配 heap 区域的虚拟内存。内核对 brk 的实现可以看着是 mmap 的一个精简版,相对高效一些。如果主分配区的内存是通过 mmap()向系统分配的,当 free 该内存时,主分配区会直接调用 munmap()将该内存归还给系统。
当某一线程需要调用 malloc()分配内存空间时,该线程先查看线程私有变量中是否已经存在一个分配区,如果存在,尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败,该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。
不管内存是在哪里被分配的,用什么方法分配,用户请求分配的空间在 ptmalloc 中都使用一个 chunk 来表示。用户调用 free()函数释放掉的内存也并不是立即就归还给操作系统,相反,它们也会被表示为一个 chunk,ptmalloc 使用特定的数据结构来管理这些空闲的 chunk
Chunk 格式
ptmalloc 在给用户分配的空间的前后加上了一些控制信息,用这样的方法来记录分配的信息,以便完成分配和释放工作。一个使用中的 chunk(使用中,就是指还没有被 free 掉)在内存中的样子如图所示:
在图中,chunk 指针指向一个 chunk 的开始,一个 chunk 中包含了用户请求的内存区域和相关的控制信息。图中的 mem 指针才是真正返回给用户的内存指针。chunk 的第二个域的最低一位为 P,它表示前一个块是否在使用中,P 为 0 则表示前一个 chunk 为空闲,这时chunk 的第一个域 prev_size 才有效,prev_size 表示前一个 chunk 的 size,程序可以使用这个值来找到前一个 chunk 的开始地址。当 P 为 1 时,表示前一个 chunk 正在使用中,prev_size无效,程序也就不可以得到前一个chunk的大小。不能对前一个chunk进行任何操作。ptmalloc分配的第一个块总是将 P 设为 1,以防止程序引用到不存在的区域。
Chunk 的第二个域的倒数第二个位为 M,他表示当前 chunk 是从哪个内存区域获得的虚拟内存。M 为 1 表示该 chunk 是从 mmap 映射区域分配的,否则是从 heap 区域分配的。
Chunk 的第二个域倒数第三个位为 A,表示该 chunk 属于主分配区或者非主分配区,如果属于非主分配区,将该位置为 1,否则置为 0。
当 chunk 空闲时,其 M 状态不存在,只有 AP 状态,原本是用户数据区的地方存储了四个指针,指针 fd 指向后一个空闲的 chunk,而 bk 指向前一个空闲的 chunk,ptmalloc 通过这两个指针将大小相近的 chunk 连成一个双向链表。对于 large bin 中的空闲 chunk,还有两个指针,fd_nextsize 和 bk_nextsize,这两个指针用于加快在 large bin 中查找最近匹配的空闲chunk。不同的 chunk 链表又是通过 bins 或者 fastbins 来组织的
chunk 中的空间复用
为了使得 chunk 所占用的空间最小,ptmalloc 使用了空间复用,一个 chunk 或者正在被使用,或者已经被 free 掉,所以 chunk 的中的一些域可以在使用状态和空闲状态表示不同的意义,来达到空间复用的效果。以 32 位系统为例,空闲时,一个 chunk 中至少需要 4个 size_t(4B)大小的空间,用来存储 prev_size,size,fd 和 bk (见上图),也就是 16B,chunk 的大小要对齐到 8B。当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size域肯定是无效的。所以实际上,这个空间也可以被当前 chunk 使用。这听起来有点不可思议,但确实是合理空间复用的例子。故而实际上,一个使用中的 chunk 的大小的计算公式应该是:in_use_size = (用户请求大小+ 8 - 4 ) align to 8B,这里加 8 是因为需要存储 prev_size 和 size,但又因为向下一个 chunk“借”了 4B,所以要减去 4。最后,因为空闲的 chunk 和使用中的chunk 使用的是同一块空间。所以肯定要取其中最大者作为实际的分配空间。即最终的分配空间 chunk_size = max(in_use_size, 16)。这就是当用户请求内存分配时,ptmalloc 实际需要分配的内存大小,在后面的介绍中。如果不是特别指明的地方,指的都是这个经过转换的实际需要分配的内存大小,而不是用户请求的内存分配大小。
这里的向下一个chunk“借”了 4B的具体含义,跟prev_size有关,字段 prev_size 记录的什么信息呢?有两种情况:
1)如果前一个邻接 chunk 块空闲,那么当前 chunk 块结构体内的 prev_size 字段记录的是前一个邻接 chunk 块的大小。
2)如果前一个邻接 chunk 在使用中,则当前 chunk 的 prev_size 的空间被前一个 chunk借用中,其中的值是前一个 chunk 的内存内容,对当前 chunk 没有任何意义。
因此一个chunk正在被使用,那么用户存放的数据,最后的4B数据一定来自下一个chunk,无论下一个chunk是否正在被使用
chunk源码定义:
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;
};
mchunk_prev_size:如果前一个 chunk 是空闲的,该域表示前一个 chunk 的大小,如果前一个 chunk不空闲,该域无意义。
mchunk_size:当前 chunk 的大小,并且记录了当前 chunk 和前一个 chunk 的一些属性,包括前一个 chunk 是否在使用中,当前 chunk 是否是通过 mmap 获得的内存,当前 chunk 是否属于非主分配区。
fd 和 bk:指针 fd 和 bk 只有当该 chunk 块空闲时才存在,其作用是用于将对应的空闲chunk 块加入到空闲 chunk 块链表中统一管理,如果该 chunk 块被分配给应用程序使用,那么这两个指针也就没有用(该 chunk 块已经从空闲链中拆出)了,所以也当作应用程序的使用空间,而不至于浪费。
fd_nextsize 和 bk_nextsize:当当前的 chunk 存在于 large bins 中时,large bins 中的空闲chunk 是按照大小排序的,但同一个大小的 chunk 可能有多个,增加了这两个字段可以加快遍历空闲 chunk,并查找满足需要的空闲 chunk,fd_nextsize 指向下一个比当前 chunk 大小大的第一个空闲 chunk,bk_nextszie 指向前一个比当前 chunk 大小小的第一个空闲 chunk。如果该 chunk 块被分配给应用程序使用,那么这两个指针也就没有用(该 chunk 块已经从 size链中拆出)了,所以也当作应用程序的使用空间,而不至于浪费。
对于空闲的 chunk,ptmalloc 采用分箱式内存管理方式,根据空闲 chunk 的大小和处于的状态将其放在四个不同的 bin 中,这四个空闲 chunk 的容器包括 fast bins,unsorted bin,small bins 和 large bins。
用户 free 掉的内存并不是都会马上归还给系统,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 的容器内
ptmalloc使用small bins管理空闲小chunk
在 SIZE_SZ 为 4B 的平台上,small bins 中的 chunk 大小是以 8B 为公差的等差数列,最大的 chunk 大小为 504B,最小的 chunk 大小为 16B,所以实际共 62 个 bin。分别为 16B、24B、32B,…,504B。在 SIZE_SZ 为 8B 的平台上,small bins 中的 chunk 大小是以 16B 为公差的等差数列,最大的 chunk 大小为 1008B,最小的 chunk 大小为 32B,所以实际共 62 个 bin。分别为 32B、48B、64B,…,1008B。
ptmalloc 维护了 62 个双向环形链表,每一个链表内的各空闲 chunk 的大小一致,因此当应用程序需要分配某个字节大小的内存空间时直接在对应的链表内取就可以了,这样既可以很好的满足应用程序的内存空间申请请求而又不会出现太多的内存碎片
在 SIZE_SZ 为 4B 的平台上,大于等于 512B 的空闲 chunk,或者,在 SIZE_SZ 为 8B 的平台上,大小大于等于 1024B 的空闲 chunk,由 sorted bins 管理。Large bins 一共包括 63 个 bin,每个 bin 中的 chunk 大小不是一个固定公差的等差数列,而是分成 6 组 bin,每组 bin 是一个固定公差的等差数列,每组的 bin 数量依次为 32、16、8、4、2、1,公差依次为 64B、512B、4096B、32768B、262144B 等。
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 的一个缓冲区,增加它只是为了加快分配的速度。
一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,ptmalloc 中在分配过程中引入了 fast bins,不大于 max_fast (默认值为 64B)的 chunk 被释放后,首先会被放到 fast bins 中,fast bins 中的 chunk 并不改变它的使用标志 P。这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,ptmalloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找bins中的空闲chunk。在某个特定的时候,ptmalloc会遍历fast bins中的chunk,将相邻的空闲 chunk 进行合并,并将合并后的chunk 加入 unsorted bin 中,然后再将 usorted bin 里的 chunk 加入 bins 中。
并不是所有的 chunk 都按照上面的方式来组织,实际上,有三种例外情况。Top chunk,mmaped chunk 和 last remainder,下面会分别介绍这三类特殊的 chunk。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 的大小。
当需要分配的 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
每个分配区是 struct malloc_state 的一个实例,ptmalloc 使用 malloc_state 来管理分配区
struct malloc_state
{
//Mutex 用于串行化访问分配区,当有多个线程访问同一个分配区时,第一个获得这个mutex 的线程将使用该分配区分配内存,分配完成后,释放该分配区的 mutex,以便其它线程使用该分配区。
__libc_lock_define (, mutex);
//Flags 记录了分配区的一些标志,bit0 用于标识分配区是否包含至少一个 fast bin chunk,bit1 用于标识分配区是否能返回连续的虚拟地址空间
int flags;
//fastbin块是否包含最近插入的空闲块
int have_fastchunks;
//Fastbins
mfastbinptr fastbinsY[NFASTBINS];
//top chunk
mchunkptr top;
//last_remainder
mchunkptr last_remainder;
//bins 用于存储 unstored bin,small bins 和 large bins 的 chunk 链表头
mchunkptr bins[NBINS * 2 - 2];
//binmap 字段是一个 int 数组,ptmalloc 用一个 bit 来标识该 bit 对应的 bin 中是否包含空闲 chunk
unsigned int binmap[BINMAPSIZE];
//用于将分配区以单向链表链接起来
struct malloc_state *next;
//next_free 字段空闲的分配区链接在单向链表中,只有在定义了 PER_THREAD 的情况下才定义该字段
struct malloc_state *next_free;
//记录了当前分配区已经分配的内存大小
INTERNAL_SIZE_T system_mem;
//记录了当前分配区最大能分配的内存大小
INTERNAL_SIZE_T max_system_mem;
};
ptmalloc 的响应用户内存分配要求的具体步骤:
获取分配区的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区域的锁。线程先查看线程私有实例中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有的分配区都已经加锁,那么 ptmalloc 会开辟一个新的分配区,把该分配区加入到全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。开辟出来的新分配区一定为非主分配区,因为主分配区是从父进程那里继承来的。开辟非主分配区时会调用 mmap()创建一个 sub-heap,并设置好 top chunk。
将用户的请求大小转换为实际需要分配的 chunk 空间大小。
判断所需分配chunk的大小是否满足chunk_size <= max_fast (max_fast 默认为 64B),如果是的话,则转下一步,否则跳到第 5 步。
首先尝试在 fast bins 中取一个所需大小的 chunk 分配给用户。如果可以找到,则分配结束。否则转到下一步。
判断所需大小是否处在 small bins 中,即判断 chunk_size < 512B 是否成立。如果chunk 大小处在 small bins 中,则转下一步,否则转到第 7 步。
根据所需分配的 chunk 的大小,找到具体所在的某个 small bin,从该 bin 的尾部摘取一个恰好满足大小的 chunk。若成功,则分配结束,否则,转到下一步。
到了这一步,说明需要分配的是一块大的内存,或者 small bins 中找不到合适的chunk。于是,ptmalloc 首先会遍历 fast bins 中的 chunk,将相邻的 chunk 进行合并,并链接到 unsorted bin 中,然后遍历 unsorted bin 中的 chunk,如果 unsorted bin 只有一个 chunk,并且这个 chunk 在上次分配时被使用过,并且所需分配的 chunk 大小属于 small bins,并且 chunk 的大小大于等于需要分配的大小,这种情况下就直接将该 chunk 进行切割,分配结束,否则将根据 chunk 的空间大小将其放入 small bins 或是 large bins 中,遍历完成后,转入下一步。
到了这一步,说明需要分配的是一块大的内存,或者 small bins 和 unsorted bin 中都找不到合适的 chunk,并且 fast bins 和 unsorted bin 中所有的 chunk 都清除干净了。从 large bins 中按照“smallest-first,best-fit”原则,找一个合适的 chunk,从中划分一块所需大小的 chunk,并将剩下的部分链接回到 bins 中。若操作成功,则分配结束,否则转到下一步。
如果搜索 fast bins 和 bins 都没有找到合适的 chunk,那么就需要操作 top chunk 来进行分配了。判断 top chunk 大小是否满足所需 chunk 的大小,如果是,则从 top chunk 中分出一块来。否则转到下一步。
到了这一步,说明 top chunk 也不能满足分配要求,所以,于是就有了两个选择: 如果是主分配区,调用 sbrk(),增加 top chunk 大小;如果是非主分配区,调用 mmap来分配一个新的 sub-heap,增加 top chunk 大小;或者使用 mmap()来直接分配。在这里,需要依靠 chunk 的大小来决定到底使用哪种方法。判断所需分配的 chunk大小是否大于等于 mmap 分配阈值,如果是的话,则转下一步,调用 mmap 分配,否则跳到第 12 步,增加 top chunk 的大小。
使用 mmap 系统调用为程序的内存空间映射一块 chunk_size align 4kB 大小的空间。然后将内存指针返回给用户。
判断是否为第一次调用 malloc,若是主分配区,则需要进行一次初始化工作,分配一块大小为(chunk_size + 128KB) align 4KB 大小的空间作为初始的 heap。若已经初始化过了,主分配区则调用 sbrk()增加 heap 空间,分主分配区则在 top chunk 中切割出一个 chunk,使之满足分配需求,并将内存指针返回给用户。
总结一下:根据用户请求分配的内存的大小,ptmalloc 有可能会在两个地方为用户分配内存空间。在第一次分配内存时,一般情况下只存在一个主分配区,但也有可能从父进程那里继承来了多个非主分配区,在这里主要讨论主分配区的情况,brk 值等于start_brk,所以实际上 heap 大小为 0,top chunk 大小也是 0。这时,如果不增加 heap大小,就不能满足任何分配要求。所以,若用户的请求的内存大小小于 mmap 分配阈值,则 ptmalloc 会初始 heap。然后在 heap 中分配空间给用户,以后的分配就基于这个 heap进行。若第一次用户的请求就大于 mmap 分配阈值,则 ptmalloc 直接使用 mmap()分配一块内存给用户,而 heap 也就没有被初始化,直到用户第一次请求小于 mmap 分配阈值的内存分配。第一次以后的分配就比较复杂了,简单说来,ptmalloc 首先会查找 fast bins,如果不能找到匹配的 chunk,则查找 small bins。若还是不行,合并 fast bins,把 chunk加入 unsorted bin,在 unsorted bin 中查找,若还是不行,把 unsorted bin 中的 chunk 全加入 large bins 中,并查找 large bins。在 fast bins 和 small bins 中的查找都需要精确匹配,而在 large bins 中查找时,则遵循“smallest-first,best-fit”的原则,不需要精确匹配。若以上方法都失败了,则 ptmalloc 会考虑使用 top chunk。若 top chunk 也不能满足分配要求。而且所需 chunk 大小大于 mmap 分配阈值,则使用 mmap 进行分配。否则增加heap,增大 top chunk。以满足分配要求。
free() 函数接受一个指向分配区域的指针作为参数,释放该指针所指向的 chunk。而具体的释放方法则看该 chunk 所处的位置和该 chunk 的大小。free()函数的工作步骤如下:
free()函数同样首先需要获取分配区的锁,来保证线程安全。
判断传入的指针是否为 0,如果为 0,则什么都不做,直接 return。否则转下一步。
判断所需释放的 chunk 是否为 mmaped chunk,如果是,则调用 munmap()释放mmaped chunk,解除内存空间映射,该该空间不再有效。如果开启了 mmap 分配阈值的动态调整机制,并且当前回收的 chunk 大小大于 mmap 分配阈值,将 mmap分配阈值设置为该 chunk 的大小,将 mmap 收缩阈值设定为 mmap 分配阈值的 2倍,释放完成,否则跳到下一步。
判断 chunk 的大小和所处的位置,若 chunk_size <= max_fast,并且 chunk 并不位于heap 的顶部,也就是说并不与 top chunk 相邻,则转到下一步,否则跳到第 6 步。(因为与 top chunk 相邻的小 chunk 也和 top chunk 进行合并,所以这里不仅需要判断大小,还需要判断相邻情况)
将 chunk 放到 fast bins 中,chunk 放入到 fast bins 中时,并不修改该 chunk 使用状态位 P。也不与相邻的 chunk 进行合并。只是放进去,如此而已。这一步做完之后释放便结束了,程序从 free()函数中返回。
判断前一个 chunk 是否处在使用中,如果前一个块也是空闲块,则合并。并转下一步。
判断当前释放 chunk 的下一个块是否为 top chunk,如果是,则转第 9 步,否则转下一步。
判断下一个 chunk 是否处在使用中,如果下一个 chunk 也是空闲的,则合并,并将合并后的 chunk 放到 unsorted bin 中。注意,这里在合并的过程中,要更新 chunk的大小,以反映合并后的 chunk 的大小。并转到第 10 步。
如果执行到这一步,说明释放了一个与 top chunk 相邻的 chunk。则无论它有多大,都将它与 top chunk 合并,并更新 top chunk 的大小等信息。转下一步。
判断合并后的 chunk 的大小是否大于 FASTBIN_CONSOLIDATION_THRESHOLD(默认64KB),如果是的话,则会触发进行 fast bins 的合并操作,fast bins 中的 chunk 将被遍历,并与相邻的空闲 chunk 进行合并,合并后的 chunk 会被放到 unsorted bin 中。fast bins 将变为空,操作完成之后转下一步。
判断 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 收缩阈值,才有可能收缩堆。