30 张图深度解读 glibc 内存管理,OOM 致千万损失背后真相揭秘

前言

大家好,我是雨乐。五年前,在上一家公司时,由于进程发生OOM(Out of Memory,内存溢出),致使公司遭受了上千万的损失。当时,我花费了一个月的时间深入剖析glibc源码,最终成功将问题彻底解决。

最近浏览知乎时,我发现不少人对malloc/free存在类似疑惑。恰好我曾研读这方面的源码,便把之前的源码阅读笔记整理了一番,耗时约三周撰写了这篇文章,旨在剖析glibc内存管理的精髓。相信它对C/C++从业者而言,具有一定的参考价值。

主要内容

1 写在前面

源码分析本就枯燥,若要将其转化为通俗易懂的文章,更是难上加难。本文竭力从读者视角出发进行分析,着重阐述大家关注的要点。必要时,会附上部分源码,以加深理解,力求借本文助力大家洞悉内存分配与释放的本质原理。接下来的内容干货满满,无论是对读者还是对我而言,都是一次收获之旅。文章将主要从内存布局、glibc内存管理、malloc实现以及free实现等方面,带您深入探究glibc内存管理的实质。最后,针对项目中出现的问题,给出切实可行的解决方案。

2 背景

几年前,我在上家公司参与了一个名为SeedService的项目。SeedService从kafka获取feed流的转发、评论、点赞信息,并加载至内存。鉴于每日数据各异,kafka的topic按天进行切分。整个server内部采用类似双指针的实现方式,每日零点释放前一天的数据。

项目上线初期,一切运行顺畅。然而,没过几天,进程却无故消失。我们随即着手定位问题,最终发现是内存暴增引发OOM,致使进程被操作系统强制终止。

查明进程消失的缘由后,我们开始排查内存泄漏问题。在解决了几个潜在的内存泄漏隐患后,却发现系统在高压力、高并发环境下长时间运行时,依旧会出现内存暴增现象,最终进程还是因OOM被操作系统杀掉。

由于内存管理主要涉及用户管理层、C运行时库层、操作系统层这三个层面,既然在操作系统层发现进程内存暴增,且已确认用户管理层不存在内存泄漏,于是我们怀疑是C运行时库,即Glibc的内存管理方式导致了进程内存暴增。

问题聚焦到glibc的内存管理方面,要解决SeedService进程消失的问题,必须厘清以下几个问题:

  • glibc在何种情况下不会将内存归还给操作系统?
  • glibc的内存管理方式存在哪些约束?适用于怎样的内存分配场景?
  • 我们系统中的内存管理方式是否与glibc内存管理的约束相冲突?
  • glibc究竟是如何管理内存的?

带着这些问题,我花费了将近一个月的时间深入研究glibc运行时库的内存管理代码。今天,我将当时的笔记整理出来,期望能对大家有所帮助。

3 基础

在Linux系统中,当装载elf格式的程序文件时,会调用loader将可执行文件中的各个段,依次载入到从某个特定地址起始的空间里。用户程序既可以直接运用系统调用来管理heap和mmap映射区域,不过在多数情况下,程序还是借助C语言提供的malloc()和free()函数,来动态地分配与释放内存。stack区域颇为特殊,它是唯一无需映射,用户却能够访问的内存区域,这也成为利用堆栈溢出进行攻击的根基。

3.1 进程内存布局

计算机系统分为32位与64位,32位和64位的进程布局有所不同。即便是同为32位的系统,其布局也会因内核版本而异。在详细介绍内存布局之前,我们先来明确几个关键概念:

  • 栈区(Stack):用于存储程序执行期间的本地变量以及函数参数,其生长方向是从高地址向低地址。
  • 堆区(Heap):这是动态内存分配的区域,通过malloc、new、free和delete等函数进行管理。
  • 未初始化变量区(BSS):存放未初始化的全局变量和静态变量。
  • 数据区(Data):存储在源代码中预先定义了值的全局变量和静态变量。
  • 代码区(Text):保存只读的程序执行代码,也就是机器指令。

3.1.1 32位进程内存布局

基于不同的内核版本,32位系统中进程内的布局存在差异。

3.1.1.1 经典布局

在Linux内核2.6.7之前,进程内存布局如下图所示。

32位默认布局

在这个内存布局示例图里,mmap区域与栈区域相向增长。这意味着堆仅有1GB的虚拟地址空间可供使用,若继续增长就会进入mmap映射区域,这显然并非我们所期望的。这是由于32位模式下地址空间的限制所致,因此内核引入了另一种虚拟地址空间的布局形式。不过对于64位系统而言,因其拥有巨大的虚拟地址空间,所以64位系统采用了这种布局方式。

3.1.1.2 默认布局

如前文所述,由于经典内存布局存在空间局限性,于是在内核2.6.7之后,引入了如下图所示的默认进程布局方式。

32位经典布局

从上图可以看出,栈自顶向下扩展,并且栈是有边界的。堆自底向上扩展,mmap映射区域自顶向下扩展,mmap映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域。这种结构方便C运行时库利用mmap映射区域和堆来进行内存分配。

