【内存】GLIBC堆内存申请

1、前言

堆内存(Heap Memory)是一个很有意思的领域。你可能和我一样,也困惑于下述问题很久了:

如何从内核申请堆内存?
谁管理它?内核、库函数,还是应用本身?
内存管理效率怎么这么高?!
堆内存的管理效率可以进一步提高吗?
最新对glibc堆内存的管理做了研究,把自己的总结 和理解汇总一下

1.1什么是堆

  • 堆是每个程序被分配的一块内存区域,和栈的区别主要在于堆内存是动态分配的。也就是说,程序可以从heap段请求一块内存,或者释放一块内存。
  • 另外,堆内存是全局的,即在程序的任意位置 都可以访问到堆,并不一定要在调用malloc的函数访问,这是因为C语言使用指针指向动态分配的内存。

1.2使用动态分配的内存

  • Glibc采用ptmalloc2内存分配器管理堆内存,相比前身dlmalloc,它增加了队多线程的支持。
  • 注意,即使申请0字节内存,malloc仍然会分配一个最小的chunk;如果传给free的参数是空指针,free不会做任何事,而如何传入一个是已经free过的指针,那么后果是不可预期的。

1.3两个系统调用

注意申请内存时,linux内核只会先分配一段虚拟内存,真正使用时才会映射到物理内存上去。

1.3.1brk()

brk()通过增加break location来获取内存,一开始heap段的起点stark_brk和heap段的终点brk指向同一个位置。

  • ASLR关闭时,两者指向data/bss段的末尾,也就是end_data
  • ASLR打开时,两者指向data/bss段的末尾加上一段随机brk偏移
  • 先了解一下内存布局,以x86的32位系统为例:
    在这里插入图片描述
  • 注: 在进行堆溢出,如果需要覆盖栈上的变量,直接计算出偏移就可以;如果通过溢出覆盖bss段,32位下可以一直向上溢出到最上面,继续溢出会返回从0地址开始

1.3.2mmap()

  • 用于创建私有的匿名映射段,主要为了分配一块新的内存,且这块内存只有调用mmap()的进程可以使用,所以称之为私有的。与之进行相反操作的是munmap(),删除一块内存区域上的映射。
  • brk是真正分配一块内存,mmap只是在memory mapping segment中分配一块内存,做一个标记

2、多线程

前面提到,ptmalloc的一大改进就在于多线程,那么他是如何做到的呢?不难猜到,每个线程必定要维护一些独立的数据结构,并且对这些数据结构的访问时需要加锁的。

  • arena:分配区,其实就是一段连续的堆内存区域
  • 在ptmalloc中,每个线程(代码用户态线程)拥有自己的freelist,也就是维护空闲内存的一个链表;以及自己的arena;一段连续的堆内存区域
  • 特别地,主线程的arena叫做main_arena
  • 注意只有main_arena可以访问heap段和mmap映射区域,no_heap_arena只能访问mmap映射区域

当我们在程序中第一次申请内存时还没有heap段,因此132KB的heap段,也就是我们的main_arena,会被创建(通过brk),无论我们申请的内存是多大。对于接下来的内存申请,malloc都会从main_arena中尝试取出一块内存进行分配。如果空间不够,main_arena可以通过brk()扩张;如果空闲空间太多,也可以缩小。

那么对于no_main_arena呢?前面提到它只能访问mmap映射区域,因为在创建时它就是由mmap()创建的——1MB的内存空间会被映射到进程地址空间,不过实际上只有132KB是可读写的,这132KB就是该线程的heap结构,或者叫no_main_arena

注:当然了,当申请的空间大于128KB且arena中没有足够空间时,无论在哪个arena里都只能通过mmap申请内存

3、Arena

3.1Arena 的数量

arena也不是和线程一对一的,实际上有数量限制:

for 32 bit systems:
	Number of arena = 2 * number of cores
for 64 bit systems:
	Number of arena = 8 * number of cores

而当我们free一小块内存时,内存也不会直接归还给内核,而是给ptmalloc让他去维护,后者会将空闲内存丢入bin中,或者说freelist中也可以。如果过了一会我们的程序又要申请内存,那么ptmalloc就会从bin中找一块空闲的内存进行分配,找不到的话才会去问内核要内存。

