前言

当我们谈论程序性能时,总绕不开“内存”这个话题。内存管理如同程序的血脉系统,决定着程序的效率、稳定性和资源利用率。我们对内存的理解不能只停留在malloc/free、new/delete的简单调用层面,更要对其背后的机制以及设计缘由有所理解。

这是“内存分配与管理”系列的第一篇文章。我们将从最基础、也最重要的malloc开始,逐层揭开内存管理的神秘面纱。malloc不仅仅是C语言中的一个库函数,更是理解现代操作系统内存分配思想的窗口——它的设计哲学、实现策略、以及效率与通用性的权衡,构成了整个内存生态的基础。

在具体说malloc之前,我们先分清楚几个常见的概念:allocator、malloc和ptmalloc:malloc理解为分配内存的接口,allocator是一个负责内存分配和回收的具体策略实现。在Linux + glibc默认环境下,通过ptmalloc策略来实现(ptmalloc是一个名称);Google实现了tcmalloc策略,微软实现了mimalloc….

arena

ptmalloc针对堆管理,最顶层的管理结构为malloc_state,即arena

arena(分配器实例)
  └── heap(一段连续内存区域)
        └── chunk(用户分配单位)

struct malloc_state
{
  // 本arena的锁
  mutex_t mutex;

  /* Flags (formerly in max_fast).  */
  int flags;

  /* Fastbins */
  mfastbinptr fastbinsY[NFASTBINS];

  //当前arena中最高地址的、尚未分配的连续内存块
  mchunkptr top;

  /* The remainder from the most recent split of a small request */
  mchunkptr last_remainder;

 // small/large/unsorted bins
  mchunkptr bins[NBINS * 2 - 2];

  // Bitmap of bins 
  unsigned int binmap[BINMAPSIZE];

  // 链表连接所有arena
  struct malloc_state *next;

  /* Linked list for free arenas.  */
  struct malloc_state *next_free;

  // 本arena从系统分配的总内存
  INTERNAL_SIZE_T system_mem;
  INTERNAL_SIZE_T max_system_mem;
};

【arena是什么?】

每个线程都会绑定一个arena,其代表着一整套完整的malloc管理体系,独立着管理多个heap(非主arena的heap来自于多个mmap区域),这些heap通过链表相连,共用一套bins,而arena通过top指针指向当前正在被使用的heap

arena
  ├─ fastbins
  ├─ smallbins
  ├─ largebins
  ├─ unsorted bin
  ├─ top chunk
  └─ mutex

【arena的类别有哪些?】

  • main arena:即对应到进程的初始堆,可通过brk/sbrk系统调用扩展堆大小,即来自进程对应的“堆”,每个进程有且只有一个main arena。

  • thread arena:即每个线程独有的arena(不超过数量限制的前提下),需要通过mmap单独映射内存以扩展heap,多个内存块是离散的

为什么不用brk给所有arena扩展?

brk只能线性增长,但是多线程情况下,需要多块独立的内存,可以分别独立扩展、独立释放。如果线程被回收了,则线程申请的内存同样需要被回收,而对于进程地址空间下的heap来说,其无法做到这一点,必须从top开始释放内存,而通过mmap映射的内存可以通过unmmap单独释放

多线程程序中可能有多个heap VMA是什么意思?不是所有线程共享同一个heap吗?

准确来说,多线程程序中可能存在“多个由mmap 创建的 heap-like VMA” 指的是,多线程中只能使用mmap作为动态内存的分配来源,这些mmap VMA位于虚拟地址空间中的文件和匿名映射区,其不属于进程的heap,但是在线程眼中被当作heap使用。此时,就会存在多个匿名mmap VMA,它们分别属于不同的arena,但是仍然属于同一个进程地址空间中

一个多线程程序的内存分配情况可能如下所示:

[ text ]
[ data ]
[ brk heap ]      ← main_arena
[ mmap arena 1 ]  ← thread_arena1
[ mmap arena 2 ]  ← thread_arena2
[ mmap arena 3 ]
[ stack thread1 ]
[ stack thread2 ]