3.1.2 64位进程内存布局

如前所述,64位进程内存布局方式因其地址空间充足,且实现相对简便,所以采用了与32位经典内存布局一致的方式,如下图所示:

64位进程布局

3.2 操作系统内存分配函数

在之前介绍内存布局时提到,heap和mmap映射区域是可供用户程序使用的虚拟内存空间。那么,我们该如何获取这两个区域的内存呢?操作系统提供了相应的系统调用来完成内存分配工作。

  • 针对heap的操作,操作系统提供了brk()函数,C运行时库则提供了sbrk()函数。
  • 对于mmap映射区域的操作,操作系统提供了mmap()和munmap()函数。

sbrk()、brk()或者mmap()都可用于为进程添加额外的虚拟内存。glibc正是借助这些函数向操作系统申请虚拟内存,以实现内存分配。

这里要着重提及一个极为重要的概念——内存的延迟分配。Linux内存管理的基本思想之一便是,只有在真正访问某个地址时,才会建立该地址的物理映射。Linux内核在用户申请内存时,仅仅分配一个线性区(即虚拟内存),并未分配实际物理内存;只有当用户使用这块内存时,内核才会分配具体的物理页面给用户,此时才会占用宝贵的物理内存。内核释放物理页面的过程,是通过释放线性区,找到其对应的物理页面,并将它们全部释放。

内存分配

进程的内存结构,在内核中用mm_struct来表示,其定义如下:

struct mm_struct {
  ...
  unsigned long (*get_unmapped_area) (struct file *filp,
  unsigned long addr, unsigned long len,
  unsigned long pgoff, unsigned long flags);
  ...
  unsigned long mmap_base; /* base of mmap area */
  unsigned long task_size; /* size of task vm space */
  ...
  unsigned long start_code, end_code, start_data, end_data;
  unsigned long start_brk, brk, start_stack;
  unsigned long arg_start, arg_end, env_start, env_end;
  ...
}

在上述mm_struct结构中:

  • [start_code,end_code)代表代码段的地址空间范围。
  • [start_data,end_start)代表数据段的地址空间范围。
  • [start_brk,brk)分别表示heap段的起始空间以及当前的heap指针。
  • [start_stack,end_stack)表示stack段的地址空间范围。
  • mmap_base表示memory mapping段的起始地址。

C语言的动态内存分配基本函数是malloc(),在Linux上的实现依赖于内核的brk系统调用。brk()是一个极为简单的系统调用,仅仅是改变mm_struct结构的成员变量brk的值。

mm_struct

3.2.1 Heap操作

前文提到,有两个函数可直接从堆(Heap)申请内存,其中brk()是系统调用,sbrk()是C库函数。

系统调用通常提供最基础的功能,而库函数相较于系统调用,能提供更复杂的功能。在glibc里,malloc通过调用sbrk()函数移动数据段的下界,以此来实现内存的分配与释放。sbrk()函数在内核的管控下,将虚拟地址空间映射到内存,供malloc()函数使用。

下面是brk()函数和sbrk()函数的声明:

#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

需要注意的是,当sbrk()的参数increment为0时,sbrk()返回的是进程当前的brk值;当increment为正数时,会扩展brk值;当increment为负值时,则会收缩brk值。

3.2.2 MMap操作

在Linux系统中,我们可以使用mmap在进程虚拟内存地址空间中分配地址空间,并创建与物理内存的映射关系。

共享内存

mmap()函数可将一个文件或其他对象映射到内存中。文件会被映射到多个页上,若文件大小并非所有页大小之和,最后一个页未使用的空间会被清零。

munmap则执行相反的操作,用于删除特定地址区域的对象映射。

函数定义如下:

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

映射关系主要分为以下两种:

  • 文件映射:将磁盘文件映射到进程的虚拟地址空间,使用文件内容初始化物理内存。
  • 匿名映射:初始化全为0的内存空间。

根据映射关系是否共享,又可分为:

  • 私有映射(MAP_PRIVATE):多进程间数据共享,但修改不会反映到磁盘实际文件中,采用写时复制(copy-on-write)的映射方式。
  • 共享映射(MAP_SHARED):多进程间数据共享,修改会反映到磁盘实际文件中。

综合来看,映射关系可总结为以下四种:

  • 私有文件映射:多个进程使用相同的物理内存页进行初始化,但各进程对内存文件的修改既不会共享,也不会反映到物理文件中。
  • 私有匿名映射:mmap会创建一个新的映射,各进程不共享,主要用于分配内存(malloc分配大内存时会调用mmap)。例如,开辟新进程时,会为每个进程分配虚拟地址空间,这些虚拟地址映射的物理内存空间在各进程读时共享,写时则进行写时复制。
  • 共享文件映射:多个进程通过虚拟内存技术共享相同的物理内存空间,对内存文件的修改会反映到实际物理文件中,这也是进程间通信(IPC)的一种机制。
  • 共享匿名映射:这种机制在进行fork时不会采用写时复制,父子进程完全共享相同的物理内存页,从而实现父子进程通信(IPC)。