3.2Multiple Arena

举例而言:让我们来看一个运行在单核计算机上的 32 位操作系统上的多线程应用(4 线程,主线程 + 3 个线程)的例子。这里线程数量(4)> 2 * 核心数(1),所以分配器中可能有 Arena(也即标题所称「multiple arenas」)会被所有线程共享。那么是如何共享的呢?

  • 当主线程第一次调用 malloc 时,已经建立的 main arena 会被没有任何竞争地使用;
  • 当 thread 1 和 thread 2 第一次调用 malloc 时,一块新的 arena 将被创建,且将被没有任何竞争地使用。此时线程和 arena 之间存在一一映射关系;
  • 当 thread3 第一次调用 malloc 时,arena 的数量限制被计算出来,结果显示已超出,因此尝试复用已经存在的 arena(也即 Main arena 或 Arena 1 或 Arena 2);
  • 复用:
    • 一旦遍历到可用 arena,就开始自旋申请该 arena 的锁;
    • 如果上锁成功(比如说 main arena 上锁成功),就将该 arena 返回用户;
    • 如果没找到可用 arena,thread 3 的 malloc 将被阻塞,直到有可用的 arena 为止。
  • 当thread 3 调用 malloc 时(第二次了),分配器会尝试使用上一次使用的 arena(也即,main arena),从而尽量提高缓存命中率。当 main arena 可用时就用,否则 thread 3 就一直阻塞,直至 main arena 空闲。因此现在 main arena 实际上是被 main thread 和 thread 3 所共享。

3.3Multiple Heaps

main_arena只有一个堆,并且可以灵活的放缩;no_main_arena则只能通过mmap()获得一个堆。那么如果no_main_arena里分配的堆内存不够了怎么办?很简单,再mmap()一次,创建一个新的堆。
所以,在no_main_arena里,我们必须考虑如何维护多个堆的问题。这里我们涉及三个头部:

  • heap_info:每个堆的头部,main_arena是没有的;一般情况下,每个 thread arena 都只维护一个堆,但是当这个堆的空间耗尽时,新的堆(而非连续内存区域)就会被 mmap 到这个 aerna 里
  • malloc_state:arena的头部,一个 thread arena 可以维护多个堆,这些堆另外共享同一个 arena header;main_arena的这个部分是全局变量而不属于堆段
  • malloc_chunk:每个chunk的头部,根据用户请求,每个堆被分为若干 chunk。每个 chunk 都有自己的 chunk header。

main arena 和 thread arena 的图示如下(单堆段):
在这里插入图片描述
thread arena 的图示如下(多堆段):
在这里插入图片描述

4、chunk的结构

通俗的说,一块由分配器分配的内存块叫一个chunk,包含了元数据和用户数据。具体一点,chunk完整定义如下:

struct malloc_chunk {  
	INTERNAL_SIZE_T      prev_size;    /* Size of previous chunk (if free).  */  
	INTERNAL_SIZE_T      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; 
};  

这里面的6个字段均为元数据。
一个chunk可以是以下几种类型之一:

  • 已分配的(allocated chunk)
  • 空闲的(free chunk)
  • Top chunk
  • Last Remainder chunk

4.1allocated chunk

在这里插入图片描述
图中左方三个箭头依次表示:

  • chunk:该 Allocated chunk 的起始地址;
  • mem:该 Allocated chunk 中用户可用区域的起始地址(= chunk + sizeof(malloc_chunk));
  • next_chunk:下一个 chunck(无论类型)的起始地址。

图中结构体内部各字段的含义依次为:

  • prev_size:若上一个 chunk 可用,则此字段赋值为上一个 chunk 的大小;否则,此字段被用来存储上一个 chunk 的用户数据;
  • size:此字段赋值本 chunk 的大小,其最后三位包含标志信息:
    • PREV_INUSE § – 置「1」表示上个 chunk 被分配;使用size_t表示,所以大小由平台决定(32位上4B,64位上8B)
    • IS_MMAPPED (M) – 置「1」表示这个 chunk 是通过 mmap 申请的(较大的内存)
    • NON_MAIN_ARENA (N) – 置「1」表示这个 chunk 属于一个 thread arena。

