1. 从一次线上故障说起:为什么要啃虚拟地址分配这块硬骨头
前阵子帮朋友排查一个服务进程的异常退出问题,现象很怪:进程跑着跑着突然收到 SIGSEGV,日志里没有任何业务异常,core dump 打开一看,崩溃点落在一段看起来完全正常的指针解引用上。用 gdb 把地址打出来,发现那个地址落在了一个"理论上不该被访问"的区域。折腾了大半天,最后定位到根因是堆区和 mmap 映射区之间的空隙被不断增长的 brk 挤占,加上一个第三方库自己做了 mmap 映射,两边撞车了。
这件事让我重新把 glibc 手册里关于虚拟地址分配和分页的那一章翻出来仔细读了一遍。说实话,平时写业务代码,虚拟内存这东西基本是"透明"的——malloc 一下,指针拿来就用,谁管它背后是 brk 还是 mmap。但一旦涉及到性能调优、内存泄漏排查、大页配置、或者像上面那种诡异崩溃,不懂虚拟地址分配机制,基本就是盲人摸象。
这篇内容就是把我自己啃手册、做实验、踩坑的过程整理出来。核心围绕三件事:进程的虚拟地址空间到底是怎么划分的、glibc 在分配内存时到底做了什么选择、分页机制如何影响你的程序性能。适合有一定 C/C++ 或系统编程基础、想深入理解内存管理的开发者,也适合正在做性能优化、被内存问题折磨的运维和 SRE。哪怕你平时只写上层业务,读完至少能明白为什么"内存够用但程序还是崩"这种事会发生。
2. 虚拟地址空间的全景图:一张图看懂进程的内存布局
2.1 用户态地址空间的经典分区
在 64 位 Linux 上,每个进程都活在一个巨大的虚拟地址空间里。这个空间不是平的,而是被划分成若干功能明确的区域。从低地址往高地址看,大致是这样:
- 代码段(text):存放编译后的机器指令,通常是只读、可执行的。
- 数据段(data / bss):已初始化全局变量放 data,未初始化的放 bss,bss 在文件里不占空间,加载时清零。
- 堆(heap):由
brk系统调用管理的区域,从低地址向高地址增长,malloc的小块分配主要落在这里。 - mmap 映射区:从高地址往低地址增长,动态库、大块 malloc、文件映射、匿名映射都在这里。
- 栈(stack):从高地址向低地址增长,函数调用、局部变量、返回地址都在这。
- 内核空间:用户态不可直接访问,通过系统调用陷入。
这里有个关键点很多人会忽略:堆和 mmap 区是相向增长的。堆往上长,mmap 区往下长,中间留了一段"缓冲地带"。如果两边都长得太猛,撞上了,新的分配就会失败,甚至可能覆盖已有映射——这就是我开头那个故障的根源。
提示:在 32 位系统上,用户态地址空间只有 3GB(典型配置),堆和 mmap 区撞车的概率比 64 位高得多。如果你的服务还在 32 位环境跑,内存布局要格外小心。
2.2 64 位下的地址空间其实"用不满"
64 位理论上能寻址 2^64 字节,但实际硬件和内核只用了其中一部分。x86-64 目前规范是 48 位有效地址(部分新 CPU 支持 57 位五级页表),用户态通常占低 128TB,内核态占高 128TB,中间是巨大的"空洞"。
这个空洞不是浪费,而是安全设计:任何落在这个空洞里的指针访问都会立刻触发段错误,相当于给野指针设了一道"隔离带"。所以你在 64 位下写代码,指针越界往往比 32 位更容易暴露,因为撞到空洞的概率高。
2.3 用 /proc 亲手看一眼真实布局
光说理论没意思,直接上命令。随便跑一个进程,读它的 maps 文件:
cat /proc/self/maps你会看到类似这样的输出(我截取了一段实际运行的结果):
55f3a2c1d000-55f3a2c1e000 r--p 00000000 08:01 1234567 /usr/bin/cat 55f3a2c1e000-55f3a2c1f000 r-xp 00001000 08:01 1234567 /usr/bin/cat 55f3a2c1f000-55f3a2c20000 r--p 00002000 08:01 1234567 /usr/bin/cat 55f3a2c20000-55f3a2c21000 r--p 00002000 08:01 1234567 /usr/bin/cat 55f3a2c21000-55f3a2c22000 rw-p 00003000 08:01 1234567 /usr/bin/cat 55f3a3f8a000-55f3a3fac000 rw-p 00000000 00:00 0 [heap] 7f8b2c000000-7f8b2c021000 rw-p 00000000 00:00 0 7f8b2c021000-7f8b30000000 ---p 00000000 00:00 0 7f8b34000000-7f8b34028000 rw-p 00000000 00:00 0 ... 7ffd4a3e0000-7ffd4a401000 rw-p 00000000 00:00 0 [stack]每一行格式是:起始地址-结束地址 权限 偏移 设备 inode 路径名。权限那四位分别是读、写、执行、私有/共享。[heap]就是堆,[stack]是栈,中间那些没有路径名的匿名映射,很多就是 glibc 的 malloc 通过 mmap 拿到的内存。
我建议你养成一个习惯:排查内存问题时,第一件事就是看 maps。它能告诉你进程到底映射了哪些区域、每个区域多大、权限是什么。比任何猜测都靠谱。
3. glibc 的内存分配策略:brk 和 mmap 到底怎么选
3.1 两个系统调用,两套逻辑
glibc 的 malloc 并不是直接向内核要内存,而是维护了一套自己的分配器(ptmalloc2)。它向内核要内存只有两条路:
- brk:调整堆顶指针,把堆区往上推。优点是连续、开销小;缺点是只能整体伸缩,释放的内存如果不在堆顶就还不了内核,容易产生碎片。
- mmap:直接映射一块新的虚拟内存区域。优点是独立、释放时直接 munmap 还给内核;缺点是每次映射都有系统调用开销,且映射数量多了会占用大量 VMA(虚拟内存区域)结构。
那 glibc 怎么选?核心阈值是M_MMAP_THRESHOLD,默认128KB。也就是说:
- 申请小于 128KB 的内存,走 brk,从堆里切。
- 申请大于等于 128KB 的内存,走 mmap,单独映射一块。
这个阈值不是固定的,glibc 有个动态调整机制:当你 free 掉一块 mmap 分配的内存时,如果它足够大,glibc 会把这个阈值往上调,最高到DEFAULT_MMAP_THRESHOLD_MAX(32 位是 512KB,64 位是 32MB)。目的是减少 mmap/munmap 的频繁调用。
3.2 为什么阈值是 128KB 而不是别的数
这个数字是经验值,背后是权衡:
- 太小:大量小分配都走 mmap,系统调用开销爆炸,VMA 数量激增。
- 太大:大块内存都挤在堆里,释放后无法归还内核,进程 RSS 居高不下。
128KB 大致是"系统调用开销"和"内存归还收益"的平衡点。当然,不同负载下最优值不一样,所以 glibc 才做了动态调整。
你可以用mallopt手动调这个阈值:
#include <malloc.h> int main() { // 把 mmap 阈值设为 512KB mallopt(M_MMAP_THRESHOLD, 512 * 1024); // 关闭 mmap 的动态调整,固定阈值 mallopt(M_MMAP_MAX, 0); return 0; }注意:
M_MMAP_MAX设为 0 是禁止使用 mmap 分配,不是关闭动态调整。要关闭动态调整应该用M_MMAP_THRESHOLD配合环境变量MALLOC_MMAP_THRESHOLD_。这个坑我踩过,手册里写得比较绕,实测下来环境变量方式更可靠。
3.3 环境变量调优:不改代码也能生效
glibc 支持通过环境变量调整 malloc 行为,这对线上服务特别有用,不用重新编译:
| 环境变量 | 作用 | 典型值 |
|---|---|---|
MALLOC_MMAP_THRESHOLD_ | mmap 分配阈值 | 131072(128KB) |
MALLOC_TRIM_THRESHOLD_ | 堆顶回收阈值 | 131072 |
MALLOC_TOP_PAD_ | 每次扩展堆的额外预留 | 131072 |
MALLOC_ARENA_MAX | 最大 arena 数量 | 视 CPU 核数而定 |
MALLOC_ARENA_MAX这个在多线程服务里特别关键。glibc 为了减少锁竞争,会为每个线程创建独立的 arena(分配区),默认上限是 8 倍 CPU 核数。线程多、分配频繁的服务,arena 数量会爆炸,每个 arena 都占一块虚拟内存,RSS 虚高。我一般会把它设成 2 到 4,实测能显著降低内存占用,代价是锁竞争略微增加。
3.4 一个真实的分配路径追踪
光看理论不够,我用 ltrace 跟了一次 malloc 的实际调用:
ltrace -e 'malloc+free+brk+mmap+munmap' ./my_program输出大致是:
malloc(100) = 0x55f3a3f8a2a0 malloc(200000) = 0x7f8b34000010 free(0x7f8b34000010) = <void>可以看到,100 字节的分配返回的地址在0x55f3...段,属于堆区;200000 字节(约 195KB,超过 128KB)返回的地址在0x7f8b...段,属于 mmap 区。free 掉大块后,munmap 会把它还给内核。
这个实验很直观地验证了阈值机制。你可以自己写个小程序,分别 malloc 不同大小的内存,打印地址,观察它们落在哪个段。
4. 分页机制:虚拟地址到物理地址的翻译过程
4.1 页表:地址翻译的核心数据结构
虚拟地址不是直接对应物理地址的,中间要经过页表翻译。x86-64 用四级页表(新 CPU 支持五级),每级页表有 512 个条目,每个条目 8 字节,正好占一页(4KB)。
一个 48 位虚拟地址被拆成五段:
- 位 47-39:PGD 索引(9 位)
- 位 38-30:PUD 索引(9 位)
- 位 29-21:PMD 索引(9 位)
- 位 20-12:PTE 索引(9 位)
- 位 11-0:页内偏移(12 位)
翻译过程就是逐级查表:从 CR3 寄存器拿到 PGD 基址,用最高 9 位索引找到 PUD,再用次 9 位找到 PMD,再找到 PTE,最后 PTE 里存的就是物理页帧号,加上页内偏移就是物理地址。
这个过程听起来慢,但 CPU 有 TLB(Translation Lookaside Buffer)缓存最近的翻译结果。TLB 命中就一步到位,不命中才走完整页表遍历。所以性能优化的关键之一就是提高 TLB 命中率。
4.2 缺页异常:内存不是一开始就给你的
很多人以为 malloc 返回指针就代表内存到手了,其实不是。malloc 只是拿到了虚拟地址,物理页要等到第一次访问时才分配,这叫按需分页(demand paging)。
第一次访问某个虚拟页时,CPU 发现 PTE 无效,触发缺页异常,内核介入:
- 检查地址是否合法(在某个 VMA 范围内)。
- 分配一个物理页。
- 如果是文件映射,从磁盘读数据;如果是匿名映射,清零。
- 更新 PTE,建立映射。
- 返回用户态,重新执行那条指令。
这就是为什么malloc大块内存后,RSS 不会立刻涨——物理页还没分配。只有真正写入了,才触发缺页,RSS 才上去。
提示:这个特性可以用来做"内存预留"——先 malloc 一大块,但不写,实际占用几乎为零。不过要注意 overcommit 设置,如果内核开了严格模式,malloc 可能直接失败。
4.3 大页:减少 TLB miss 的利器
标准页是 4KB,一个 2MB 的 TLB 条目能覆盖 512 个 4KB 页。如果程序访问的内存跨度大、随机性强,TLB miss 会很频繁,每次 miss 都要走四级页表,开销不小。
大页(Huge Page)把页大小提到 2MB 甚至 1GB,一个 TLB 条目覆盖的范围大 512 倍,TLB miss 大幅减少。对于数据库、内存缓存、科学计算这类大内存随机访问的场景,收益非常明显。
Linux 提供两种大页:
- 显式大页(HugeTLB):需要预先在
/proc/sys/vm/nr_hugepages里预留,用mmap加MAP_HUGETLB标志申请。 - 透明大页(THP):内核自动把符合条件的 4KB 页合并成 2MB 大页,无需改代码。
THP 用起来省事,但有个坑:合并过程会有延迟,且可能造成内存浪费(一个 2MB 大页里只要有一字节被用到,整页就占 2MB 物理内存)。我一般建议对延迟敏感的服务关闭 THP:
echo never > /sys/kernel/mm/transparent_hugepage/enabled对内存密集型且访问模式规整的服务,则可以考虑开启。
4.4 用 perf 观察 TLB 性能
想知道你的程序 TLB 表现如何,用 perf 直接看:
perf stat -e dTLB-loads,dTLB-load-misses,dtlb_store_misses ./my_program如果dTLB-load-misses占dTLB-loads的比例超过 1%,就值得关注了。我实测过一个遍历大数组的程序,随机访问模式下 miss 率能到 5% 以上,改成顺序访问后降到 0.1% 以下,性能差了将近一倍。
5. 实操:从零观察一个进程的地址分配全过程
5.1 写一个能暴露分配行为的测试程序
理论讲再多不如动手。下面这个程序故意制造各种分配场景,方便观察:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main() { printf("PID: %d\n", getpid()); // 场景1:小块分配,走 brk char *small = malloc(64); printf("small (64B): %p\n", small); // 场景2:大块分配,走 mmap char *large = malloc(256 * 1024); printf("large (256KB): %p\n", large); // 场景3:触发缺页,观察 RSS 变化 printf("before touch, check RSS...\n"); sleep(5); memset(large, 0xAA, 256 * 1024); printf("after touch, check RSS...\n"); sleep(5); // 场景4:释放大块,观察是否归还内核 free(large); printf("after free large...\n"); sleep(5); free(small); return 0; }编译运行后,在另一个终端里反复执行:
while true; do grep -E 'VmRSS|VmSize' /proc/<PID>/status sleep 1 done你会看到:malloc 大块后 VmSize 涨了,但 VmRSS 没怎么动;memset 之后 VmRSS 才涨上去;free 之后 VmRSS 又降下来。这就是按需分页和 mmap 归还的直观体现。
5.2 用 strace 追踪系统调用
想看得更细,用 strace 跟系统调用:
strace -e trace=brk,mmap,munmap -f ./my_program输出里能看到每次 brk 的调整和 mmap 的映射。注意观察 brk 的返回值——它返回的是新的堆顶,如果返回值比请求的小,说明扩展失败。
5.3 关键参数的计算过程
假设你的服务需要频繁分配 100KB 左右的对象,默认阈值 128KB 意味着它们都走 brk,堆会持续增长且难以归还。这时候有两个选择:
方案一:调低 mmap 阈值到 64KB
export MALLOC_MMAP_THRESHOLD_=65536这样 100KB 的分配走 mmap,释放时能归还内核。代价是 mmap/munmap 调用变多,每次约 1-2 微秒开销。如果分配频率是每秒 1 万次,额外开销约 10-20 毫秒/秒,可以接受。
方案二:用内存池自己管理
预分配一大块,自己切分复用。适合对象大小固定、生命周期可控的场景。这是最彻底的方案,但需要自己处理碎片和线程安全。
怎么选?我的经验是:分配频率高、对象大小固定,用内存池;分配模式不规则、大小差异大,调阈值。两者也可以结合。
6. 常见问题与排查技巧实录
6.1 内存问题速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| RSS 持续增长不降 | 堆碎片 / arena 过多 | 看 maps、调 MALLOC_ARENA_MAX |
| 进程突然 SIGSEGV | 堆与 mmap 区撞车 / 野指针 | core dump + maps 对比 |
| malloc 返回 NULL | 地址空间耗尽 / overcommit 限制 | 看 VmSize、检查 ulimit |
| 内存分配变慢 | 频繁 mmap/munmap / 锁竞争 | perf + strace 观察 |
| TLB miss 高 | 随机访问大内存 / 未用大页 | perf stat 看 dTLB |
6.2 几个我踩过的坑
坑一:以为 free 了就一定还内存。实际上只有 mmap 分配的大块 free 后才 munmap 归还;brk 分配的小块 free 后只是回到分配器的空闲链表,堆顶不降就不还内核。所以一个长期运行、频繁分配释放小块的服务,RSS 可能一直很高,但这不是泄漏,是分配器的正常行为。
坑二:多线程下 arena 数量失控。一个 32 核机器上的多线程服务,默认 arena 上限是 256 个,每个 arena 至少占 64MB 虚拟空间(实际 RSS 看使用量)。我见过一个服务 VmSize 高达几十 GB,排查半天发现是 arena 太多。设MALLOC_ARENA_MAX=4后 VmSize 直接降到几 GB。
坑三:THP 导致的延迟毛刺。一个延迟敏感的服务,P99 延迟偶尔飙到几百毫秒。最后发现是 THP 在后台做页合并(khugepaged),占用了 CPU 和内存带宽。关掉 THP 后毛刺消失。
坑四:误判内存泄漏。用 valgrind 跑出来一堆"still reachable",其实很多是 glibc 自己缓存的内存,不是泄漏。判断真泄漏要看"definitely lost",且要结合业务逻辑确认。
6.3 排查内存问题的标准流程
我总结了一套流程,基本能覆盖 90% 的场景:
- 看 /proc/PID/status:VmSize、VmRSS、VmData 三个值先记下来。
- 看 /proc/PID/maps:数一下映射区域数量,看有没有异常大的匿名映射。
- 看 /proc/PID/smaps:每个映射的详细统计,包括 RSS、PSS、共享/私有。
- 用 pmap -x:更友好的 maps 展示。
- 对比不同时间点:找出增长的区域。
- 结合代码定位:找到对应的分配点。
这套流程走下来,大部分内存问题都能定位到具体区域。剩下的就是看代码了。
7. 写在最后:一些个人体会
虚拟地址分配和分页这套机制,平时藏在操作系统和 glibc 背后,不显山不露水。但一旦你的程序规模上去、并发上来、内存用量变大,它就会以各种意想不到的方式跳出来影响你——可能是性能毛刺,可能是诡异崩溃,可能是 RSS 虚高。
我的建议是:不要等到出问题才去学。平时写代码时就有意识地想一想,这个分配会走 brk 还是 mmap,这块内存什么时候会真正占用物理页,这个访问模式对 TLB 友好不友好。养成这个习惯后,很多问题在写代码阶段就能避免。
另外,工具一定要熟练。/proc下的那几个文件、perf、strace、valgrind,这几个组合起来基本能解决绝大多数内存相关问题。工具不熟,理论再懂也是纸上谈兵。
最后分享一个小技巧:如果你怀疑某个进程有内存问题,但又不确定是分配器行为还是真泄漏,可以临时设MALLOC_ARENA_MAX=1再跑一遍。如果内存曲线明显变好,那大概率是 arena 的问题,不是泄漏。这个技巧帮我省过好几次通宵排查。