线程1 (绑定主arena)             线程2 (绑定arena1)             线程3 (绑定arena2)
      |                               |                               |
      v                               v                               v
  main_arena                    malloc_state1                   malloc_state2
  +-------------+               +-------------+               +-------------+
  | mutex       |               | mutex       |               | mutex       |
  | fastbinsY[] |               | fastbinsY[] |               | fastbinsY[] |
  | top ------------> [heap]    | top ------------> heap1    | top ------------> heap2
  | bins[]      |    +------+   | bins[]      |    +------+  | bins[]      |    +------+
  | ...         |    | chunk|   | ...         |    | chunk|  | ...         |    | chunk|
  | system_mem  |    | chunk|   | system_mem  |    | chunk|  | system_mem  |    | chunk|
  +-------------+    | ...  |   +-------------+    | ...  |  +-------------+    | ...  |
                     +------+                      +------+                     +------+
                       brk堆                          mmap区域                    mmap区域

我们知道了arena是什么,以及如何获取内存,那么arena是怎么和线程交互的?

  • 如果线程第一次分配内存:

    • 对于第一个分配内存的线程:使用main arena

    • 对于其他线程:会遍历已有的arena,尝试找到一个未被其他线程使用的arena绑定(即锁是空闲状态),若所有arena都被占用,则会创建新的arena。arena默认数量上限为8*CPU核数,每个线程都会尝试绑定一个独有的arena,如果arena数量达到上限,则多个线程会共享同一个arena

  • 如果线程后续需要分配内存:则优先复用先前使用过的arena,如果其已被其他线程占用,则去尝试使用其他的arena,而不是干等着。这是为了提高内存利用效率,防止明明一个进程分配到了大量内存资源,但线程依然无法获取

【为什么要有arena?其优点和缺点是什么?】

arena的主要设计目的是为了减少多线程下分配内存的锁竞争。 如果所有线程的malloc/free都需要依赖于同一个堆结构,那么每次分配/释放内存需要抢同一把锁,性能会非常低下。

其缺点在于,在多线程下每个线程都有自己的arena,A线程中的空闲块无法被B线程使用,可能导致明明分配给了该进程较多内存,但是依然不够用的情况,这就会导致内存膨胀,整体的内存利用率下降。

内存池内存管理机制

为什么堆要用内存池管理内存?而栈不需要?

从操作系统资源粒度来看:操作系统只提供了“页级别”的粗粒度管理,内核负责建立VMA、建立页表映射关系、分配物理页框,整个过程都是以页为基本单位的。但是用户程序却需要几十几十字节的内存,对于这种远小于4KB页大小的请求,如果没有allocator作为中间层负责页内细粒度的分配,那么会导致内部碎片严重、内存利用率极低。

从生命周期控制来看:堆内存的释放允许任意顺序,生命周期不可预测。如果没有allocator,无法记录哪些内存块是空闲/已用的。相比于栈,栈内存的分配是严格按照LIFO——后分配、先释放的,生命周期相互嵌套,出作用域后直接释放,不存在该问题

从碎片控制的角度来看:由于堆内存的分配在空间上不是连续的,这就会可能出现外部碎片问题,如果没有allocator,就无法合并、切割,否则很快就无法分配大块内存区域

从系统调用申请内存的角度来看:brk()/sbrk()/mmap()属于系统调用,如果每次申请内存都要调用这三个系统调用的其中之一,触发用户态到内核态的切换,是非常影响性能的。相比之下,栈的内存分配只需要通过移动栈指针即可

内存池具体管理方法

如何描述堆虚拟内存

隐式链表技术

malloc将整个堆内存空间分为连续的、大小不一的chunk,chunk即为堆内存的最小管理单位。

为了区分不同的内存块的信息,比如已分配、未分配……会将这些信息直接嵌入到chunk内部

堆内存要求chunk的大小必须是8的整数倍,因此chunk size的后三位是无效的,为了充分利用内存,就将这三位作为chunk的标志位。

而padding部分则是用于内存对齐,让整个chunk为8的整数倍

allocated chunk:

free chunk:

此时,整个堆内存组织为一个连续的,已分配或未分配的chunk序列

这种结构叫做隐式链表,我们只要拿到堆的起始地址,就可以根据每个chunk中存储的size信息,遍历所有chunk,以找到合适的chunk进行分配

缺点:遍历效率低,难以进行不同chunk之间的合并,久而久之,就会导致堆内存被分割为许多内存碎片。为什么不能合并?假设下面这样的场景,从上到下有3个内存块ABC,假设释放B内存:如果想和C合并,只需要让B内存的起始地址加上记录的chunk size即可得到C内存的起始地址,根据C的标志位即可判断是否可以合并;但是想和A合并较为困难,因为我们没有办法找到上一个内存块(高地址),无法判断,最坏情况下必须遍历整个堆才可以将AB合并