值得注意的是,mmap只是在虚拟内存中分配了地址空间,只有在首次访问虚拟内存时才会分配物理内存。

在执行mmap之后,文件内容并未加载到物理页上,只是在虚拟内存中分配了地址空间。当进程访问这段地址时,通过查找页表,若发现虚拟内存对应的页未在物理内存中缓存,就会产生“缺页”情况,此时由内核的缺页异常处理程序进行处理,将文件对应内容以页为单位(4096)加载到物理内存。不过,实际加载情况可能会受操作系统的一些调度策略影响,加载的内容可能会比所需的多。

下面的内容是本文的重中之重,对于理解内存布局以及后续glibc的内存分配原理至关重要,如有必要,可多次阅读。

4 概述

前文提到,在堆上分配内存可使用brk()系统调用和sbrk() C运行时库函数,在内存映射区分配内存则可使用mmap函数。

现在假设一种情况:若每次分配内存都直接使用brk()、sbrk()或mmap()函数进行多次内存分配,且程序频繁地进行内存分配和释放操作,都直接与操作系统交互,那么其性能可想而知。

这就引出了“内存管理”这一概念。

本节大纲如下:

4.1 内存管理

内存管理是指软件运行时对计算机内存资源进行分配和使用的技术,其主要目的是高效、快速地分配内存,并在适当的时候释放和回收内存资源。

一个优秀的内存管理器应具备以下特点:

  1. 跨平台、可移植:通常情况下,内存管理器先向操作系统申请内存,再进行二次分配。因此,针对不同的操作系统,内存管理器需支持操作系统兼容,确保使用者在跨平台操作时无差异。
  2. 空间浪费小:若内存管理器管理内存时浪费空间较大,显然不能称之为优秀。常见的内存碎片是导致空间浪费的主要原因,若内存管理器中存在大量不连续的小块内存碎片,即便它们总量可能很大,但因无法使用,也不能算是优秀的内存管理器。
  3. 速度快:使用内存管理器的根本原因在于实现快速的内存分配和释放。
  4. 调试功能强大:对于C/C++程序员而言,内存错误堪称噩梦,上一次遭遇的内存错误想必仍历历在目。内存管理器提供的调试功能应强大且易用,特别是在嵌入式环境中,由于缺乏内存错误检测工具,内存管理器的调试功能就显得尤为重要。

4.2 管理方式

内存管理方式主要分为“手动管理”和“自动管理”两类。

手动管理,即使用者在申请内存时借助malloc等函数,释放内存时则需调用free函数。若已使用的内存未释放,便会引发内存泄漏,占用更多系统内存;要是在使用结束前就释放内存,会产生危险的悬挂指针,此时其他对象指向的内存已被系统回收或重新分配使用。

自动管理内存则由编程语言的内存管理系统自动运作,多数情况下无需使用者干预,能够自动释放不再使用的内存。

4.2.1 手动管理

手动管理内存是一种较为传统的方式。像C/C++这类系统级编程语言,狭义上并不具备自动内存管理机制,使用者必须主动申请和释放内存。

经验丰富的工程师能够精准把控内存的分配与释放时机。若人肉实施的内存管理策略足够精准,采用手动管理内存的方式不仅可提升程序运行性能,还能规避内存安全问题。

然而,现实中能做到如此精准的使用者毕竟是少数。只要涉及人工操作,就难免出错。内存泄漏和悬挂指针堪称C/C++这类语言中最常见的错误。手动内存管理还会耗费工程师大量精力,他们常常需要思索对象该分配到栈上还是堆上,以及堆上的内存何时释放,维护成本相对较高,这也是必须权衡的现实情况。

4.2.2 自动管理

自动管理内存几乎已成为现代编程语言的标配。由于内存管理模块的功能相对固定,因此可在编程语言的编译期或运行时引入自动内存管理方式。最常见的自动内存管理机制当属垃圾回收,此外,部分编程语言还会借助自动引用计数辅助内存管理。

自动内存管理机制能为工程师节省大量处理内存事务的时间,使其得以将全部精力聚焦于核心业务逻辑,从而提高开发效率。一般情况下,这种机制能够妥善解决内存泄漏和悬挂指针问题,但同时也会带来额外开销,对语言的运行时性能造成一定影响。

4.3 常见的内存管理器

  1. ptmalloc:ptmalloc是glibc(GNU Libc)中的一款内存分配器。如今在Linux环境下,我们所使用的运行库中内存分配(malloc/new)和释放(free/delete)功能便由它提供。
  2. BSD Malloc:BSD Malloc随4.2 BSD发行,包含于FreeBSD之中。该分配程序可从预先确定大小的对象池中分配对象。它设有一些对象大小的size类,这些对象的大小为2的若干次幂减去某一常数。所以,当请求特定大小的对象时,它会直接分配与之匹配的size类。这种方式实现速度快,但可能造成内存浪费。
  3. Hoard:编写Hoard的目的是让内存分配在多线程环境中能够高效进行。因此,其构造围绕锁的使用展开,确保所有进程无需等待内存分配。对于频繁进行内存分配和回收的多线程进程,它能显著提升运行速度。
  4. TCMalloc:这是Google开发的内存分配器,在众多项目中得以应用,例如Golang就采用了类似算法进行内存分配。它具备现代化内存分配器的基本特性:可有效对抗内存碎片,在多核处理器环境下性能良好。据说,其内存分配速度是glibc2.3中malloc实现速度的数倍。

