操作系统内存管理:虚拟内存、物理内存、页表、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)。

它的工作流程是这样的:

  1. 小内存(< 128KB):从堆里切一块给你。堆用 brk 系统调用扩展。
  2. 大内存(>= 128KB):直接用 mmap 映射一块匿名内存,用完 munmap 释放。
  3. 空闲内存管理:用 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) 到物理内存的完整路径。我建议你仔细看,理解每一层的作用。

malloc(100) 完整调用路径 用户代码 p = malloc(100); // 虚拟地址 glibc ptmalloc 内存池 检查空闲链表(fastbin → smallbin → unsorted bin → ...) 没找到合适块 → 扩展堆或 mmap 系统调用 brk() 扩展堆 或 mmap() 映射匿名内存 内核内存管理 分配物理页框 → 建立页表映射 → 返回虚拟地址 硬件 MMU TLB 缓存 → 页表遍历 → 物理地址 → 访问内存

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 缓存还是真泄漏?页表占了多少?这些才是资深工程师的思考方式。

可可精品资料库