带边界标记的合并技术

Kunth在每个chunk的最后添加一个和header的副本——footer,此时,只要拿到了一个chunk的起始地址,将该地址+4就可以获得到高地址的chunk的信息了

问题:只有free chunk才需要footer,allocated chunk并不需要,此时footer对于allocated chunk是无用的

优化:用第1位,表示前一个chunk是allocated还是free。如果为1,则代表前一个chunk为allocated,当前chunk地址的前4个字节为前一个allocated chunk的payload或padding,此时,allocated chunk就算不保留footer也不会导致相邻的free chunk访问自身出错;如果为0,则代表前一个chunk为free,则将二者合并

free chunk:

支持多线程

随着技术的发展,堆内存管理器需要添加对堆多线程的支持,可是,此时chunk的标志位已经不够用了,我们还需要两个标志位——一个用于判断当前chunk是否属于主线程,另一个判断当前chunk是由mmap得来的还是由brk得到的。

思考先前chunk的缺点:是否有必要在一个chunk中,同时保存前一个chunk的已分配/未分配标志位和当前chunk的已分配/未分配标志位?没必要,只保存前一个chunk的已分配/未分配标志位就够了,对于当前chunk是否已经分配,完全可以查下一个chunk的Header得到。此时满足要求。

第0位P(PREV_INUSE) 则代表前一个chunk是否已经被分配,如果为1则代表已分配,是一个allocated chunk,为0则代表未分配,是一个free chunk。

第1位M(IS_MAPPED),表示当前chunk对应的是哪个区域的虚拟内存,1表示文件和匿名映射区,0表示heap区

第2位N(NON_MAIN_ARENA),表示chunk是否是thread arena

此时的free chunk:

allocated chunk:

同理,发现对于不同的标志位,只需要存储一份即可。所以,将原先的footer中的标志位部分统一为prev_size,并挪动到chunk头部

如何组织堆虚拟内存

如果我们通过隐式链表的方式管理chunk,则总会涉及到chunk的遍历,效率极低,由此引入了半侵入式链表来提高堆内存的分配和释放效率。

bin中各个free chunk是如何连接在一起的? free chunk的frea area中会有两个指针,分别指向当前chunk所属链表中的前一个chunk和后一个chunk

链表中的节点即为free chunk,链表称之为bin;为了将这些bin管理起来,通过数组fast bins和bins来将所有bin的头结点存储起来,二者同属于malloc_state

struct malloc_state
{
  ……
  /* Fastbins */
  mfastbinptr fastbinsY[NFASTBINS];
  ……
  /* Normal bins packed as described above */
  mchunkptr bins[NBINS * 2 - 2];  // #define NBINS    128
  ……
};

fast bins

(规定下文中,chunk size代表malloc_chunk的实际大小,chunk unused size为实际可用大小,前者比后者大16字节,用于存储prev_size,size以及两个指针)

作用:存放小尺寸且快速分配的空闲块

设计目的:快速地分配和释放小内存

程序中会经常申请和释放较小的内存空间。如果一释放就被分配器合并为一个较大的chunk,那么一旦又有小块内存的请求,那么分配器又需要把大的空闲内存做切分,无疑是比较低效的。所以,引入了较小块内存fast bin。

特点: 链表特点:每个fast bin都是单链表且总数为10,10个fast bin中所包含的fast chunk size是按照递增16字节排序的,比如第一个fast bin中所有的fast chunk size为16字节,第二个fast bin中所有的fast chunk size为32字节

为什么是单链表+LIFO?通过单链表就可以实现删除和添加操作。删除(malloc分配)即进行头删,添加(free释放)操作即头插,不需要对中间的节点进行操作,为O(1)操作。之所以要LIFO,是因为根据temporal locality原理,刚释放的chunk很可能马上再用,LIFO可以提高效率

节点特点:fast bin中的fast chunk大小较小,chunk unused不大于64字节,且所有的fast chunk不会自动合并,有延迟合并的特点

合并只会发生在malloc请求large chunk、malloc请求smallbin但是smallbin为空、free 非 fastbin chunk或显示地调用malloc_consolidate(),此时会触发malloc_consolidate(),将所有的fastbin chunk取出并与相邻块合并,放入到unsorted bin中

此外,会将fast free chunk的prev标志位置为1,保证不会对fast free chunk进行合并操作,这是其设计目的决定的。

unsorted bin