5 glibc之内存管理(ptmalloc)

由于本次事故中使用的是运行库函数new/delete进行内存分配和释放,所以本文将重点剖析glibc下的内存分配库ptmalloc。

本节大纲如下:

在C/C++中,内存分配是在堆上进行的,那么在glibc里,堆是如何表示的呢?

我们先来看堆的结构声明:

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];

在上述堆的定义中,ar_ptr是指向分配区的指针,堆与堆之间以链表形式连接。后续我将详细阐述进程布局下堆的结构示意图。

在深入这部分内容之前,我们先了解一些关键概念。

5.1 分配区(arena)

ptmalloc通过一个个Arena对进程内存进行管理。

在ptmalloc中,分配区分为主分配区(arena)和非主分配区(narena),分配区由struct malloc_state表示。主分配区和非主分配区的区别在于:主分配区既能通过sbrk,也能通过mmap向操作系统申请内存,而非主分配区仅能借助mmap向操作系统申请内存。

当一个线程调用malloc申请内存时,该线程首先检查线程私有变量中是否已存在一个分配区。若存在,则对该分配区加锁,加锁成功便使用该分配区进行内存分配;若加锁失败,则搜索环形链表,寻找一个未加锁的分配区。倘若所有分配区都已加锁,malloc会开辟一个新的分配区,将其加入环形链表并加锁,然后用它来分配内存。释放操作同样需要获取锁才能执行。

需注意的是,非主分配区通过mmap向操作系统申请内存,每次申请64MB,一旦申请成功,该分配区便不会被释放。为避免资源浪费,ptmalloc对分配区的数量有所限制。

对于32位系统,分配区最大个数 = 2 * CPU核数 + 1 对于64位系统,分配区最大个数 = 8 * CPU核数 + 1

堆管理结构如下:

struct malloc_state {
    mutex_t mutex;                 /* Serialize access. */
    int flags;                       /* Flags (formerly in max_fast). */
    #if THREAD_STATS
    /* Statistics for locking. Only used if THREAD_STATS is defined. */
    long stat_lock_direct, stat_lock_loop, stat_lock_wait;
    #endif
    mfastbinptr fastbins[NFASTBINS];    /* Fastbins */
    mchunkptr top;
    mchunkptr last_remainder;
    mchunkptr bins[NBINS * 2];
    unsigned int binmap[BINMAPSIZE];   /* Bitmap of bins */
    struct malloc_state *next;           /* Linked list */
    INTERNAL_SIZE_T system_mem;
    INTERNAL_SIZE_T max_system_mem;
};

malloc_state

每个进程仅有一个主分配区,以及若干个非主分配区。主分配区由主线程或第一个线程创建并持有。主分配区和非主分配区通过环形链表连接。分配区内有一个mutex变量,用于支持多线程访问。

环形链表链接的分配区

前文提到,每个分配区中都有一个mutex变量支持多线程访问。每个线程必定对应一个分配区,但一个分配区可供多个线程使用。同时,一个分配区可由一个或多个堆组成,同一分配区下的堆以链表方式连接,它们之间的关系如下图所示:

线程-分配区-堆

进程的动态内存由分配区管理,一个进程内有多个分配区,一个分配区又包含多个堆,由此构成了复杂的进程内存管理结构。

这里有几个要点需要注意:

  • 主分配区通过brk进行内存分配,非主分配区通过mmap进行内存分配。
  • 非主分配区虽使用mmap分配内存,但与大于128K直接使用mmap分配内存毫无关联。大于128K的内存使用mmap分配后,使用完毕直接通过ummap归还给系统。
  • 每个线程在执行malloc操作时,会先获取一个area,使用area内存池分配自身内存,这一过程存在竞争问题。
  • 为避免竞争,我们可采用线程局部存储(thread cache,tcmalloc中的tc即为此意)。线程局部存储对area的改进原理如下:
    • 若要在一个线程内部的各个函数调用中都能访问、但其他线程无法访问的变量(即线程局部静态变量),就需要新的机制来实现,这便是TLS。
    • thread cache本质上是在static区为每个thread开辟一个专属空间,因其专属,不再存在竞争问题。
    • 每次执行malloc时,先在线程局部存储空间中查找area,使用thread cache中的area分配存储在thread area中的chunk。当空间不足时,才去堆区查找area。

5.2 chunk

ptmalloc借助malloc_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;
};
  • prev_size:若前一个chunk处于空闲状态,此域代表前一个chunk的大小;若前一个chunk正在使用,该域无实际意义。 一段连续的内存会被划分成多个chunk,prev_size记录的就是相邻前一个chunk的size。已知当前chunk的地址,减去prev_size就能得到前一个chunk的地址。prev_size主要用于合并相邻的空闲chunk。
  • 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_nextsize指向前一个比当前chunk小的首个空闲chunk (同一大小的chunk可能有多个,在总体大小有序的情况下,要找到下一个比自己大或小的chunk,若遍历所有相同大小的chunk效率较低,因此才有fd_nextsize和bk_nextsize这样的设计)。若该chunk块被分配给应用程序使用,这两个指针同样不再有用(该chunk块已从size链中移除),其占用空间也可被应用程序使用,避免浪费。

