深入理解堆:malloc和内存池是怎么回事?

431 阅读8分钟

本文为学习笔记,原文地址为time.geekbang.org/column/arti…

执行系统调用是要进入内核态的,运行态的切换会耗费不少时间。为了解决这个问题,人们倾向于使用系统调用来分配大块内存,然后再把这块内存分割成更小的块,以方便程序员使用,这样可以提升分配的效率。

malloc 的基本功能

示例:

#include <stdio.h>
#include <malloc.h>


int main() {
  void *p = malloc(16);
  printf("%p\n", p);
  free(p);
  return 0;
}

在 glibc 的实现里,malloc 函数在向操作系统申请堆内存时,会使用 mmap,以 4K 的整数倍一次申请多个页。这样的话,mmap 的区域就会以页对齐,页与页之间的排列非常整齐,避免了出现内存碎片。

malloc 的实现原理

内存的精细化管理,我们要考虑两个因素,一是分配和回收的效率,二是内存区域的有效利用率

对小块内存进行精细化管理,最常用的数据结构就是链表。为了能够方便地进行分配和回收,人们把空闲区域记录到链表里,这就是空闲链表 (free list)。

空闲链表

空闲链表里的节点主要是为了记录内存的开始位置和长度,如图所示:

image.png

当分配内存的请求到达以后,我们就通过遍历 free list 来查找可用的空闲内存区域,在找到合适的空闲区域以后,就将这一块区域从链表中摘下来。

在释放的时候,将这块区域按照起始起址的排序放回到链表里,并且检查它的前后是否有空闲区域,如果有就合并成一个更大的空闲区。这种算法所使用的数据结构比较简单,算法也很直接,我们把这种算法称为简单算法 (Naive Algorithm)。

简单算法举例:
算法开始时,内存是这样的

image.png

而经过如下代码后,就会变成最上面那张图的样子:

void test() {
  void* p1 = malloc(16);
  void* p2 = malloc(16);
  void* p3 = malloc(20);


  free(p2);


  void* p4 = malloc(16);
  void* p5 = malloc(16);


  free(p4);
}

不过,这三块 16 字节的空闲区域就是内存碎片。这就是我们所介绍的简单算法的第一个缺陷:会产生内存碎片。

每一次分配内存时,我们都需要遍历 free list,最差情况下的时间复杂度显然是 O(n)。如果是多线程同时分配的话,free list 会被多线程并发访问,为了保护它,就必须使用各种同步机制,比如锁或者无锁的 concurrent linked list 等。可见上述算法的第二个缺陷是分配效率一般,且多线程并发场景下性能还会恶化。

优化方案

其中一种方案是直接对简单算法进行优化。简单算法中找到第一个可用的区域就返回,这个策略被称为 First Fit,优化的具体做法是把它改成最佳匹配 (Best Fit),改造后,它要找到能满足条件的最小的空闲区域才返回。

从直观上说,这种分配策略能尽可能地保留大块内存,避免它被快速地分割成小块内存,这就能更好地对抗内存碎片。严格的理论证明也证明了这一点。但是这种策略需要遍历整个链表,时间复杂度反而变差。

另一种方案是 Knuth 提出的 Next Fit 策略,即每次查找不必从头开始,而是从上一次查找的位置继续向后查找。实验也证明,这种策略会比从头开始的算法有更高的效率。但它依然不能解决内存碎片的问题。

分桶式内存管理

分桶式内存管理采用了多个链表,对于单个链表,它内部的所有结点所对应的内存区域的大小是相同的。换句话说,相同大小的区域会挂载到同一个链表上。

最常见的方式是以 4 字节为最小单位,把所有 4 字节的区域挂到同一个链表上,再把 8 字节的区域挂到一起,然后是 16 字节,32 字节,这样以 2 次幂向上增长。

如图所示:

image.png 首先,分配的时候,我们要只要找到能满足这一次分配请求的最小区域,然后去相应的链表里把整块区域都取下来。

由于整个大块内存被提前分割成了整齐的小块(比如是以 4 字节对齐),所以整个区域里不存在块与块之间内存碎片。但是这种做法还是会产生区域内部的空间浪费

这就造成了一个字节的内部浪费,或者称之为内部碎片。(分配给进程的内存并没有全部用到,称为内部碎片。剩余未分配的连续的内存不足以再分配给进程,称为外部碎片)

分桶式内存管理比简单算法无论是在算法效率方面,还是在碎片控制方面都有很大的提升。但它的缺陷也很明显:区域内部的使用率不够高和动态扩展能力不够好。例如,4 字节的区域提前消耗完了,但 8 字节的空闲区域还有很多,此时就会面临两难选择,如果直接分配 8 字节的区域,则区域内部浪费就比较多,如果不分配,则明明还有空闲区域,却无法成功分配。

伙伴系统

为了避免分配失败,我们其实还可以考虑将大块的内存做一次拆分。

分配一块 4 字节大小的空间,在 4 字节的 free list 上找不到空闲区域,系统就会往上找,假如 8 字节和 16 字节的 free list 中也没有空闲区域,就会一直向上找到 32 字节的 free list。

image.png 伙伴系统不会直接把 32 的空闲区域分配出去,因为这样做的话,会带来巨大的浪费。它会先把 32 字节分成两个 16 字节,把后边一个挂入到 16 字节的 free list 中。然后继续拆分前一半。前一半继续拆成两个 8 字节,再把后一半挂入到 8 字节的 free list,最后,把前一半 8 字节拿去分配,当然这里也要继续拆分成两个 4 字节的空闲区域,其中一个用于本次 malloc 分配,另一个则挂入到 4 字节的 free list。

image.png 这种不断地把一块内存分割成更小的两块内存的做法,就是伙伴系统,这两块更小的内存就是伙伴。 它的好处是可以动态地根据分配请求将大的内存分割成小的内存。当释放内存时,如果系统发现与被释放的内存相邻的那个伙伴也是空闲的,就会把它们合并成一个更大的连续内存。通过这种拆分,系统就变得更加富有弹性。

malloc 的实现,在历史上先后共有几十种策略,这些策略往往就是上述三种算法的组合。具体到 glibc 中的 malloc 实现,它就采用了分桶的策略,但是它的每个桶里的内存不是固定大小的,而是采用了将 1 ~ 4 字节的块挂到第一个链表里,将 5 ~ 8 字节的块挂到第二个链表里,将 9~16 字节的块挂到第三个链表里,依次类推。

在单个链表内部则采用 naive 的分配方式,比如要分配 5 个字节的内存块,我们会先在 5 ~ 8 这个链表里查找,如果查找到的内存大小是 8 字节的,那就会将这个区域分割成 5 字节和 3 字节两个部分,其中 5 字节用于分配,剩余的 3 字节的空闲区域则会挂载到 1~4 这个链表里。

多线程优化

在多线程并发地分配内存时,每次分配都要对 free list 进行加锁以避免并发程序带来的问题,这就容易形成性能瓶颈。

为了解决这个问题,可以引入了线程本地缓存 (Thread Local Cache),每个线程在分配内存的时候都先在自己的本地缓存中寻找,如果找到就结束,只有找不到的情况才会继续向全局管理器申请一块大的空闲区域,然后按照伙伴系统的方式继续添加到本地缓存中去。

引用

本文内容来自极客时间《编程高手必学的内存知识》第09讲,原文链接为:time.geekbang.org/column/arti…