- 文档
- 教程
- 知识库
【免费下载链接】CS-Base
图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文!🚀 在线阅读:https://xiaolincoding.com
本文是 CS-Base 仓库 《图解系统》 内存管理章节的核心内容,聚焦 C 标准库malloc()在 Linux 下的底层分配机制。你将通过两组可直接运行的 C 实验,亲手验证「malloc 分配的是虚拟内存」「malloc(1) 实际预分配 132KB」「free 后内存是否归还操作系统」等关键结论,并掌握用/proc/<pid>/maps、ps aux等工具观测进程内存分布的实战手段。读完后,你能系统讲清 brk() 与 mmap() 两条分配路径的差异、128KB 阈值的设计动机,以及 free() 仅凭一个地址就能准确释放内存的底层原理。
一、Linux 进程的内存分布:malloc 工作的舞台
在深入malloc()之前,先要看清它的活动空间——进程虚拟地址空间的分布结构。在 Linux 操作系统中,虚拟地址空间内部被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围不同:
32位系统的内核空间占用1G,位于最高处,剩下的3G是用户空间;64位系统的内核空间和用户空间都是128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
内核空间与用户空间之间有一道明确的分界线(权限隔离):
- 进程在用户态时,只能访问用户空间内存;
- 只有进入内核态后,才可以访问内核空间的内存。
虽然每个进程都各自拥有独立的虚拟内存,但每个虚拟内存中的内核地址,关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存,这也是系统调用能安全完成内核态工作的基础。关于虚拟地址与物理地址如何通过页表映射、MMU 如何完成地址转换,可参阅仓库文档 为什么要有虚拟内存?。
用户空间和内核空间的划分方式不同。以 32 位系统为例,用户空间内存从低到高分别是 6 种不同的内存段:
- 程序文件段:包括二进制可执行代码;
- 已初始化数据段:包括静态常量;
- 未初始化数据段:包括未初始化的静态变量;
- 堆段:包括动态分配的内存,从低地址开始向上增长;
- 文件映射段:包括动态库、共享内存等,从低地址开始向上增长(增长方向跟硬件和内核版本有关);
- 栈段:包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB,系统也提供了参数以便自定义大小。
在这 6 个内存段中,堆和文件映射段的内存是动态分配的。使用 C 标准库的malloc()或者mmap(),就可以分别在堆和文件映射段动态分配内存。这正是本文主角malloc()的活动舞台。
二、malloc 是如何分配内存的?
一个容易被误解的事实是:malloc()并不是系统调用,而是 C 库里的函数,用于动态分配内存。它底层再通过系统调用来向操作系统申请堆内存,一共有两种方式:
- 方式一:通过
brk()系统调用从堆分配内存。实现方式很简单,通过brk()函数将「堆顶」指针向高地址移动,获得新的内存空间。 - 方式二:通过
mmap()系统调用在文件映射区域分配内存。通过mmap()系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区"偷"了一块内存。
什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?
malloc()源码里默认定义了一个阈值(不同 glibc 版本的阈值定义不同):
- 如果用户分配的内存小于 128 KB,则通过
brk()申请内存; - 如果用户分配的内存大于 128 KB,则通过
mmap()申请内存。
这个 128KB 阈值是理解 malloc 全部行为的分水岭:它决定了内存来自堆段还是文件映射段,也决定了free()释放后内存的去留,后文的所有实验都将围绕这个阈值展开。
三、malloc() 分配的是物理内存吗?
不是的,malloc()分配的是虚拟内存。
如果分配后的虚拟内存没有被访问,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存。这一点在仓库文档 在 4GB 物理内存的机器上,申请 8G 内存会怎么样? 中有更极端的验证:在 64 位系统、物理内存仅 2GB 的机器上,程序可以连续申请 4 次 1GB 的虚拟内存而完全成功,因为只要不读写这些虚拟内存,操作系统就不会分配物理内存。
只有在访问已分配的虚拟地址空间的时候,操作系统才会真正介入:CPU 通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,进程从用户态切换到内核态,由内核的 Page Fault Handler(缺页中断处理函数)处理——若此时有足够空闲物理内存,就直接分配物理内存并建立虚拟内存与物理内存之间的映射关系;若无空闲物理内存,则进入内存回收流程,回收仍不满足时才触发 OOM 机制。完整的缺页中断→内存回收→OOM 链路可参阅仓库文档 内存满了,会发生什么?。
值得注意的是,即便只是「申请虚拟内存」这个过程本身,也会消耗少量物理内存(例如内核保存虚拟内存数据结构所用内存),这在超大虚拟内存申请场景下会成为触发 OOM 的诱因,alloc_mem.md中对此有完整实验佐证。
四、malloc(1) 会分配多大的虚拟内存?——132KB 实测
malloc()在分配内存时,并不会老老实实按用户预期申请的字节数来分配空间,而是会预分配更大的空间作为内存池。具体预分配多大,跟 malloc 使用的内存管理器有关,这里以 malloc 默认的内存管理器Ptmalloc2来分析。
我们做个实验:申请 1 字节的内存,看看操作系统实际分配了多大的内存空间。先准备如下 C 代码:
#include <stdio.h> #include <malloc.h> int main() { printf("使用 cat /proc/%d/maps查看内存分配\n",getpid()); //申请 1 字节的内存 void *addr = malloc(1); printf("此 1 字节的内存起始地址:%x\n", addr); printf("使用 cat /proc/%d/maps查看内存分配\n",getpid()); //将程序阻塞,当输入任意字符时才往下执行 getchar(); //释放内存 free(addr); printf("释放了 1 字节的内存,但 heap 堆并不会释放\n"); getchar(); return 0; }代码中两次getchar()分别把程序阻塞在「申请后」和「释放后」两个时间点,方便我们分两次观察内存分布。执行代码(原文实验环境使用的 glibc 库版本是2.17),在程序阻塞期间,通过/proc/<pid>/maps文件查看进程的内存分布情况,并用该 1 字节内存的起始地址过滤出内存地址范围:
[root@xiaolin ~]# cat /proc/3191/maps | grep d730 00d73000-00d94000 rw-p 00000000 00:00 0 [heap]这里补充说明/proc/<pid>/maps输出各字段的含义:00d73000-00d94000是内存段的虚拟地址范围,rw-p表示该段可读(r)、可写(w)、不可执行(x 缺位)、私有(p),00000000是文件内的偏移,00:00是设备号,0是 inode 号(匿名映射为 0),行尾的[heap]标识该段为堆。
这个例子分配的内存小于 128 KB,所以是通过brk()系统调用向堆空间申请的内存,因此最右边有[heap]的标识。可以看到堆空间的内存地址范围是00d73000-00d94000,大小是132KB,这就说明了malloc(1) 实际上预分配了 132K 字节的内存。
细心的读者会发现:程序里打印的内存起始地址是d73010,而 maps 文件显示堆内存空间的起始地址是d73000,多出了0x10(16 字节)。这个 16 字节的差异是本文最后一个问题的伏笔,后面会专门展开。
五、free 释放内存,会归还给操作系统吗?
继续让上面的进程往下执行:调用free()释放那 1 字节内存后,再次查看堆内存,发现堆内存还是存在的,并没有归还给操作系统。
这是因为,与其把这 1 字节释放给操作系统,不如先缓存进 malloc 的内存池里——当进程再次申请 1 字节内存时可以直接复用,速度快了很多。当然,当进程退出后,操作系统会回收进程的所有资源。
但以上结论只针对 malloc 通过brk()方式申请的内存。如果 malloc 通过mmap()方式申请内存,free()释放内存后就会归还给操作系统。做个实验验证:通过 malloc 申请 128KB 字节内存,让 malloc 走 mmap 路径。
#include <stdio.h> #include <malloc.h> int main() { //申请 1 字节的内存 void *addr = malloc(128*1024); printf("此 128KB 字节的内存起始地址:%x\n", addr); printf("使用 cat /proc/%d/maps查看内存分配\n",getpid()); //将程序阻塞,当输入任意字符时才往下执行 getchar(); //释放内存 free(addr); printf("释放了 128KB 字节的内存,内存也归还给了操作系统\n"); getchar(); return 0; }执行代码后查看进程的内存分布,可以发现最右边没有[heap]标志,说明这是通过 mmap 以匿名映射方式从文件映射区分配的匿名内存。释放掉这块内存后再查看该 128KB 内存的起始地址,会发现已经不存在了,说明归还给了操作系统。
于是「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题可以做个总结:
| 分配方式 | free 后内存去向 | 原因 |
|---|---|---|
| malloc 通过brk()方式申请的内存 | 不归还操作系统,缓存在 malloc 的内存池中,待下次使用 | 堆空间连续,缓存复用成本低 |
| malloc 通过mmap()方式申请的内存 | 归还操作系统,内存得到真正释放 | 匿名映射与进程生命周期绑定 |
六、为什么不全部使用 mmap 来分配内存?
既然 mmap 释放后能真正归还内存,看起来"干净利落",为什么不全部用 mmap?原因在于性能:
- 系统调用开销:向操作系统申请内存要执行系统调用,执行系统调用要进入内核态,之后再回到用户态,运行态的切换会耗费不少时间。如果都用 mmap 分配内存,等于每次分配都要执行系统调用;
- 缺页中断开销:mmap 分配的内存每次释放时都归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态,第一次访问该虚拟地址时就会触发缺页中断。
也就是说,频繁通过 mmap 分配内存,不仅每次都会发生运行态的切换,还会发生缺页中断(第一次访问虚拟地址时),导致 CPU 消耗较大。
为了改进这两个问题,malloc 通过brk()系统调用在堆空间申请内存时,由于堆空间是连续的,可以直接预分配更大的内存作为内存池;内存释放时就缓存在内存池中,等下次再申请内存时,直接从内存池取出对应的内存块即可,而且这个内存块的虚拟地址与物理地址的映射关系可能还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,大大降低了 CPU 的消耗。
七、既然 brk 那么"高效",为什么不全部使用 brk 来分配?
brk 的内存池方案也有其代价——内存碎片。
考虑这样一个场景:连续申请了 10k、20k、30k 三片内存,如果 10k 和 20k 这两片被释放了,就变为了空闲内存空间。如果下次申请的内存小于 30k,就可以重用这个空闲内存空间;但如果下次申请的内存大于 30k,没有可用的空闲内存空间,就必须向操作系统申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc 和 free,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致"内存泄露"。这种"泄露"现象使用valgrind 是无法检测出来的——因为内存确实被释放过,只是碎片无法被重新利用,属于堆内部的碎片化问题。
所以,malloc 实现中充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存(大于 128KB)时才使用 mmap 分配内存空间:小块内存用 brk 走内存池减少系统调用,大块内存用 mmap 避免堆碎片堆积。
八、free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
还记得前面那个伏笔吗?malloc 返回给用户态的内存起始地址(d73010)比进程的堆空间起始地址(d73000)多了 16 字节。
这个多出来的 16 字节就是保存该内存块的描述信息,其中就包含该内存块的大小。这样当执行free()函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的描述信息中分析出当前内存块的大小,自然就知道要释放多大的内存了。
这也是为什么free()只传一个地址就能完成精确释放:指针并非直接指向用户数据区,而是数据区起点,其前方的元数据区记录了块大小等管理信息。这种"用户指针前移、元数据前置"的设计在 Ptmalloc2 等主流分配器中是通用做法。
九、实战观测:用系统工具验证 malloc 的内存行为
结合本仓库的内存管理系列文档,可以形成一套完整的观测与排查手段:
- 查看内存段分布:
cat /proc/<pid>/maps | grep <地址前缀>可过滤出特定内存段,[heap]标识对应 brk 分配的堆,无标识的匿名段则来自 mmap; - 区分虚拟内存与物理内存:
ps aux | grep <进程名>中的VSZ代表进程使用的虚拟内存大小,RSS代表进程使用的物理内存大小。申请不访问时,VSZ 很大而 RSS 很小,正是「malloc 分配虚拟内存」的直接证据(实验示例见 alloc_mem.md); - 大内存申请被拒:若 64 位系统上 malloc 大块虚拟内存返回
Cannot allocate memory,可检查cat /proc/sys/vm/overcommit_memory(值为 0 表示试探式允许 overcommit,过大的单次申请会被内核判定不合理而拒绝;值为 1 表示 Always overcommit,来者不拒;值为 2 表示禁止 overcommit)。按需执行echo 1 > /proc/sys/vm/overcommit_memory可放开限制,详见 alloc_mem.md; - 缺页中断与回收:当访问 malloc 出的虚拟内存触发缺页中断且物理内存紧张时,内核会先做后台回收(kswapd)与直接内存回收,仍不足则触发 OOM Killer 杀进程,回收机制与 oom_score_adj 保护策略详见 mem_reclaim.md。
十、总结
回顾全文,malloc()的核心结论可以浓缩为以下几点:
- malloc 是C 库函数而非系统调用,底层通过
brk()(堆,小内存)或mmap()(文件映射区,大内存)两条路径向操作系统申请内存,默认分界阈值为128 KB(具体数值随 glibc 版本变化); - malloc 分配的是虚拟内存,未访问时不占物理内存;首次访问触发缺页中断后才建立虚拟到物理的映射;
- malloc 会预分配更大的内存池(Ptmalloc2 下 malloc(1) 实测预分配 132KB),并以 16 字节的块描述信息记录内存块大小,这也是 free() 只凭地址即可精确释放的依据;
- brk 分配的内存 free 后缓存在内存池不归还(换取快速复用、减少系统调用与缺页中断),但会造成堆碎片式"内存泄露"(valgrind 无法检测);mmap 分配的内存 free 后真正归还操作系统(避免碎片,但每次分配都伴随系统调用与首次访问缺页中断)。
理解了这两条路径的性能权衡,也就真正理解了 Linux 动态内存分配的设计取舍。进一步学习可继续阅读仓库内存管理章节的 为什么要有虚拟内存?、内存满了,会发生什么? 与 在 4GB 物理内存的机器上,申请 8G 内存会怎么样?。
- 文档
- 教程
- 知识库
【免费下载链接】CS-Base
图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文!🚀 在线阅读:https://xiaolincoding.com
相关推荐
ctf-wiki 堆利用入门:ptmalloc2 堆概述与 glibc 底层内存分配机制(brk/mmap 全解析)
ctf wiki 堆利用入门:ptmalloc2 堆概述与 glibc 底层内存分配机制(brk/mmap 全解析) 导读 本文是 ctf wiki 堆利用系列
文档网络安全教程mmap-go 内存映射实战:Go 可移植 mmap 库的 API 详解与平台实现原理
mmap go 内存映射实战:Go 可移植 mmap 库的 API 详解与平台实现原理 导读 mmap go 是一个为 Go 语言设计的可移植内存映射(memo
后端云原生容器编排微服务CS-Base 项目中的 Redis 内存优化:bigkey 处理与内存碎片图解
CS Base 项目中的 Redis 内存优化:bigkey 处理与内存碎片图解 Redis 作为高性能的内存数据库,在处理大规模数据时经常面临内存管理挑战。本
文档教程知识库
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考