注意:

  • prev_size:只有在前一个chunk空闲时才表示前一个块的大小,否则就是无效的,可以被前一个块征用(存储用户数据)
  • ptmalloc 分配的第一个块总是将p设为1, 以防止程序引用到不存在的区域
  • 这里的前一个chunk,指内存中相邻的前一个,而不是freelist链表中的前一个,PREV_INUSE代表的“前一个chunk”同理
  • malloc_chunk中其他成员因为没有用到,所以被当做用户内存
  • 内存最少需要8字节对齐,所以AMP占用了低三位自然就没用,拿来当做标志位

4.2free chunk

用户已释放的 chunk,其图示如下:
在这里插入图片描述
图中结构体内部各字段的含义依次为:

  • prev_size: 两个相邻 free chunk 会被合并成一个,因此该字段总是保存前一个 allocated chunk的用户数据;
  • size: 该字段保存本 free chunk 的大小;
  • fd: Forward pointer —— 本字段指向同一 bin 中的下个 free chunk(free chunk 链表的前驱指针);
  • bk: Backward pointer —— 本字段指向同一 bin 中的上个 free chunk(free chunk 链表的后继指针)。

注意:

  1. free chunk的P标志一定是1,否则一定会和前一个chunk合并

Top chunk

Last Remainder chunk

5、Bins

「bins」 就是空闲列表数据结构。它们用以保存 free chunks。根据其中 chunk 的大小,bins 被分为如下几种类型:

  • Fast bin;
  • Unsorted bin;
  • Small bin;
  • Large bin.
    保存这些 bins 的字段为:
  • fastbinsY: 这个数组用以保存 fast bins;
  • bins: 这个数组用于保存 unsorted bin、small bins 以及 large bins,共计可容纳 126 个,其中:
    • Bin 1: unsorted bin;
    • Bin 2 - 63: small bins;
    • Bin 64 - 126: large bins.

5.1. Fast Bin

大小为 16 ~ 80 字节的 chunk 被称为「fast chunk」。在所有的 bins 中,fast bins 路径享有最快的内存分配及释放速度。

  • 数量: 10
  • 每个 fast bin 都维护着一条 free chunk 的单链表,采用单链表是因为链表中所有 chunk 的大小相等,增删 chunk 发生在链表顶端即可;—— LIFO
  • chunk 大小:8 字节递增
  • fast bins 由一系列所维护 chunk 大小以 8 字节递增的 bins 组成。也即,fast bin[0] 维护大小为 16 字节的 chunk、fast bin[1] 维护大小为 24 字节的 chunk。依此类推……
  • 指定 fast bin 中所有 chunk 大小相同;
  • 在 malloc 初始化过程中,最大的 fast bin 的大小被设置为 64 而非 80 字节。因为默认情况下只有大小 16 ~ 64 的 chunk 被归为 fast chunk 。
  • 无需合并 —— 两个相邻 chunk 不会被合并。虽然这可能会加剧内存碎片化,但也大大加速了内存释放的速度!
  • malloc(fast chunk)
    • 初始情况下 fast chunck 最大尺寸以及 fast bin 相应数据结构均未初始化,因此即使用户请求内存大小落在 fast chunk 相应区间,服务用户请求的也将是 small bin 路径而非 fast bin 路径;
    • 初始化后,将在计算 fast bin 索引后检索相应 bin;
    • 相应 bin 中被检索的第一个 chunk 将被摘除并返回给用户。
  • free(fast chunk)
    • 计算 fast bin 索引以索引相应 bin;
    • free 掉的 chunk 将被添加到上述 bin 的顶端。
      在这里插入图片描述

5.2. Unsorted Bin

当 small chunk 和 large chunk 被 free 掉时,它们并非被添加到各自的 bin 中,而是被添加在 「unsorted bin」 中。这使得分配器可以重新使用最近 free 掉的 chunk,从而消除了寻找合适 bin 的时间开销,进而加速了内存分配及释放的效率。
注: 在内存分配的时候,在前后检索 fast/small bins 未果之后,在特定条件下,会将 unsorted bin 中的 chunks 转移到合适的 bin 中去,small/large

  • 数量:1
  • unsorted bin 包括一个用于保存 free chunk 的双向循环链表(又名 binlist);
  • chunk 大小:无限制,任何大小的 chunk 均可添加到这里。
    在这里插入图片描述

