malloc 和 free 背后发生了什么?

malloc 和 free 底层原理

    • [malloc 会直接找内核吗?](#malloc 会直接找内核吗?)
    • 内存从哪里来?
      • [brk 和 sbrk 有什么区别?](#brk 和 sbrk 有什么区别?)
      • [mmap 有什么不同?](#mmap 有什么不同?)
    • [13 字节到底占多少?](#13 字节到底占多少?)
    • [malloc 为什么这么快?](#malloc 为什么这么快?)
    • [free 怎么知道大小?](#free 怎么知道大小?)
    • [free 后内存去哪了?](#free 后内存去哪了?)
    • [再看一次 malloc(13)](#再看一次 malloc(13))

在 C/C++ 中,申请和释放动态内存似乎只有两行代码:

c 复制代码
void *ptr = malloc(13);
free(ptr);

可如果继续追问,这两行代码很快就会变得不简单:

  • 操作系统通常按 4KB 的页管理内存,malloc 怎么只申请 13 字节?
  • malloc 是不是每次都要进入内核?
  • glibc 为什么有时使用 brk,有时又使用 mmap
  • free 没有接收大小,它怎么知道该释放多少?
  • 明明执行了 free,为什么进程的内存占用经常没有下降?

这些问题看似分散,其实都指向同一个角色:用户态内存分配器

接下来,我们就沿着 malloc(13) 返回的那根指针一路向下,看看一块内存怎样从内核来到程序,又怎样被 free 回收。

malloc 会直接找内核吗?

不会,至少大多数时候不会。

malloc 是 C 标准库函数,不是系统调用。程序调用 malloc 时,首先进入 glibc 的用户态内存分配器。分配器会先检查自己手里有没有可以复用的空闲内存,实在不够时才向内核申请更多空间。

之所以需要中间这一层,是因为内核与应用程序管理内存的粒度不同。

内核以页为基本单位建立虚拟地址映射,常见页大小是 4KB;应用程序却会申请 13 字节、100 字节等各种大小。如果每个小对象都单独建立页映射,不仅会浪费大量空间,也会产生频繁的系统调用和映射管理开销。

glibc 的做法是先从内核取得一片较大的虚拟地址空间,再把它切成许多小块交给程序。之后的多次 mallocfree,往往只需要修改用户态的数据结构。

还有一点很重要:取得虚拟地址空间,不等于立即占用了等量物理内存。Linux 通常采用按需分页,程序第一次访问某个页面时,才可能触发缺页异常并建立对应的物理页映射。

因此,下面三个时刻并不一定同时发生:

  1. malloc 返回一个非空指针;
  2. 进程的虚拟地址空间增大;
  3. 进程的物理内存驻留量增加。

那么,当 glibc 手里的空间确实不够时,它怎样向内核要内存?主要有两条路径:brkmmap

内存从哪里来?

brkmmap 都能为分配器提供虚拟地址空间,但它们最本质的区别不是"一个用于小内存、一个用于大内存",而是新空间出现在什么位置 :前者把传统堆的边界向后推,后者在地址空间的其他位置建立一段映射。

绿色部分是本次获得的内存;brk/sbrk 扩展原 heap,mmap 创建独立映射

左图中,新增空间紧挨着原来的 heap,program break 从旧位置移动到了新位置。右图中,传统 heap 和 program break 都没有变化,内核只是在另一段空白地址中建立了新的映射。这就是两条路径最直观的差别。

brk 和 sbrk 有什么区别?

传统堆位于进程数据段之后,它的末端边界称为 program break。把这条边界向高地址移动,堆就变大;向低地址移动,堆尾的一段地址空间就被收回。

brksbrk 操纵的是同一条边界,区别在于一个使用绝对位置,另一个使用相对增量:

c 复制代码
int   brk(void *addr);
void *sbrk(intptr_t increment);

假设当前 program break 位于地址 B

  • brk(B + 4096) 的意思是:把 program break 设置到指定地址 B + 4096
  • sbrk(4096) 的意思是:让 program break 从当前位置向后移动 4096 字节;
  • sbrk(0) 不移动边界,只返回当前 program break。

可以把它们理解成两种移动方式:

text 复制代码
brk(addr)   :移动到哪里
sbrk(delta) :移动多少字节

Linux 内核提供 brk 系统调用,glibc 又在其上提供了程序可调用的 brk/sbrk 接口。普通程序不应该一边使用 malloc,一边自行调用 sbrk 分配内存,因为两者同时修改同一条堆边界,会破坏分配器对堆布局的管理。

brk 路径很适合支撑大量普通对象:glibc 偶尔扩展一次堆,之后就可以在这片连续空间中反复切分和复用内存。

但连续堆也有一个限制。即使堆中间已经出现很多空洞,只要堆尾仍有对象存活,就不能简单地降低 program break,把中间某一段地址空间单独挖掉。

mmap 有什么不同?

mmap 不移动传统堆的边界。它让内核在进程地址空间中寻找一段合适的范围,并建立新的匿名映射:

c 复制代码
void *addr = mmap(NULL, length,
                  PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS,
                  -1, 0);

成功时,mmap 返回映射的起始地址;失败时返回 MAP_FAILED。映射长度会按页处理,其中的物理页通常仍在真正访问时按需分配。

内核使用 VMA(Virtual Memory Area)描述一段属性一致的虚拟地址区间,例如地址范围、访问权限和映射方式。VMA 是内核中的管理结构,不是物理内存,也不能简单理解成"每次 malloc 都会创建一个 VMA"。

一段不再需要的映射可以通过 munmap 解除:

c 复制代码
munmap(addr, length);

这使 mmap 很适合直接服务较大的独立请求:释放时可以解除对应映射,不必等待传统堆尾的其他对象消失。

不过,"小对象走 brk,大对象走 mmap"只是便于入门的近似说法,并不是固定规则:

  • 只要现有空闲块足够,即使请求较大,glibc 也可能直接复用已有空间;
  • 多线程程序的非主 arena 通常也使用 mmap 建立 heap,而这些 heap 同样会被切成许多小块;
  • glibc 会结合请求大小、可复用空间、动态阈值和运行配置选择路径。

所以,网络上常见的"超过 128KB 一定使用 mmap"不能当成不变的结论。

13 字节到底占多少?

现在回到最初的 malloc(13)

glibc 管理内存的基本单元通常称为 chunkmalloc 返回的指针指向 chunk 中的用户区域,而用户区域附近还需要保存分配器使用的元数据,例如 chunk 的大小和状态。

text 复制代码
较低地址                                               较高地址
┌──────────────────┬───────────────────────────────┐
│ chunk 元数据      │ 用户可用区域                  │
└──────────────────┴───────────────────────────────┘
                    ↑
                malloc 返回值

除此之外,返回地址还需要满足对齐要求,chunk 也有最小尺寸。因此,13 字节只是用户提出的请求,不等于分配器真正消耗的空间。

以常见的 64 位 glibc 配置为例,13 字节请求往往会落入一个 32 字节的 chunk,用户实际可用空间可能是 24 字节。具体数字依赖平台和版本,但这里真正需要区分的是五个概念:

  • 用户请求的大小;
  • 用户实际可用的大小;
  • glibc 内部 chunk 的大小;
  • 新增的虚拟地址空间;
  • 最终驻留的物理内存。

它们并不是同一个数字。

malloc 为什么这么快?

因为 glibc 不会从头扫描所有空闲内存,更不会每次都找内核。它为不同状态和大小的 chunk 维护了多层结构:

  • tcache:每个线程自己的小块缓存,常见的小额申请通常先从这里查找;
  • fastbins:arena 中用于部分小 chunk 的快速链表;
  • small bins、large bins:按大小组织空闲 chunk;
  • unsorted bin:普通 chunk 释放后常见的临时中转站;
  • top chunk:arena 尾部尚未分配的空闲空间。

一次 malloc(13) 通常会先检查 tcache,再尝试从各种 bin 中复用空闲 chunk。如果仍然找不到,glibc 才会从 top chunk 中切下一块。

这里再区分一下前面出现过的 program break:program break 是内核记录的传统堆边界,top chunk 则是 glibc 管理的空闲 chunk。在主 arena 中,top chunk 通常紧邻 program break,但二者不是同一个概念。

如果 top chunk 也不够,分配器才需要向内核取得更多虚拟地址空间:主 arena 常通过 sbrk/brk 扩展传统堆;非主 arena 或适合独立映射的较大请求则可能使用 mmap

因此,连续执行很多次 malloc(13),通常不会连续产生很多次系统调用。一次扩展取得的空间,可以支持后续许多次小额分配。

free 怎么知道大小?

free 只接收一个指针,并没有大小参数:

c 复制代码
void free(void *ptr);

它之所以知道该释放多大的内存,是因为 glibc 可以从合法指针定位对应的 chunk 元数据,从中恢复 chunk 的大小和来源。

这也解释了为什么传给 free 的必须是分配函数返回的原始指针:

c 复制代码
char *ptr = malloc(100);
free(ptr + 1);                 // 未定义行为

ptr + 1 已经不是原始地址。glibc 无法从它正确定位 chunk,随后可能读到错误的元数据并破坏整个堆。

free 后内存去哪了?

最常见的答案不是"回到内核",而是"留在 glibc 中等待下次使用"。

小 chunk 释放后,通常先进入 tcache 或 fastbin;普通 chunk 可能与相邻空闲块合并,再进入 unsorted bin 等结构。这样,下一次 malloc 就可以直接复用,不必再次向内核申请。

只有两类情况更可能真正影响操作系统看到的内存:

第一类是直接通过 mmap 获得的独立大 chunk。glibc 可以从元数据中恢复映射范围,free 时通常直接执行 munmap

第二类是传统堆尾出现了足够大的连续空闲空间。普通 chunk 与相邻空闲块合并后,如果最终连接到 top chunk,top chunk 就会变大;只有它满足页对齐、回收阈值和保留策略等条件,glibc 才可能收缩 program break。

glibc 还可能使用 madvise 告诉内核某些页面内容已经不再需要。此时虚拟地址范围仍然保留,但对应的物理页可能被回收。

无论底层采取哪条路径,free(ptr) 之后程序都已经失去了访问该对象的权利。地址值和旧数据即使暂时还在,也不能继续使用:

c 复制代码
int *ptr = malloc(sizeof *ptr);
*ptr = 42;
free(ptr);

printf("%d\n", *ptr);        // Use After Free,未定义行为

再看一次 malloc(13)

现在重新回到最初的两行代码:

c 复制代码
void *ptr = malloc(13);
free(ptr);

这一次,我们已经可以看到它背后的完整过程:

  1. glibc 将 13 字节请求换算成满足元数据和对齐要求的 chunk;
  2. malloc 优先从 tcache 和各种 bin 中寻找可复用空间;
  3. 找不到时,从 top chunk 切分;top 仍不足时,才通过 brkmmap 向内核扩展;
  4. free 根据 chunk 元数据恢复大小和来源;
  5. 普通 chunk 通常留在分配器中等待复用,独立 mmap chunk 则可能直接 munmap
  6. 只有虚拟映射或物理页进一步被回收,进程的相关内存指标才可能下降。

所以,malloc/free 的本质并不是简单地向操作系统"申请多少、归还多少",而是由 glibc 在应用程序与内核之间完成一次粒度转换:向下管理页和虚拟地址区域,向上提供对象大小的内存块,并在速度、碎片与内存占用之间不断权衡。

从程序员的视角,malloc(13) 只返回了一个指针;从系统的视角,这根指针背后连接着 chunk、tcache、bin、arena、program break、VMA、页表和物理内存。理解这条路径,也就理解了 mallocfree 最核心的工作方式。

相关推荐
sunzy_泽阳14 分钟前
Java + Spring 实现 Hermes Agent:从源码看多模型接入、子代理、人审与沙箱
java
2601_9620654920 分钟前
interface GigabitEthernet0/0/0
服务器·php·apache
2601_9611332820 分钟前
同花顺期货通指标公式通达信指标
java
Freak嵌入式25 分钟前
树莓派 Pico GPIO 深度解析:从 MCU 架构到寄存器控制,底层原理与实践指南
java·开发语言·科技·单片机·嵌入式硬件·架构
互联网中的一颗神经元28 分钟前
09. mheap:全局堆管理器
java·spring·dubbo
小二李29 分钟前
第12章 nestjs服务端开发:RBAC权限系统设计
java·linux·前端
Freak嵌入式36 分钟前
实战对比:用 mpremote/Putty/MobaXterm 连接树莓派 Pico REPL,谁更省心?
java·大数据·开发语言·科技·单片机·嵌入式硬件
feilieren40 分钟前
ubuntu 安装 docker
linux·ubuntu·docker
张文君41 分钟前
ubuntu26.04坏块分区隔离急速版
java·前端·python