作用:当一个chunk被释放时,它首先被放入unsorted bin,而不是直接放入其他bins。其是free后chunk的“缓冲区+热缓存层”,用于延迟分类并提高复用概率

设计目的:1.降低free成本。如果每次free内存,都立刻判断是smallbin还是largebin,larginbin还要维护有序结构,并加入到其中,效率较低。所以,会首先合并相邻chunk并先放进unsorted bin中

2.提高命中率。程序中有个重要现象——刚释放的内存,大概率会被马上申请,可以理解为访问的局部性原理。所以如果把这块内存又放到smallbin/largebin中,相比于将这些热数据放在一个unsorted bin中,其查找时间会更多

特点:只有一个unsorted bin,其为双向链表且不排序,FIFO——头插(释放内存),遍历时从尾部开始查,并优先尾删(malloc分配)

为什么是双向链表 + FIFO?因为遍历时可能删除中间节点,而单链表删除中间节点的成本高,为O(n),在知道要删除的节点的情况下,需要通过遍历获取前驱节点。FIFO的效果是,如果freeABC,则顺序为C–>B–>A,但是malloc检查是从尾部A开始检查的,目的是让最早被释放的内存优先被检查,防止某些块长期滞留

small bin

作用:用于管理比 fast bins 稍大一些的小尺寸空闲块,支持合并

链表本身特点:共62个small bin,为双向链表,FIFO——内存释放操作就将新的chunk头插,分配操作就尾删

节点特点:一个small bin中的所有free small chunk大小相等,第一个small bin的size为16字节,后续递增8

large bin

作用:用于管理超过 small bin 范围的大尺寸空闲块

链表本身特点:其大体逻辑和small bin类似,区别在于单一large bin中每个chunk的大小不是相同的,从链表头到链表尾部呈现递增趋势,采用范围管理策略,而不是small bin的固定大小管理策略

关于bins的初始化时机:

注意,bins不是“预分配内存”的缓存池,而是已经分配过、又被free的chunk回收站。在第一次malloc前,bins完全是空的,直至第一块内存区域被free,如果大小较小则放入fastbins,否则合并并放入到unsorted bin中,再分类放入到smallbin/largebin中

malloc流程梳理

1.void* p = malloc(size)。内部首先内存对齐(8/16字节),并加上chunk header大小,得到normalized size

2.tcache中查找。tcache为线程本地缓存,按照size class分桶,LIFO,O(1)查找且无锁。但是其容量较小,且只会缓存小对象,在高并发下依然无法替代arena,可能导致频繁的锁竞争

thread
  └── tcache(每线程私有)
        ├── size class 1 → 链表
        ├── size class 2 → 链表
        └── ...

3.选择arena并获取到其对应的锁,开始从bins中查找何时的chunk,此后的查找都是在持锁的状态下完成的

4.fastbin中查找。如果nb属于fastbin范围内(80字节左右),则做精准匹配,如果fastbin[index]非空,则返回,否则进入下一次

5.smallbin。如果nb属于smallbin范围内(小于512字节),则将nb转换为smallbins索引,做精准匹配,并从对应的smallbin中取出一个chunk并返回

index = size_to_smallbin(nb)
if smallbin[index] 非空
    取一个 chunk
    标记 inuse
    返回

6.unsorted bin延迟分类层。做最接近匹配,遍历unsorted bin,在第一次满足nb>=chunk.size时,将该chunk从unsorted bin中移除,并将其做切割,将富裕部分根据其大小插入到smallbin/largebin中,并返回结果。注意,对于不符合大于等于条件的chunk都会被转移到smallbin或largebin中

如果遍历完unsorted bin依然没有找到合适块,即nb<所有chunk.size,此时unsorted也会被清空,因为已经被分类

for (chunk in unsorted):
    if chunk.size >= nb:
        从 unsorted 摘除
        if chunk.size - nb >= MIN_CHUNK_SIZE:
            切割
            remainder 分类插入 smallbin/largebin
        返回前半部分
    else:
        将该 chunk 分类到 smallbin 或 largebin

7.largebin。做最接近匹配,目标是找到>=nb的最小块,会先定位到index,得到largebins[index],再在其中找到目标快,如果有剩余则做切割,将剩余部分插入到smallbin或largebin中,并返回

8.top chunk。如果top.size()>=nb,则从top中做切割,将剩余部分插入到smallbin或largebin中,更新top指针,并返回

