3. 内存分配与释放:malloc/free 原理、brk/sbrk 系统调用、mmap 映射机制
聊到内存管理,有个话题绕不开——malloc 和 free。你每天都在用,但你真的知道它们背后干了什么吗?
说实话,我早年刚入行时,觉得 malloc 就是个黑盒子。直到有一次线上服务出现诡异的内存泄漏,查了三天三夜,最后发现是 free 之后指针没置空,导致野指针踩了堆结构。嗯,从那以后,我就把 malloc/free 的底层原理彻底啃了一遍。
今天咱们就把它讲透。从用户态的 malloc,一路挖到内核态的 brk 和 mmap。
3.1 malloc/free:用户态的内存管家
malloc 不是系统调用,它是 C 标准库提供的函数。说白了,它是个「批发商」——从内核那里批量申请内存,然后零售给你的程序。
为什么需要这层中间商?因为系统调用太贵了。每次 brk 或 mmap 都要陷入内核,上下文切换的成本很高。所以 malloc 会预申请一大块内存,自己管理。
3.1.1 malloc 的核心数据结构
每个 malloc 分配的内存块,前面都藏着一个「头部」——我们叫它 chunk header。这个头部记录了块的大小、是否空闲、前后块的指针等信息。
// 简化的 chunk 结构
struct chunk {
size_t prev_size; // 前一个块的大小(如果前一个块空闲)
size_t size; // 当前块的大小(最低位标记是否在使用中)
struct chunk *fd; // 空闲链表的前向指针
struct chunk *bk; // 空闲链表的后向指针
// 用户数据从这里开始
};
你想想看,你调用 malloc(100),实际分配的可能不止 100 字节。头部占了 16 或 24 字节,还要对齐到 16 字节边界。所以实际开销比你想的大。
关键点: free(p) 释放时,标准库会根据 p 地址往前偏移,找到 chunk header,然后把这个块标记为空闲,并尝试合并相邻的空闲块。
3.1.2 空闲链表与碎片管理
malloc 内部维护了多个空闲链表,按块大小分类。比如 16 字节、32 字节、64 字节……一直到大的连续区域。这叫「分箱管理」。
分配时,malloc 会找最合适的空闲块。如果找不到,就向内核申请新内存。释放时,块会放回对应的空闲链表。
我在项目中遇到过一个问题:频繁 malloc/free 小对象,导致内存碎片化严重。明明还有大量空闲内存,但 malloc(1000) 却失败了。为什么?因为空闲块都是几十字节的小碎片,凑不出一个连续的大块。
避坑指南: 我曾经在嵌入式项目里用 malloc 分配大量小结构体,结果堆碎片化到系统崩溃。后来改用内存池(固定大小块预分配),问题迎刃而解。如果你的场景是固定大小的对象频繁分配释放,别用 malloc,自己搞个池子。
3.2 brk/sbrk:堆的边界移动
好,现在咱们从用户态往内核态走一步。malloc 底层怎么向内核要内存?两个途径:brk 和 mmap。
先说 brk。每个进程有一个「堆」区域,堆的顶部叫 program break。brk 系统调用就是移动这个边界。
#include <unistd.h>
int brk(void *addr); // 设置 program break 到 addr
void *sbrk(intptr_t increment); // 增加/减少 program break,返回旧地址
sbrk(0) 不会改变边界,只返回当前 program break 地址。我调试内存问题时经常用这招——在 malloc 前后各调一次 sbrk(0),看看堆涨了多少。
3.2.1 brk 的工作方式
初始时,堆从数据段(BSS 之后)开始,program break 指向堆的末尾。调用 brk 或 sbrk 时,内核会调整进程的虚拟地址空间映射。
注意:brk 只能线性扩展堆。你申请 100 字节,堆顶往上移 100 字节。释放时,只有顶部的空闲块才能归还给内核——如果中间有块没释放,顶部的内存就收不回来。
警告: brk 不是线程安全的。多线程程序里,malloc 内部会加锁来保护堆结构。但如果你直接调 sbrk,小心竞争条件。我见过有人手痒在信号处理函数里调 sbrk,结果死锁了。
3.3 mmap:大块内存的映射机制
对于大块内存(通常超过 128KB),malloc 不会用 brk,而是用 mmap。为什么?因为 brk 释放大块时容易造成堆空洞,而 mmap 分配的内存可以独立释放回内核。
#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);
mmap 会在进程的虚拟地址空间中创建一段新的映射。它不依赖堆,可以分配在任意位置。释放时调用 munmap,这段虚拟地址就彻底归还给内核了。
3.3.1 mmap 的典型用法
除了 malloc 大块内存,mmap 还有两个经典场景:
- 文件映射: 把文件直接映射到内存,读写文件就像读写内存一样。我做过一个日志分析工具,用
mmap映射 GB 级别的日志文件,随机访问速度比read/write快了一个数量级。 - 匿名映射:
flags设为MAP_ANONYMOUS,不关联文件,纯粹分配内存。这就是malloc大块时用的方式。
性能对比:
| 特性 | brk/sbrk | mmap |
|---|---|---|
| 分配速度 | 快(线性移动指针) | 较慢(需要建立页表) |
| 释放方式 | 只能从顶部释放 | 任意位置释放 |
| 碎片问题 | 容易产生内部碎片 | 按页对齐,碎片少 |
| 适用场景 | 小内存(<128KB) | 大内存(>128KB) |
| 归还内核 | 难(除非顶部空闲) | 易(munmap 直接归还) |
3.4 整体流程:从 malloc 到内核
咱们把整个链路串起来。你写一行 p = malloc(100),背后发生了什么?
- 检查空闲链表:
malloc在对应的分箱里找大小合适的空闲块。找到了,直接返回。 - 找不到? 如果空闲链表没有合适的块,
malloc决定向内核申请。 - 判断大小: 如果申请量小于 128KB(阈值可配置),调用
sbrk扩展堆。如果大于 128KB,调用mmap创建匿名映射。 - 内核处理:
brk调整堆的 VMA(虚拟内存区域),mmap创建新的 VMA。此时只是分配了虚拟地址,物理页还没分配。 - 缺页中断: 当你真正读写这块内存时,CPU 触发缺页异常,内核才分配物理页框,建立页表映射。
嗯,这里要注意:malloc 返回后,你拿到的只是虚拟地址。物理内存是「按需分配」的。你申请 1GB 内存,可能只占了几 KB 的页表开销,直到你真正读写它。
个人经验: 我曾经用 malloc(1GB) 然后只用了前 1MB,以为内存占用很小。结果 top 一看,RSS 确实只有 1MB,但 VIRT 显示 1GB。这就是虚拟内存和物理内存的区别。别被 VIRT 吓到。
3.5 知识体系总览
下面这张图帮你理清本章的核心逻辑:
3.6 总结与避坑
好了,咱们把 malloc/free、brk/sbrk、mmap 这条线捋清楚了。最后给你几个实战建议:
- 别频繁 malloc/free 小对象: 用内存池或栈分配替代。我见过一个网络库,每个包都
malloc一次,压力测试下直接 OOM。 - 大内存用 mmap: 超过 128KB 的分配,
malloc会自动走mmap。但如果你自己管理大块内存,直接调mmap更可控。 - 注意虚拟内存 vs 物理内存:
malloc成功不代表真有物理内存。写之前不会分配物理页。所以别用malloc返回值判断内存是否够用。 - 多线程下小心:
malloc内部有锁,但如果你直接调sbrk或mmap,要自己保证线程安全。
说实话,内存管理这块坑很多。但只要你理解了从用户态到内核态的完整链路,遇到问题就能快速定位。下次你的程序内存泄漏了,至少知道该从哪里下手查。
可可精品资料库