3. 内存分配与释放

聊到内存管理,有个话题绕不开——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),背后发生了什么?

  1. 检查空闲链表: malloc 在对应的分箱里找大小合适的空闲块。找到了,直接返回。
  2. 找不到? 如果空闲链表没有合适的块,malloc 决定向内核申请。
  3. 判断大小: 如果申请量小于 128KB(阈值可配置),调用 sbrk 扩展堆。如果大于 128KB,调用 mmap 创建匿名映射。
  4. 内核处理: brk 调整堆的 VMA(虚拟内存区域),mmap 创建新的 VMA。此时只是分配了虚拟地址,物理页还没分配。
  5. 缺页中断: 当你真正读写这块内存时,CPU 触发缺页异常,内核才分配物理页框,建立页表映射。

嗯,这里要注意:malloc 返回后,你拿到的只是虚拟地址。物理内存是「按需分配」的。你申请 1GB 内存,可能只占了几 KB 的页表开销,直到你真正读写它。

个人经验: 我曾经用 malloc(1GB) 然后只用了前 1MB,以为内存占用很小。结果 top 一看,RSS 确实只有 1MB,但 VIRT 显示 1GB。这就是虚拟内存和物理内存的区别。别被 VIRT 吓到。

3.5 知识体系总览

下面这张图帮你理清本章的核心逻辑:

内存分配与释放:核心流程 用户态:malloc/free 空闲链表管理 分箱 + 合并 大小判断 < 128KB 或 >= 128KB chunk 头部管理 元数据开销 系统调用 内核态:brk / mmap brk/sbrk:移动 program break 线性扩展堆,适合小内存 mmap:创建匿名映射 独立 VMA,适合大内存 缺页中断 硬件层:物理内存分配 按需分配物理页框 建立页表映射 TLB 缓存更新

3.6 总结与避坑

好了,咱们把 malloc/free、brk/sbrk、mmap 这条线捋清楚了。最后给你几个实战建议:

  • 别频繁 malloc/free 小对象: 用内存池或栈分配替代。我见过一个网络库,每个包都 malloc 一次,压力测试下直接 OOM。
  • 大内存用 mmap: 超过 128KB 的分配,malloc 会自动走 mmap。但如果你自己管理大块内存,直接调 mmap 更可控。
  • 注意虚拟内存 vs 物理内存: malloc 成功不代表真有物理内存。写之前不会分配物理页。所以别用 malloc 返回值判断内存是否够用。
  • 多线程下小心: malloc 内部有锁,但如果你直接调 sbrk 或 mmap,要自己保证线程安全。

说实话,内存管理这块坑很多。但只要你理解了从用户态到内核态的完整链路,遇到问题就能快速定位。下次你的程序内存泄漏了,至少知道该从哪里下手查。

可可精品资料库