9.向系统申请内存以扩展堆。如果nb<128KB,则调用brk()扩展heap,更新top chunk并再次尝试分配;如果nb>=128KB,则调用mmap()创建匿名映射,返回mmap chunk

10.返回地址。此时,用户得到的是chunk+header_size,但是还没有物理页

可能存在的问题:

既然unsorted bin作为热缓存层,为什么是malloc查找顺序是fastbin->smallbin,而不是先查unsorted bin?

因为smallbin是精确匹配层,如果请求在smallbin的范围内,直接通过O(1)时间复杂度即可确定是否存在可用的内存块:

smallbin[index] 非空 → 直接返回

相比之下,unsorted bin里chunk大小杂乱,查找unsorted bin的代价是O(n),且查找到后可能需要进行分割。

malloc分配内存的设计原则是,先查找“确定性精确命中”的结构,再查找“需要遍历判断”的较低效结构

是否所有的内存块在分配时,都需要切割?

不是。如果想要让smallbin命中,必须要请求大小和small chunk大小完全相同,即必须做完全匹配而不是最恰当匹配,由此来避免低效的查找。这是空间利用率与速度的折中。

如果unsorted bin或者large bin命中,则需要进行切割

free流程梳理

1.定位chunk。free(ptr)将根据ptr定位到chunk的Header地址,获取PREV_INUSE、IS_MAPPED和NON_MAIN_ARENA信息

2.检查是否是mmap分配的chunk。通过查看IS_MAPPED标志位,可以判断是否是mmap分配的——如果是,则通过munmap(chunk,size)将虚拟地址区间移除、VMA移除、页表项删除并立刻释放物理页,归还给操作系统,不涉及任何合并以及bin;如果不是,则进行_int_free()函数来合并释放

3.检查是否属于fastbin大小范围。如果size <= 64B/128B,则将其头插到fastbin的单链表中,不做合并(延迟合并,因为小块频繁分配释放,很可能马上又malloc,合并的成本太高了),不清除PREV_INUSE

4.说明属于smallbin或是largebin的大小范围,进行合并。合并目标是,尽可能地让当前chunk和前一个chunk、后一个chunk合并。对于前一个chunk——通过Header中的PREV_INUSE标志位来判断前一个chunk是否为free,如果是free则通过chunk - prev_size来获取到前一个chunk的起始地址并合并;对于后一个chunk——通过chunk + size获取到下一个chunk的Header,检查其是否被使用,如果未被使用,则进行合并。合并的流程——从对应的bin中unlink(nextChunk),并让当前chunk->size += nextChunk->size

5.合并完成,将合并后的块放入到unsorted bin中。此时,虚拟地址依然存在、VMA存在、物理页也依然存在,并没有归还给操作系统,allocator认为可重用

6.特殊情况:合并到了top chunk。如果释放的块后面是top chunk,即chunk + size == top,则直接扩展top,不进入unsorted bin。此时,如果top_size > trim_threshold(一般是128KB),就会调用systrim,进而调用brk(new_end),让heap缩小,缩减mm_struct映射的堆区域与对应的vm_area_struct映射的虚拟地址范围,清除对应的页表项,并真正释放物理页,将物理内存归还给操作系统

重新理解free:

从进程的视角来看: free是“进程管理内存资源的行为”,等同于将这块内存的使用权还给了allocator,但是这块内存是否会交还给操作系统,取决于分配器策略,并不是free的必然结果

从数据结构的视角来看: allocator维护bins以及chunk状态,free本质上是对这些数据结构的操作,会根据ptr找到chunk并标记为free、尝试进行合并操作并插入到bin中,本质上是对堆管理数据结构的操作

从虚拟内存的视角来看: 对于小块内存(brk()),free不等同于删除虚拟页,VMA、页表依然存在且有效、PTE访问权限没有变化,此时并不是硬件层面禁止访问,只是属于未定义行为。比如,下面的代码通常不会立刻报错

free(p);
*p = 1;

对于大块内存,才会删除vm_area_struct、将物理页归还给操作系统,此时访问是硬件层面的错误

从内核视角来看: 绝大多数free并不会立刻进入内核,因为内存块已经交给allocator管理。只有在munmap、brk收缩和进程退出时才会进入内核态,调整映射,真正把物理内存收回

从硬件的视角来看: CPU完全不知道,CPU只做虚拟地址到物理地址的映射,free不会修改CR3、不会刷新TLB、不会改变PTE也不会触发page fault,除非munmap