如上述描述,在ptmalloc中,为最大程度节省内存,正在使用的chunk与未使用的chunk在结构上存在差异。

非空闲chunk

在上图中:

  • chunk指针指向chunk起始地址。
  • mem指针指向用户内存块起始地址。
  • p = 0时,表明前一个chunk为空闲状态,prev_size有效。
  • p = 1时,表明前一个chunk正在被使用,prev_size无效。p主要用于内存块的合并操作;ptmalloc分配的首个块,总是将p设为1,防止程序访问到不存在的区域。
  • M = 1表示从mmap映射区域分配;M = 0表示从heap区域分配。
  • A = 0表示从主分配区分配;A = 1表示从非主分配区分配。

相较于非空闲chunk,空闲chunk在用户区域多了四个指针,分别是fd、bk、fd_nextsize、bk_nextsize,这些指针的含义前文已作解释,在此不再重复。

空闲chunk

5.3 空闲链表(bins)

用户调用free函数释放内存时,ptmalloc不会立刻将其归还给操作系统,而是将其存入空闲链表(bins)。如此一来,下次调用malloc函数申请内存时,便能直接从bins中取出一块返回,避免频繁调用系统调用函数,从而降低内存分配开销。

在ptmalloc中,会把大小相近的chunk链接起来,形成bin。ptmalloc总共使用128个bin。

依据chunk的大小,ptmalloc将bin分为以下几类:

  • fast bin
  • unsorted bin
  • small bin
  • large bin

从前面malloc_state结构定义来看,对bin分类后,可分为fast bin和bins,其中unsorted bin、small bin以及large bin都属于bins。

在glibc中,上述4种bin的数量各不相同,如下图所示:

bin

5.3.1 fast bin

程序运行过程中,常常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小chunk后,可能很快又会收到小块内存的请求,此时分配器又得从大的空闲内存中切分出一块,这种操作效率较低。因此,malloc在分配过程中引入了fast bins。

在前面malloc_state定义中:

mfastbinptr fastbins[NFASTBINS]; // NFASTBINS  = 10
  1. fast bin的数量为10个。
  2. 每个fast bin都是一个单链表(仅使用fd指针)。这是因为对fast bin中的chunk进行添加或移除操作,都在链表尾部进行。也就是说,对fast bin中chunk的操作采用LIFO(后入先出)算法:释放内存(free)操作,是将新的fast chunk添加到链表尾部;分配内存(malloc)操作,是删除链表尾部的fast chunk。
  3. chunk size:10个fast bin中包含的chunk size以8字节为单位逐渐递增。即第一个fast bin中的chunk size均为16字节,第二个fast bin的chunk size为24字节,依此类推,最后一个fast bin的chunk size为80字节。
  4. 不会对free chunk执行合并操作。这是由于fast bin的设计初衷是实现小内存的快速分配和释放。所以,系统会将属于fast bin的chunk的P(未使用标志位)始终设置为1。这样,即便fast bin中有某个chunk与一个free chunk相邻,系统也不会自动进行合并操作,而是保留两者。
  5. malloc操作:执行malloc时,如果申请的内存大小在fast bin的范围内,会先在fast bin中查找,若找到则直接返回。否则,会从small bin、unsorted bin以及large bin中查找。
  6. free操作:首先通过chunksize函数,依据传入的地址指针获取该指针对应chunk的大小;接着根据这个chunk大小,确定该chunk所属的fast bin,随后将此chunk添加到该fast bin的链尾即可。

下面是fastbin结构图:

fastbin

5.3.2 unsorted bin

unsorted bin的队列使用bins数组的第一个位置,它是bins的一个缓冲区,用于加快分配速度。当用户释放的内存大于max_fast,或者fast bins合并后的chunk,都会首先进入unsorted bin。

在unsorted bin中,对chunk的size没有限制,任何大小的chunk都能放入其中。这主要是为了让“glibc malloc机制”有第二次机会重新利用最近释放的chunk(第一次机会是fast bin机制)。借助unsorted bin,能够加快内存的分配和释放操作,因为整个过程无需再花费额外时间查找合适的bin。用户执行malloc时,如果在fast bins中未找到合适的chunk,malloc会先在unsorted bin中查找合适的空闲chunk。若未找到合适的bin,ptmalloc会将unsorted bin上的chunk放入bins中,然后在bins中查找合适的空闲chunk。

与fast bin不同,unsorted bin采用的遍历顺序是FIFO(先进先出)。

unsorted bin结构图如下:

unsorted bin

5.3.3 small bin

