操作系统内存管理:虚拟内存、物理内存、页表、malloc/free底层原理
说到内存泄漏,很多人第一反应是「我代码里 new 了没 delete」。但说实话,如果你不懂操作系统是怎么管内存的,你连泄漏的根本原因都找不到。我见过太多人,拿着 Valgrind 报告一脸懵,因为看不懂地址到底是什么意思。
这一章,咱们把底层的内存管理机制讲透。你理解了这些,后面定位泄漏就像开了上帝视角。
1. 虚拟内存 vs 物理内存:一个经典的障眼法
每个进程都以为自己独占 4GB(32位)或 128TB(64位)的内存空间。这是假的。操作系统在背后搞了个「骗局」——虚拟内存。
虚拟内存是进程看到的地址空间。
物理内存是硬件上真实存在的 RAM 条。
进程访问一个地址时,CPU 里的 MMU(内存管理单元)会查一张表,把虚拟地址翻译成物理地址。这张表就是页表。
核心要点: 你程序里拿到的指针,全是虚拟地址。你永远看不到真实的物理地址。这就是为什么两个进程可以有相同的地址值,却互不干扰。
我在项目中遇到过一个问题:一个服务在 64 位系统上跑,内存占用看着不高,但 OOM Killer 总把它杀了。后来发现,虚拟地址空间用到了 48 位,但物理内存碎片化严重,页表项太多,内核撑不住了。嗯,虚拟内存不是无限的,页表本身也要占内存。
2. 页表:地址翻译的「字典」
页表把虚拟地址分成两部分:页号和页内偏移。页号用来查物理页框号,偏移量直接拼接过去。
举个例子,4KB 页大小,虚拟地址 0x12345678:
- 低 12 位(0x678)是页内偏移
- 高 20 位(0x12345)是虚拟页号
页表就是一张映射表:虚拟页号 → 物理页框号。
但问题来了——32 位系统有 2^20 个页,每个页表项 4 字节,一张页表就要 4MB。每个进程一张,100 个进程就是 400MB。这还只是页表本身的开销。
所以现代系统用了多级页表。x86-64 用 4 级页表,ARMv8 用 3 级或 4 级。说白了,就是按需分配,只有用到的虚拟地址范围才建页表项。
避坑指南: 我曾经在嵌入式 Linux 上调试一个内存泄漏,发现进程的 RSS 不大,但 VIRT 飙到了 2GB。查了半天,是 mmap 了一个大文件但没有 munmap。页表项占了几十 MB 内核内存,系统直接卡死。记住:虚拟地址空间不是免费的。
3. malloc/free 底层原理:glibc 的「内存批发商」
你写 malloc(100),glibc 不会直接调用 brk 或 mmap 去问内核要 100 字节。那样太慢了。glibc 自己维护了一个内存池,叫 ptmalloc(现在主流是 glibc 的 ptmalloc2)。
它的工作流程是这样的:
- 小内存(< 128KB):从堆里切一块给你。堆用
brk系统调用扩展。 - 大内存(>= 128KB):直接用
mmap映射一块匿名内存,用完munmap释放。 - 空闲内存管理:用 bins(链表)管理空闲块,有 fastbin、smallbin、largebin、unsorted bin 等。
你想想看,free(p) 干了什么?它不会把内存还给内核,而是放回 glibc 的空闲链表里。下次 malloc 优先从链表里找合适的块。
重要: 这就是为什么你 free 了内存,进程的 RSS 可能没降。内存还在 glibc 手里,没还给内核。这不是泄漏,是「缓存」。但如果你一直 malloc 不 free,那 glibc 会一直向内核要内存,RSS 就会涨。
4. 内存分配的核心数据结构
ptmalloc 用 chunk 来管理每一块内存。每个 chunk 有一个头部,包含:
prev_size:前一个 chunk 的大小(如果前一个空闲)size:当前 chunk 的大小,最低 3 位是标志位(P、M、A)fd、bk:空闲链表的前后指针(只在空闲 chunk 中有意义)
你 malloc 返回的地址,其实是 chunk 数据区的起始地址,在头部之后 16 字节(64 位系统)。
| 字段 | 偏移 | 说明 |
|---|---|---|
| prev_size | 0 | 前一个 chunk 的大小(8 字节) |
| size | 8 | 当前 chunk 大小 + 标志位(8 字节) |
| fd | 16 | 空闲链表前向指针(仅空闲时有效) |
| bk | 24 | 空闲链表后向指针(仅空闲时有效) |
| data | 16 或 24 | 用户数据区,malloc 返回的地址 |
个人经验: 调试内存越界时,我经常直接看 chunk 头部的 size 字段。如果 size 被改写了,说明有人写穿了缓冲区。比如你 malloc(100) 却写了 200 字节,size 字段就会被覆盖,free 时 glibc 会崩溃。这就是典型的「堆溢出」。
5. 内存分配的核心流程图
下面这张图展示了从 malloc(100) 到物理内存的完整路径。我建议你仔细看,理解每一层的作用。
6. 内存泄漏的本质
现在你明白了:内存泄漏 = 堆内存被分配后,丢失了所有指向它的指针,导致无法 free。
从操作系统角度看:
- 进程的堆(heap)不断增长
- glibc 的空闲链表里找不到这些块
- 内核的页表映射越来越多
- 物理内存被慢慢耗尽
从调试角度看:
- RSS 持续增长,永不回落
- VSS 可能增长更快(mmap 泄漏)
- 系统开始 swap,性能雪崩
- 最终 OOM Killer 出手
我的习惯: 写 C/C++ 代码时,我每次 malloc 都会在注释里写清楚「谁负责 free」。如果函数返回了 malloc 的内存,文档里必须说明调用者要 free。这不是代码规范问题,是生存问题。
7. 避坑指南:常见误解
误解一:进程退出时,操作系统会回收所有内存,所以泄漏无所谓。
对短生命周期的工具可以这么说。但服务器跑 7x24 小时,泄漏 1KB/秒,一个月就是 2.5GB。你想想看,能无所谓吗?
误解二:free 了内存,RSS 就会立刻下降。
不会。glibc 把内存缓存起来了。只有 malloc_trim() 或堆顶部的连续空闲块足够大时,才会通过 brk 归还给内核。mmap 的内存 free 后会立刻 munmap,RSS 会降。
误解三:valgrind 没报泄漏,就真的没泄漏。
我曾经遇到一个 case:程序用 mmap 分配共享内存,但忘了 munmap。valgrind 默认不检查 mmap 泄漏,需要加 --trace-children=yes 和 --track-origins=yes。嗯,工具不是万能的。
8. 总结
这一章我们讲了:
- 虚拟内存是进程的「幻觉」,物理内存是硬件的「现实」
- 页表是地址翻译的字典,多级页表节省了内存
- malloc/free 底层是 glibc 的 ptmalloc 内存池,它管理着 chunk 链表
- 内存泄漏的本质是堆内存失控增长,最终耗尽物理内存
理解这些,你再看内存泄漏报告,就不会只盯着「leaked 100 bytes」了。你会问:是堆泄漏还是 mmap 泄漏?是 glibc 缓存还是真泄漏?页表占了多少?这些才是资深工程师的思考方式。
可可精品资料库