究竟什么情况下才会真正将物理内存归还给操作系统?

一是mmap映射的区域直接通过munmap()归还,二是brk()调整收缩堆大小,内核才会调整虚拟映射、归还物理内存,此时的物理内存才可被其他进程使用

注意,释放内存必须从高地址,即heap的尾部开始释放。 例如下图中,只有最右边free区域可以被归还,中间的free块永远无法被归还

| allocated | free | allocated | free(top) |

不能归还中间部分的虚拟地址,这是由VMA机制决定的。 mm_struct通过heap_start和brk指针分别指向堆底和堆顶,vm_area_struct同样也会用start和end指针指向开始和结束地址。二者都说明了,这块虚拟内存区域是连续的。如果强行删除,会导致VMA被拆成两段,二者的语义被破坏,glibc的连续heap模型崩溃。所以,brk机制天然不支持中间部分裁剪

内存碎片

内存碎片是在什么情况下产生的?

第一层:allocator层碎片

外部碎片(空间总量够 但不连续)——malloc产生大小不同的chunk,切割时会产生剩余碎片;fastbin延迟合并,以性能换空间

内部碎片——chunk对齐导致

第二层:虚拟内存层

外部碎片:brk heap中的不可裁剪空洞,由于heap只能是单一连续的VMA 只可以尾部shrink,中间free无法通过brk归还

第三层:物理内存层

外部碎片:缺页中断触发物理页的分配,但是一张张4KB的页被打散分配给了不同的进程使用,如果此时某个进程需要2MB的连续页,可能难以分配,需要swap out

内存碎片如何处理?何时处理?由谁处理?

对于虚拟地址碎片:

heap空洞:中间的free chunk的虚拟地址仍然要保留,在一定时间内不能归还。其由ptmalloc来管理,这部分碎片的处理方式,需要等待未来被malloc复用——要不就是best-fit减少碎片,要不就是与相邻区间合并

总结一下,对于虚拟地址碎片,ptmalloc都有哪些处理手段——free时对smallchunk/largechunk立即合并、fastbin的延迟合并、unsortedbin的优先复用以避免频繁分割、trim的尾部处理;malloc时的best-fit策略,“尽可能保留大块内存,尽可能减小切割剩余大小”

对于物理内存碎片:

虽然虚拟地址不能被回收,但是物理页是可以回收的。

1.正常情况下,free后虚拟地址未被回收,页表映射关系依然存在,但是如果长期不访问,则在内存紧张的情况下会被swap out

2.现代glibc在一些条件下会主动madvise。此时,虚拟地址仍然存在,页表也依然存在,不过物理页被回收

说完何时回收,下面说说如何合并,减少物理内存碎片:Linux下buddy system对物理页进行管理,在物理页已经被回收,完全脱离进程映射后,buddy system可以合并相邻的free页以减少物理碎片

为什么allocator不频繁归还内存?

设计目标是——优先重用,减少无效、昂贵的系统调用。 一块刚被使用使用过的内存,很大可能会在不久后被再次使用。而如果free时直接把物理内存归还,就意味着需要再次调用brk()/mmap()系统调用、建立页表映射,代价太大

这也解释了为什么free后RSS(Resident Set Size 常驻内存集)可能不下降:RSS代表当前进程在物理内存(RAM)中实际占用了多少物理页,时操作系统视角下进程的真实消耗的物理内存量

一是释放内存块并不属于通过mmap映射分配到的大内存块,而是brk()分配的

二是释放的内存块并不在堆顶

三是,就算是堆顶的内存块,堆的大小小于trim_threshold(一般是128KB),glibc就不会调用systrim()来执行brk系统调用收缩堆。

这三种情况下,在操作系统看来这些内存块依然被进程占用着

总结

ptmalloc是glibc默认的内存分配器,通过arena机制降低多线程下的锁竞争,每个线程独立管理堆内存。它以chunk为最小单位,采用fastbin、smallbin、unsorted bin、large bin多级缓存策略,兼顾分配效率和碎片控制。free通常只将内存归还给分配器而非操作系统,只有大块mmap内存或堆顶收缩时才会真正释放物理页,这种设计以空间换时间,优先重用避免频繁系统调用。本篇文章主要梳理了malloc的流程,下一篇则主要聚焦于“为什么”的角度,梳理malloc为什么要这样做。在接下来的系列文章中,我们将以malloc为起点,逐步构建完整的内存管理知识体系。