大小小于512字节的chunk被称为small chunk,用于保存small chunks的bin被称为small bin。数组从2开始编号,前62个bin为small bins,相邻两个small bin之间的chunk size相差8个字节,且同一个small bin中的chunk大小相同。每个small bin都包含一个空闲区块的双向循环链表(也称为binlist)。释放的chunk会添加到链表前端,而分配所需的chunk则从链表后端摘除。两个相邻的空闲chunk会被合并成一个空闲chunk。合并操作能减少碎片化的影响,但会降低free的速度。进行分配时,当samll bin不为空,相应的bin会摘除binlist中最后一个chunk并返回给用户。

在释放一个chunk时,会检查其前或后的chunk是否空闲,若空闲则进行合并。也就是将它们从所属链表中摘除,并合并成一个新的chunk,新chunk会添加到unsorted bin链表的前端。

small bin同样采用FIFO算法,即内存释放操作是将新释放的chunk添加到链表前端(front end),分配操作则是从链表后端(rear end)获取chunk。

small bin

5.3.4 large bin

大小大于等于512字节的chunk被称为large chunk,用于保存large chunks的bin被称为large bin,位于small bins之后。large bins中的每个bin分别包含一定范围内大小的chunk,其中的chunk按大小递减排序,若大小相同,则按最近使用时间排列。

两个相邻的空闲chunk会被合并成一个空闲chunk。

small bins的策略非常适用于小内存分配,但我们无法为每个可能的块大小都设置一个bin。对于超过512字节(64位系统为1024字节)的块,堆管理器采用“large bin”。

63个large bin中的每一个,其操作方式与small bin大致相似,但不是存储固定大小的块,而是存储一定大小范围内的块。每个large bin的大小范围,设计为不与small bin的块大小或其他large bin的范围重叠。换言之,给定一个块的大小,它只会对应一个small bin或large bin。

在这63个large bins中:第一组的32个large bin链,依次以64字节为步长间隔,即第一个large bin链中chunk size为1024 - 1087字节,第二个large bin中chunk size为1088 ~ 1151字节。第二组的16个large bin链,依次以512字节为步长间隔;第三组的8个large bin链,以4096字节为步长间隔;第四组的4个large bin链,以32768字节为步长间隔;第五组的2个large bin链,以262144字节为步长间隔;最后一组的large bin链中,chunk大小无限制。

进行malloc操作时,首先确定用户请求的大小属于哪个large bin,然后判断该large bin中最大的chunk的size是否大于用户请求的size。若大于,从尾端开始遍历该large bin,找到第一个size相等或最接近的chunk,分配给用户。若该chunk大于用户请求的size,会将该chunk拆分为两个chunk:前者返回给用户,size与用户请求的size相同;剩余部分作为一个新的chunk添加到unsorted bin中。

若该large bin中最大的chunk的size小于用户请求的size,那么依次查看后续的large bin中是否有满足需求的chunk。需要注意的是,由于bin的数量较多(不同bin中的chunk可能位于不同内存页),若按上述方法遍历(即遍历每个bin中的chunk),可能会引发多次内存页中断操作,严重影响检索速度。因此,glibc malloc设计了Binmap结构体,用于提升bin - by - bin检索的速度。Binmap记录了各个bin是否为空,借助bitmap可避免检索空的bin。若通过binmap找到下一个非空的large bin,就按上述方法分配chunk;否则,使用top chunk(后续会介绍)分配合适的内存。

large bin的free操作与small bin一致,在此不再赘述。

large bin

上述几种bin,构成了进程中最核心的分配部分:bins,如下图所示:

bins结构

5.4 特殊chunk

上一节介绍了几种bin及其内存分配和释放的特点。然而,仅靠上述几种bin还无法满足所有情况。当这些bins不能满足分配条件时,glibc引入了另外几种特殊的chunk用于分配和释放,分别是top chunk、mmaped chunk和last remainder chunk。

5.4.1 top trunk

top chunk位于堆的顶部,不属于任何bin。当所有的bin都无法满足分配请求时,便会从这块区域进行分配。分配出的空间返回给用户,剩余部分形成新的top chunk。若top chunk的空间也无法满足用户请求,就需要使用brk或者mmap向系统申请更多堆空间(主分配区使用brk、sbrk,非主分配区使用mmap)。

在释放chunk时,如果chunk size不在fastbin的范围内,就需要判断其是否与top chunk相邻。若相邻,则将其合并到top chunk中。

5.4.2 mmaped chunk

当分配的内存非常大(大于分配阈值,默认128K)时,需要通过mmap映射,此时会将其放入mmaped chunk。释放mmaped chunk上的内存时,会直接归还给操作系统(chunk中的M标志位置1)。

5.4.3 last remainder chunk

Last remainder chunk是另一种特殊的chunk,它被维护在unsorted bin中。

如果用户申请的size属于small bin范围,但无法精确匹配时,会采用最佳匹配方式(例如申请128字节,对应的bin为空,只有256字节的bin非空,此时就从256字节的bin上进行分配)。这样会将chunk分割成两部分,一部分返回给用户,另一部分形成last remainder chunk,插入到unsorted bin中。

当需要分配一个small chunk,但在small bins中找不到合适的chunk时,如果last remainder chunk的大小大于所需的small chunk大小,last remainder chunk会被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk成为新的last remainder chunk。