5.3. Small Bin

大小小于 512 字节的 chunk 被称为 「small chunk」,而保存 small chunks 的 bin 被称为 「small bin」。在内存分配回收的速度上,small bin 比 large bin 更快。

  • 数量:62
    • 每个 small bin 都维护着一条 free chunk 的双向循环链表。采用双向链表的原因是,small bins 中的 chunk 可能会从链表中部摘除。这里新增项放在链表的头部位置,而从链表的尾部位置移除项。—— FIFO
  • chunk 大小:8 字节递增
    • Small bins 由一系列所维护 chunk 大小以 8 字节递增的 bins 组成。举例而言,small bin[0] (Bin 2)维护着大小为 16 字节的 chunks、small bin[1](Bin 3)维护着大小为 24 字节的 chunks ,依此类推……
    • 指定 small bin 中所有 chunk 大小均相同,因此无需排序;
  • 合并 —— 相邻的 free chunk 将被合并,这减缓了内存碎片化,但是减慢了 free 的速度;
  • malloc(small chunk)
    • 初始情况下,small bins 都是 NULL,因此尽管用户请求 small chunk ,提供服务的将是 unsorted bin路径而不是 small bin 路径;
    • 第一次调用 malloc 时,维护在 malloc_state 中的 small bins 和 large bins 将被初始化,它们都会指向自身以表示其为空;
    • 此后当 small bin 非空,相应的 bin 会摘除其中最后一个 chunk 并返回给用户;
  • free(small chunk)
  • free chunk 的时候,检查其前后的 chunk 是否空闲,若是则合并,也即把它们从所属的链表中摘除并合并成一个新的 chunk,新chunk 会添加在 unsorted bin 的前端。

5.4. Large Bin

大小大于等于 512 字节的 chunk 被称为「large chunk」,而保存 large chunks 的 bin 被称为 「large bin」。在内存分配回收的速度上,large bin 比 small bin 慢。

  • 数量:63
    • 每个 large bin 都维护着一条 free chunk 的双向循环链表。采用双向链表的原因是,large bins 中的 chunk 可能会从链表中的任意位置插入及删除。
    • 这 63 个 bins
      • 32 个 bins 所维护的 chunk 大小以 64B 递增,也即 large chunk[0](Bin 65) 维护着大小为 512B ~ 568B 的 chunk 、large chunk[1](Bin 66) 维护着大小为 576B ~ 632B 的 chunk,依此类推……
      • 16 个 bins 所维护的 chunk 大小以 512 字节递增;
      • 8 个 bins 所维护的 chunk 大小以 4096 字节递增;
      • 4 个 bins 所维护的 chunk 大小以 32768 字节递增;
      • 2 个 bins 所维护的 chunk 大小以 262144 字节递增;
      • 1 个 bin 维护所有剩余 chunk 大小;
    • 不像 small bin ,large bin 中所有 chunk 大小不一定相同,各 chunk 大小递减保存。最大的 chunk 保存顶端,而最小的 chunk 保存在尾端;
  • 合并 —— 两个相邻的空闲 chunk 会被合并;
  • malloc(large chunk)
    • 初始情况下,large bin 都会是 NULL,因此尽管用户请求 large chunk ,提供服务的将是 next largetst bin 路径而不是 large bin 路劲 。
    • 第一次调用 malloc 时,维护在 malloc_state 中的 small bin 和 large bin 将被初始化,它们都会指向自身以表示其为空;
    • 此后当 large bin 非空,如果相应 bin 中的最大 chunk 大小大于用户请求大小,分配器就从该 bin 顶端遍历到尾端,以找到一个大小最接近用户请求的 chunk。一旦找到,相应 chunk 就会被切分成两块:
      • User chunk(用户请求大小)—— 返回给用户;
      • Remainder chunk (剩余大小)—— 添加到 unsorted bin。
    • 如果相应 bin 中的最大 chunk 大小小于用户请求大小,分配器就会扫描 binmaps,从而查找最小非空 bin。如果找到了这样的 bin,就从中选择合适的 chunk 并切割给用户;反之就使用 top chunk 响应用户请求。
  • free(large chunk) —— 类似于 small chunk 。

