1. 从一次内存分配异常说起为什么需要理解虚拟地址分配很多做系统编程的朋友第一次接触malloc返回NULL或者进程莫名其妙被 OOM Killer 干掉的时候第一反应往往是内存不够了。但实际情况可能完全不是这么回事——物理内存明明还有富余虚拟地址空间却已经耗尽了。这种问题在 32 位系统上尤其常见因为用户态可用的虚拟地址空间通常只有 3GB 左右一个稍微大一点的数据结构反复映射很容易就把地址空间碎片化到无法再分配。我在一个图像处理相关的模拟项目里就遇到过类似的情况程序需要频繁地mmap大块内存做帧缓冲每次处理完就munmap释放。跑单帧没问题连续跑几千帧之后就开始随机失败。用pmap一看虚拟地址空间里全是零零散散的空洞虽然总空闲量还很大但找不到一块足够大的连续区域。这就是典型的虚拟地址空间碎片问题跟物理内存余量没有半点关系。要真正搞清楚这类问题的来龙去脉就必须理解 glibc 在虚拟地址分配和分页这件事上到底做了什么。glibc 本身并不直接管理物理内存它管理的是进程的虚拟地址空间通过brk/sbrk和mmap/munmap这两套系统调用向内核要地。内核负责把这些虚拟地址映射到物理页框而分页机制则决定了什么时候真正分配物理页、什么时候只是保留地址范围。这篇文章面向的是有一定 C 语言基础、写过 Linux 用户态程序、但对内存管理内部机制还比较模糊的开发者。我会从 glibc 的分配器行为讲起把虚拟地址分配、brk与mmap的分工、页表与缺页异常、以及实际排查手段串成一条线。读完之后你至少能做到两件事第一看到malloc失败时能判断到底是虚拟地址不够还是物理内存不够第二知道怎么用工具去观察一个进程的地址空间布局而不是靠猜。需要提前说明的是glibc 的 malloc 实现ptmalloc2在不同版本间有差异本文讨论的行为以较新的 glibc 2.3x 系列为基准具体细节请以你手头的版本为准。另外内核侧的页表管理策略也在不断演进比如透明大页THP的默认行为就变过好几次这些都会影响实际观察到的现象。2. 虚拟地址空间的基本盘glibc 眼中的内存到底是什么2.1 进程地址空间的几个关键区域一个典型的 Linux 用户态进程它的虚拟地址空间从低到高大致分成这么几块代码段text、已初始化数据段data、未初始化数据段bss、堆heap、内存映射区mmap region、栈stack以及最顶上给内核留的那部分。glibc 的分配器主要跟堆和 mmap 区打交道。堆是从低地址向高地址生长的起点由brk指针标记。传统上malloc小块内存就是从堆里切brk往上抬。而 mmap 区是从高地址向低地址生长的malloc大块内存默认阈值 128KB可通过M_MMAP_THRESHOLD调整会直接走mmap拿一块独立的映射。这两套机制的分工是理解 glibc 分配行为的关键。为什么要有这个分工因为brk调整堆顶是一锤子买卖——你只能整体抬高或降低堆顶没法在中间挖洞。如果所有分配都走堆频繁的分配释放会让堆内部产生大量碎片而且堆顶一旦抬上去除非顶部那块刚好空闲否则很难降下来。mmap 则灵活得多每块映射独立释放时直接munmap还给内核地址空间能真正回收。代价是每次 mmap/munmap 都要陷入内核开销比在堆里切一刀大得多。2.2 brk 与 mmap 的阈值博弈glibc 默认的 mmap 阈值是 128KB这个数字不是随便定的。太小会导致大量小分配也走 mmap系统调用开销爆炸太大则堆容易膨胀且碎片难回收。glibc 还做了一个动态调整当它发现你free掉一块 mmap 分配的内存后如果这块比较大它会抬高mmap 阈值避免后续类似大小的分配继续走 mmap。这个机制叫动态 mmap 阈值dynamic mmap threshold。具体来说初始阈值 128KB如果你 free 了一块 256KB 的 mmap 块阈值会被抬到 256KB 左右实际是向上取整到某个粒度。这样下次再分配 256KB 以内的内存就会走堆而不是 mmap。这个设计的意图是既然你释放了这么大的块说明这类大小的分配可能是反复出现的放在堆里管理比反复 mmap/munmap 更划算。但这个动态调整有个副作用阈值一旦被抬高就不会自动降回去除非堆顶收缩到足够低。所以一个程序如果早期分配过一块很大的内存然后释放后续所有小于那个尺寸的分配都会挤在堆里堆可能因此长得很大且不容易收缩。这是很多长驻进程内存只涨不降的原因之一。2.3 主分配区与非主分配区glibc 的 malloc 为了支持多线程引入了**分配区arena**的概念。主分配区main arena就是进程启动时那个它用brk管理堆。当多线程竞争主分配区锁激烈时glibc 会创建非主分配区non-main arena每个非主分配区用mmap拿一块 64MB32 位系统或 64MB64 位系统实际是 HEAP_MAX_SIZE的连续区域来模拟堆。非主分配区的存在意味着一个多线程程序它的虚拟地址空间里可能同时存在多个堆。每个非主分配区都是一块独立的 mmap 映射内部再用类似堆的方式切分。这解释了为什么多线程程序用pmap看会有好几块大小接近 64MB 的匿名映射。线程和分配区的绑定关系是线程第一次 malloc 时尝试绑定主分配区如果抢不到锁就创建或复用一个非主分配区之后这个线程就固定用这个分配区。所以线程数很多时分配区数量也会上去虚拟地址空间占用相应增加。可以用MALLOC_ARENA_MAX环境变量限制分配区数量这在容器环境里控制内存 footprint 时很有用。3. 分页机制如何把虚拟地址变成物理内存3.1 页表、页框与地址翻译虚拟地址分配只是圈地真正要用这块地还得靠分页机制把虚拟页映射到物理页框。x86-64 上典型的页大小是 4KB地址翻译走多级页表CR3 寄存器指向顶级页表PGD虚拟地址被切成若干段作为各级页表的索引最后一级页表项PTE里存着物理页框号和各种标志位。glibc 调用mmap或抬高brk时内核只是在进程的页表里建立映射关系并不会立刻分配物理页框。真正分配物理页是在第一次访问该地址、触发缺页异常page fault的时候。这就是所谓的按需分页demand paging。所以你malloc了 1GB只要不碰它物理内存占用几乎为零但虚拟地址空间已经实实在在少了 1GB。这个区别是排查内存问题的核心。malloc成功只代表虚拟地址够用不代表物理内存够用。反过来malloc失败通常是虚拟地址空间的问题比如 32 位进程地址空间耗尽而不是物理内存不足。物理内存不足的表现是访问已分配内存时触发缺页内核发现没有空闲页框开始回收或触发 OOM。3.2 缺页异常的两种类型缺页异常分两种次缺页minor fault和主缺页major fault。次缺页是指页框已经在内存里比如在页缓存中或者只是需要建立页表映射内核只需要填一下 PTE 就行开销小。主缺页是指页框不在内存需要从磁盘读回来比如文件映射的页被换出过开销大得多。对于匿名映射malloc拿到的内存第一次访问触发的是次缺页——内核分配一个清零的物理页框填进 PTE。如果系统内存紧张这些匿名页可能被换出到交换区再次访问时就是主缺页。所以观察一个进程的缺页统计能大致判断它的内存访问模式minor fault 多说明在不断地首次触碰新页major fault 多说明内存压力大、页被换出过。用/usr/bin/time -v跑程序能看到这两类缺页的计数。如果一个程序 minor fault 数量巨大往往意味着它在频繁地分配新内存并首次写入这时候可以考虑用内存池或者复用缓冲区来减少缺页次数。这个优化在性能敏感的场景里效果很明显因为每次缺页都要陷入内核累积起来开销不小。3.3 透明大页带来的变数现代内核默认开启透明大页THP它会把连续的 4KB 页合并成 2MB 的大页。大页的好处是页表项少、TLB 命中率高对大数据量的顺序访问很友好。但它对内存分配行为有影响THP 可能会在缺页时一次性分配 2MB 物理内存即使你只访问了其中 4KB。这个行为在内存紧张时可能导致浪费也可能让malloc的行为变得不那么可预测。比如你malloc了 2MB 多一点点THP 可能给你映射一个 2MB 大页加一个 4KB 小页实际物理占用比预期多。可以用madvise(MADV_NOHUGEPAGE)对特定区域关闭 THP或者用echo never /sys/kernel/mm/transparent_hugepage/enabled全局关闭需要权限。我在那个图像处理项目里就吃过 THP 的亏帧缓冲大小是 1.5MB正好卡在 THP 的边界上导致每次分配实际占用 2MB 物理内存几路并发下来内存直接翻倍。后来把帧缓冲对齐到 2MB 并用MADV_HUGEPAGE显式管理反而更可控。这个经验说明理解分页机制不只是理论它直接影响你的内存 footprint。4. 用工具把地址空间看出来pmap、smaps 与 malloc 调试4.1 pmap 输出的每一列都在说什么pmap pid是最直观的地址空间观察工具。它的输出每行是一块虚拟内存区域VMA列包括起始地址、大小KB、权限rwx、以及映射来源比如[heap]、[stack]、[anon]、具体文件名。看 pmap 的时候重点关注几件事[heap]有多大、有多少块[anon]、有没有异常大的单块映射。[heap]那一行对应主分配区的 brk 区域。如果它很大几百 MB 甚至 GB说明大量分配走了堆。如果[anon]块很多且大小接近 64MB那多半是非主分配区。如果看到很多小块[anon]可能是 mmap 分配后没释放或者线程栈。pmap -x pid会多出 RSS、Dirty、Swap 等列能看出每块区域实际驻留了多少物理内存。这个信息很关键一块 1GB 的虚拟映射RSS 可能只有几 MB说明大部分页还没被触碰。对比虚拟大小和 RSS就能判断是圈地不种还是真的在用。4.2 /proc/pid/smaps 里的细节/proc/pid/smaps比 pmap 更详细每块 VMA 会列出 Size、Rss、Pss、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty、Swap、以及内核内部统计。其中PssProportional Set Size特别有用共享页按共享它的进程数均摊能更公平地反映一个进程实际占用的内存。排查内存泄漏时我会定期 dump smaps对比不同时间点的 Private_Dirty 变化。如果某块匿名映射的 Private_Dirty 持续增长且不回落基本可以锁定是这块区域在泄漏。再结合代码里对应的 mmap 或 malloc 调用点就能定位问题。smaps 里还有个KernelPageSize和MMUPageSize能看出这块区域用的是 4KB 还是 2MB 页。如果发现某块本该用大页的区域实际是 4KB 页可能是 THP 没生效或者对齐没做好。这个细节在性能调优时值得一看。4.3 用 mallinfo 和 malloc_stats 看分配器内部状态glibc 提供了mallinfo()和malloc_stats()两个接口来观察分配器内部状态。mallinfo返回一个结构体里面有arena堆总大小、ordblks空闲块数、uordblks已用字节数、fordblks空闲字节数等字段。通过对比uordblks和fordblks能看出堆内碎片程度。malloc_stats()直接把统计打到 stderr包括每个分配区的系统分配总量、已用、空闲等。多线程程序里它会列出所有分配区能看出是不是创建了太多非主分配区。如果发现分配区数量远超线程数可能是线程创建销毁频繁导致分配区反复创建这时候用MALLOC_ARENA_MAX限制一下会有帮助。需要注意的是mallinfo在 64 位系统上有字段溢出问题它用的是 intglibc 2.33 之后引入了mallinfo2用 size_t建议优先用新接口。另外这些统计只反映分配器视角不包含 mmap 直接分配的大块看的时候要心里有数。5. 几个真实会踩的坑与排查思路5.1 32 位进程地址空间耗尽的典型表现32 位进程用户态地址空间通常只有 3GB其中还要分给代码、栈、共享库。实际可用的堆和 mmap 空间可能只有 2GB 出头。如果一个程序反复 mmap 大块内存且不释放或者堆因为碎片无法收缩很快就会撞到上限。表现是malloc返回 NULL、mmap返回 MAP_FAILEDerrno 是 ENOMEM但free看物理内存还有富余。排查这种问题先看/proc/pid/maps里最高地址的映射到哪了再看有没有大量小块空洞。如果是碎片问题考虑改用内存池、预分配大块自己管理或者干脆迁到 64 位。64 位进程地址空间有 128TB用户态基本不可能耗尽所以很多内存不够的问题升级到 64 位就消失了。5.2 动态 mmap 阈值导致的堆膨胀前面提到动态 mmap 阈值只升不降。一个典型场景程序启动时加载了一个大配置malloc了几 MB用完free了。这会把阈值抬到几 MB之后所有小于几 MB 的分配都走堆。如果程序后续频繁分配释放小块堆会逐渐膨胀且难以收缩因为堆顶可能被某个长期存活的小块占着。应对办法有两个一是用mallopt(M_MMAP_THRESHOLD, ...)显式设定阈值不让它动态调整二是用mallopt(M_TRIM_THRESHOLD, ...)控制堆顶收缩的时机。M_TRIM_THRESHOLD默认是 128KB意思是堆顶空闲超过这个值就尝试brk回缩。把它调小能让堆更积极地归还内存代价是系统调用变多。5.3 多线程下分配区数量失控高并发短连接的服务如果每个连接创建一个线程线程频繁创建销毁glibc 可能会创建大量非主分配区。每个非主分配区占 64MB 虚拟地址空间虽然物理占用可能很小几十个分配区就是几 GB 虚拟空间。在 32 位进程上这会直接导致地址空间耗尽。解决办法是用MALLOC_ARENA_MAX限制分配区上限比如设成 4 或 8。或者改用线程池避免线程频繁创建销毁。另外 glibc 有分配区复用机制线程退出时它的分配区会被放回空闲列表供新线程复用但如果线程创建速度超过复用速度还是会堆积。这个细节在压测时用malloc_stats观察分配区数量就能发现。5.4 缺页异常导致的性能抖动一个程序如果每次请求都malloc一大块然后写入会触发大量次缺页。每次缺页都要陷入内核分配物理页在高压下这会成为瓶颈。表现是 CPU 的 sys 时间占比高minor fault 计数飙升。优化思路是复用内存用内存池预分配一批缓冲区请求来了从池里取用完还回去避免反复触发缺页。另一个思路是用mmap加MAP_POPULATE标志让内核在映射时就预分配物理页把缺页开销提前到映射阶段。不过MAP_POPULATE对大块内存会拖慢映射本身要权衡。6. 把知识落到实操一套可复用的观察流程6.1 定位内存问题的标准动作遇到内存相关问题时我一般按这个顺序走先用pmap -x pid看整体布局确认虚拟大小和 RSS 的差距再用cat /proc/pid/smaps找 Private_Dirty 持续增长的区域然后结合malloc_stats看分配器内部状态最后用strace -e tracebrk,mmap,munmap跟踪系统调用看分配释放的模式。这套流程能覆盖大部分场景虚拟地址耗尽、物理内存泄漏、堆碎片、分配区过多。关键是每一步都要有明确的观察目标而不是漫无目的地看。比如看 smaps 时如果发现某块[anon]的 Private_Dirty 只涨不跌那基本就是泄漏点接下来去代码里找对应的分配点即可。6.2 几个值得记住的调优参数glibc 提供了一组mallopt参数和环境变量来调整分配行为常用的有这么几个参数作用典型场景M_MMAP_THRESHOLD超过此值走 mmap控制大块分配策略M_TRIM_THRESHOLD堆顶空闲超过此值回缩控制堆内存归还M_TOP_PAD每次 brk 扩展的额外余量减少 brk 调用次数M_ARENA_MAX分配区数量上限多线程内存控制MALLOC_ARENA_MAX环境变量形式的上限容器环境常用调这些参数的原则是先测量再调整不要凭感觉设。比如M_TRIM_THRESHOLD调小会让堆更积极归还但 brk 调用变多可能反而拖慢性能。用strace -c统计一下 brk 调用次数再决定要不要调。6.3 一个容易忽略的细节对齐与页边界malloc返回的指针对齐到 16 字节64 位系统但如果你要分配的内存正好跨页边界访问时可能触发两次缺页。对于性能敏感的大块分配可以考虑自己用posix_memalign或aligned_alloc对齐到页边界4KB 或 2MB减少跨页访问。另外mmap分配的地址本身就是页对齐的但malloc从堆里切出来的不一定。如果你在做 DMA 或者需要页对齐的场景直接用mmap更省事。这个细节在做高性能 IO 或者和硬件打交道时经常被忽略但影响不小。7. 写在最后的一点个人体会虚拟地址分配和分页这套机制初看像是操作系统课上的理论但真正写起系统程序来它几乎无处不在。我自己的经验是不要等到出问题才去翻手册平时写代码时就有意识地想一想这块内存是走堆还是走 mmap这个访问会不会触发缺页很多坑就能提前避开。还有一个习惯值得养成在关键路径上加上内存统计的日志定期 dump 一下 smaps 和 malloc_stats。这样一旦出现异常增长你有历史数据可以对比定位起来快得多。纯靠出问题时现场抓往往已经错过了最佳观察窗口。最后提醒一句glibc 的分配器行为在不同版本、不同内核、不同配置下都有差异本文说的都是常见情况下的典型行为。真正要下结论还是得在你自己的环境里实测。工具都在手边多跑几次、多对比几组数据比看任何文档都管用。