last remainder chunk主要通过提高内存分配的局部性,来提升连续malloc(产生大量small chunk)的效率。

5.5 chunk切分

当chunk释放且其长度不在fastbins范围内时,会合并前后相邻的chunk。

若首次分配的长度在large bin范围内,且fast bins中有空闲chunk,则将fastbins中的chunk与相邻的空闲chunk进行合并,然后将合并后的chunk放入unsorted bin中。若fastbin中的chunk相邻的chunk并非空闲,无法合并,仍将该chunk放入unsorted bin中,即能合并就合并,但最终都会放入unsorted bin。

若fastbins和small bin中都没有合适的chunk,且top chunk的长度也无法满足需求,则对fast bin中的chunk进行合并。

5.6 chunk合并

前面提到相邻的chunk可以合并成一个大的chunk,反之,一个大的chunk也能分裂成两个小的chunk。chunk的分裂过程与从top chunk中分配新的chunk是一样的。需要注意的是:分裂后的两个chunk,其长度都必须大于chunk的最小长度(对于64位系统是32字节),即要确保分裂后的两个chunk仍可被分配使用,否则就不进行分裂,而是将整个chunk返回给用户。

6 内存分配(malloc)

glibc运行时库通过底层的malloc函数实现动态内存分配(new最终也是调用malloc),下面是malloc函数调用流程图:

malloc

在此,将上述流程图以文字形式呈现,方便大家理解:

  1. 获取分配区的锁:为防止多个线程同时访问同一个分配区,在分配内存前需获取分配区域的锁。线程先检查线程私有实例中是否已存在一个分配区。若存在,尝试对该分配区加锁。若加锁成功,使用该分配区分配内存;否则,该线程搜索分配区循环链表,试图获取一个空闲(未加锁)的分配区。若所有分配区都已加锁,ptmalloc会开辟一个新的分配区,将其加入全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。新开辟的分配区一定是非主分配区,因为主分配区是从父进程继承而来。开辟非主分配区时会调用mmap()创建一个sub - heap,并设置好top chunk。
  2. 转换请求大小:将用户的请求大小转换为实际需要分配的chunk空间大小。
  3. 判断是否为小尺寸请求:判断所需分配chunk的大小是否满足chunk_size <= max_fast(max_fast默认为64B)。如果满足,则进入下一步;否则跳到第5步。
  4. fast bins分配尝试:首先尝试从fast bins中获取一个所需大小的chunk分配给用户。若能找到,则分配结束;否则进入下一步。
  5. 判断是否为small bins范围:判断所需大小是否在small bins范围内,即判断chunk_size < 512B是否成立。若chunk大小在small bins范围内,则进入下一步;否则转到第6步。
  6. small bins分配:根据所需分配的chunk大小,找到对应的某个small bin,从该bin的尾部摘取一个恰好满足大小的chunk。若成功,则分配结束;否则,进入下一步。
  7. 处理大内存或无合适small bin的情况:走到这一步,说明需要分配大内存,或者在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中,遍历完成后,进入下一步。
  8. large bins分配尝试:走到这一步,说明需要分配大内存,或者在small bins和unsorted bin中都找不到合适的chunk,并且fast bins和unsorted bin中所有的chunk都已处理完毕。从large bins中按照“smallest - first,best - fit”原则,寻找一个合适的chunk,从中划分出一块所需大小的chunk,并将剩下的部分链接回到bins中。若操作成功,则分配结束;否则进入下一步。
  9. top chunk分配尝试:如果在fast bins和bins中都未找到合适的chunk,那么就需要操作top chunk来进行分配。判断top chunk大小是否满足所需chunk的大小。如果满足,则从top chunk中分出一块;否则进入下一步。
  10. 处理top chunk无法满足的情况:走到这一步,说明top chunk也不能满足分配要求,此时有两个选择:如果是主分配区,调用sbrk(),增加top chunk大小;如果是非主分配区,调用mmap来分配一个新的sub - heap,增加top chunk大小;或者使用mmap()来直接分配。这里需要依据chunk的大小决定采用哪种方法。判断所需分配的chunk大小是否大于等于mmap分配阈值。如果是,则进入下一步,调用mmap分配;否则跳到第12步,增加top chunk的大小。
  11. mmap分配:使用mmap系统调用为程序的内存空间映射一块chunk_size align 4kB大小的空间,然后将内存指针返回给用户。
  12. 主分配区初始化或增加heap空间:判断是否为第一次调用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,以满足分配要求。

当然,glibc中malloc的分配机制远比上述过程复杂得多,需要考虑各种情况,比如指针异常越界等。将这些判断条件加入流程图后,如下图所示:

malloc(需要高清大图,留言区留言或者私信我)

7 内存释放(free)

在内存管理中,malloc用于内存分配,与之对应的free函数则负责内存释放。下面是free函数的基本流程图:

free