6、申请和释放

6.1内存分配malloc流程

  1. 获取分配区的锁,防止多线程冲突。
  2. 计算出实际需要分配的内存的chunk实际大小。
  3. 判断chunk的大小,如果小于max_fast(64B),则尝试去fast bins上取适合的chunk,如果有则分配结束。否则,下一步;
  4. 判断chunk大小是否小于512B,如果是,则从small bins上去查找chunk,如果有合适的,则分配结束。否则下一步;
  5. ptmalloc首先会遍历fast bins中的chunk,将相邻的chunk进行合并,并链接到unsorted bin中然后遍历 unsorted bins。如果unsorted bins上只有一个chunk并且大于待分配的chunk,则进行切割,并且剩余的chunk继续扔回unsorted bins;如果unsorted bins上有大小和待分配chunk相等的,则返回,并从unsorted bins删除;如果unsorted bins中的某一chunk大小 属于small bins的范围,则放入small bins的头部;如果unsorted bins中的某一chunk大小 属于large bins的范围,则找到合适的位置放入。若未分配成功,转入下一步;
  6. 从large bins中查找找到合适的chunk之后,然后进行切割,一部分分配给用户,剩下的放入unsorted bin中。
  7. 如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了;当top chunk大小比用户所请求大小还大的时候,top chunk会分为两个部分:User chunk(用户请求大小)和Remainder chunk(剩余大小)。其中Remainder chunk成为新的top chunk;当top chunk大小小于用户所请求的大小时,top chunk就通过sbrk(main arena)或mmap(thread arena)系统调用来扩容。
  8. 到了这一步,说明 top chunk 也不能满足分配要求,所以,于是就有了两个选择: 如 果是主分配区,调用 sbrk(),增加 top chunk 大小;如果是非主分配区,调用 mmap 来分配一个新的 sub-heap,增加 top chunk 大小;或者使用 mmap()来直接分配。在 这里,需要依靠 chunk 的大小来决定到底使用哪种方法。判断所需分配的 chunk 大小是否大于等于 mmap 分配阈值,如果是的话,则转下一步,调用 mmap 分配, 否则跳到第 10 步,增加 top chunk 的大小。
  9. 使用 mmap 系统调用为程序的内存空间映射一块 chunk_size align 4kB 大小的空间。 然后将内存指针返回给用户。
  10. 判断是否为第一次调用 malloc,若是主分配区,则需要进行一次初始化工作,分配 一块大小为(chunk_size + 128KB) align 4KB 大小的空间作为初始的 heap。若已经初 始化过了,主分配区则调用 sbrk()增加 heap 空间,分主分配区则在 top chunk 中切 割出一个 chunk,使之满足分配需求,并将内存指针返回给用户。

简而言之: 获取分配区(arena)并加锁–> fast bin –>small bin –> unsorted bin –> large bin –> top chunk –> 扩展堆

6.2内存回收free流程

  1. 获取分配区的锁,保证线程安全。
  2. 如果free的是空指针,则返回,什么都不做。
  3. 判断当前chunk是否是mmap映射区域映射的内存,如果是,则直接munmap()释放这块内存。前面的已使用chunk的数据结构中,我们可以看到有M来标识是否是mmap映射的内存。
  4. 判断chunk是否与top chunk相邻,如果相邻,则直接和top chunk合并(和top chunk相邻相当于和分配区中的空闲内存块相邻)。转到步骤8
  5. 如果chunk的大小大于max_fast(64b),则放入unsorted bin,并且检查是否有合并,有合并情况并且和top chunk相邻,则转到步骤8;没有合并情况则free。
  6. 如果chunk的大小小于 max_fast(64b),则直接放入fast bin,fast bin并没有改变chunk的状态。没有合并情况,则free;有合并情况,转到步骤7
  7. 在fast bin,如果当前chunk的下一个chunk也是空闲的,则将这两个chunk合并,放入unsorted bin上面。合并后的大小如果大于64B,会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中,fast bin会变为空。合并后的chunk和topchunk相邻,则会合并到topchunk中。转到步骤8
  8. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。free结束。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值