对上述流程图的详细描述如下:

  1. 获取分配区锁:获取分配区的锁,以此确保多线程环境下内存释放操作的安全性,避免多个线程同时操作同一内存区域引发的冲突。
  2. 空指针判断:如果free函数传入的是一个空指针,那么直接返回,不进行任何内存释放操作。这是因为对空指针进行释放操作是无意义的,且在C/C++中是合法的操作,不会引发程序错误。
  3. mmap映射内存判断:判断当前chunk是否是通过mmap映射区域获取的内存。在之前介绍的已使用chunk的数据结构中,有一个标识位M用于表示该chunk是否为mmap映射的内存。如果是mmap映射的内存,则直接调用munmap()函数释放这块内存。
  4. 与top chunk相邻判断:判断chunk是否与top chunk相邻。若相邻,意味着该chunk与分配区中的空闲内存块相邻,此时直接将其与top chunk合并。若不相邻,则转到步骤8继续后续处理。
  5. 大于max_fast处理:如果chunk的大小大于max_fast(通常为64字节),则将其放入unsorted bin。放入后,检查是否存在合并情况。若有合并情况且合并后的chunk与top chunk相邻,则转到步骤8;若没有合并情况,则完成free操作。
  6. 小于max_fast处理:如果chunk的大小小于max_fast(64字节),则直接将其放入fast bin。fast bin在处理这类chunk时,不会改变其状态。若没有合并情况,则完成free操作;若有合并情况,则转到步骤7。
  7. fast bin内合并处理:在fast bin中,如果当前chunk的下一个chunk也是空闲状态,那么将这两个chunk合并,并把合并后的chunk放入unsorted bin。若合并后的大小大于64字节,会触发fast bins的合并操作。此时,fast bins中的所有chunk将被遍历,每个chunk都会与相邻的空闲chunk进行合并,合并后的chunk会被统一放到unsorted bin中,fast bin则会变为空。如果合并后的chunk与topchunk相邻,那么会将其合并到topchunk中。完成这些操作后,转到步骤8。
  8. top chunk大小判断:判断top chunk的大小是否大于mmap收缩阈值(默认值为128KB)。如果大于该阈值,对于主分配区,系统会尝试归还top chunk中的一部分内存给操作系统。完成这一步后,free操作结束。

如果将free函数内部各种复杂的条件判断都加入进去,那么free调用的详细流程图如下:

free详细流程

8 问题分析以及解决

通过前面深入分析glibc运行时库的内存管理机制,我们基本可以定位出项目中出现问题的原因。在项目中,虽然调用了free函数进行内存释放,但实际上只是将内存返还给了glibc库,而glibc库并没有及时将这些内存归还给操作系统。随着程序的运行,这种情况不断积累,最终导致系统内存被耗尽,程序因OOM(Out of Memory,内存溢出)错误被系统强制终止。

针对这一问题,有以下几种可行的解决方案:

  • 禁用ptmalloc的mmap分配阈值动态调整机制:通过mallopt()函数设置M_TRIM_THRESHOLD、M_MMAP_THRESHOLD、M_TOP_PAD和M_MMAP_MAX中的任意一个参数,来关闭mmap分配阈值动态调整机制。同时,需要将mmap分配阈值设置为64K。这样一来,大于64K的内存分配都将直接使用mmap向系统申请,释放大于64K的内存时也会调用munmap将其释放回系统。不过,这种方案存在一定的缺点,每次内存分配和申请都直接与操作系统交互,会导致效率降低。
  • 预估程序使用内存并配置系统参数:预估程序能够使用的最大物理内存大小,然后通过配置系统的/proc/sys/vm/overcommit_memory、/proc/sys/vm/overcommit_ratio参数,以及使用ulimt -v限制程序可使用的虚拟内存空间大小,以此来防止程序因OOM错误被杀掉。然而,这种方案也并非完美,若预估的内存大小小于进程实际占用的内存,那么仍然可能出现OOM问题,导致进程被杀掉。
  • 使用tcmalloc:最终,项目采用tcmalloc成功解决了内存问题。后续如果有机会,会专门撰写一篇关于tcmalloc内存管理的文章,深入探讨其原理和应用。

9 结语

在业界有这样一种说法,是否深入了解内存管理机制,是区分C/C++程序员与其他高级语言程序员的重要标志之一。在C/C++编程中,指针及动态内存管理是极为重要的特性,它们为编程带来了极大的灵活性,但同时也给开发人员带来了诸多挑战和困扰。

深入了解底层内存的实现原理,往往能在实际开发中带来意想不到的效果,帮助我们更高效地解决问题,编写出更健壮的程序。

本次关于glibc内存管理的内容就先介绍到这里。

10 参考

  1. https://sourceware.org/glibc/wiki/MallocInternals
  2. https://titanwolf.org/Network/Articles/Article?AID=99524c69-cb90-4d61-bb28-01c0864d0ccc
  3. https://blog.fearcat.in/a?ID=00100-535db575-0d98-4287-92b6-4d7d9604b216
  4. https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/
  5. https://sploitfun.wordpress.com/2015/02/11/syscalls-used-by-malloc

原文阅读

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件举报,一经查实,本站将立刻删除。

文章由技术书栈整理,本文链接:https://study.disign.me/article/202511/10.glibc-memory-allocation-principle.md

发布时间: 2025-03-12