前阵子有个同事把 htop 截图发我,一脸困惑:“这进程 RSS 已经 2.6GB 了,可代码里明明只 new 了不到 200MB 的数据,剩下那 2GB 是从哪冒出来的?”我让他把 /proc/<pid>/smaps_rollup 拉出来看了一眼,答案其实就藏在 Shared 和 Private 的比值里。类似的问题我这些年见过太多:malloc 之后 RSS 没动、free 之后 RSS 不动、容器里看内存高得吓人却不知道是谁干的。搞懂 Linux 进程内存,不是为了应付面试,而是为了在线上出问题时能在一分钟内缩小怀疑范围。
这篇文章我会从虚拟地址空间、页表映射、malloc 的 brk/mmap 策略、/proc 接口这些底层机制一层层拆开,最后用一个“RSS 只涨不降”的真实排查过程把你串起来。适合刚接触 Linux 内存管理的开发、运维,也适合那些已经踩过坑但没时间系统梳理的人。
1. 虚拟地址空间:每个进程都拥有一座“看得见摸不着”的城市
1.1 先分清账面与实账
进程眼里看到的“内存”和物理内存条上的内存,是两回事。每个进程从地址 0x0 开始,拥有一段连续、独立、私有的虚拟地址空间,仿佛整台机器只有它一个程序在跑。这份光鲜的“账面”由内核提供,而物理内存条才是“实账”。
虚拟地址空间的独立性有两大好处:一是进程间天然隔离,A 进程的地址 0x7ff00000 和 B 进程的 0x7ff00000 互不相干;二是让编译器和链接器不用关心程序最终被物理加载到哪里,统一从 0x400000 或 0x555555554000 这类固定基址开始布局即可。
x86-64 在四级页表下共有 48 位有效虚拟地址,其中低半部分(0x0000000000000000 ~ 0x00007fffffffffff)给用户态,高半部分(0xffff800000000000 以上)预留给内核。用户态这 128TB 空间,对于一个普通服务进程来说基本是“取之不尽”的。
1.2 从低地址到高地址的布局
一个典型的 Linux 用户进程,虚拟地址空间从低到高大致是这样的:
| 区域 | 典型位置(x86-64,受随机化影响) | 作用 |
|---|---|---|
代码段 .text |
0x400000 或 0x555... 附近 | 可执行的机器指令,来自 ELF 文件 |
只读数据 .rodata |
紧随代码段 | 字符串常量、跳转表等只读数据 |
数据段 .data / .bss |
紧随其后的低地址 | 已初始化全局变量 / 未初始化占位变量 |
| 堆(heap) | 数据段之上,向上增长 | brk/sbrk 管理的动态内存区域 |
| mmap 区 | 栈之下,向下增长 | 共享库、文件映射、匿名内存映射 |
| 栈(stack) | 接近地址空间顶端,向下增长 | 函数调用帧、局部变量 |
| vdso/vvar/vsyscall | 最高地址附近 | 内核暴露给用户态的一些辅助函数 |
注意,这个布局中的地址具体数值在不同内核版本、不同 ASLR 随机化策略下会不一样,但相对顺序是稳定的。共享库通常落在 mmap 区,这也是为什么 pmap 里你能看到一堆带 .so 路径的映射集中在相近的地址范围内。
1.3 中间那片“空洞”是什么
看图时你会发现:堆和 mmap 区之间、mmap 区和栈之间,有大片没有任何映射的地址区间。这就是“未映射”的地址,程序一旦访问就会触发段错误(SIGSEGV)。
这片空洞不是浪费,而是虚拟内存的“自由区”:栈往下伸、堆往上顶、mmap 往下铺,谁都不会撞到谁。更重要的是,空洞是“虚”的,不消耗任何物理资源;只有当你真正访问了某个地址,内核才会把对应的物理页准备好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 物理内存何时真正进场:页表与缺页机制
2.1 页表:虚拟地址与物理页的翻译官
虚拟地址要落到物理内存,必须通过页表翻译。Linux 使用的是多级页表:x86-64 下,一次地址翻译会依次经过 PML4 → PDPT → PD → PT 四张表,最后定位到一个 4KB 的物理页。
多级结构有个精妙之处:如果某段虚拟地址空间从未被使用,对应的中间级目录可以直接留空,省掉的不仅是页表项,还有一整棵子树占用的物理内存。对大多数进程来说,128TB 的虚拟空间里真正被映射起来的只有很小一部分,多级页表把这部分开销压到了最低。
页表也支持大页。x86-64 下普通页 4KB,系统还提供 2MB 和 1GB 的大页。使用大页可以减少页表层级、降低 TLB miss,代价是内存分配粒度变大。透明大页(THP)会在后台尝试把连续的 4KB 页合并成 2MB 大页,这也是为什么你有时会看到某个进程 RES 突然跳了好几 MB——那可能不是业务高峰,而是 THP 合并了页面。
2.2 缺页:malloc 之后为什么 RSS 不动
这是很多人第一次接触虚拟内存时的震撼瞬间:malloc(1GB) 返回后,进程的 VmSize(虚拟内存)立刻涨了 1GB,但 VmRSS(物理驻留)纹丝不动。其实非常合理:malloc 只是修改了进程的虚拟地址布局,告诉内核“这块地址我要用了”,内核懒洋洋地在进程的 VMA 链表里记了一笔,并没有真正分配物理页。
物理内存是在第一次访问时才真正分配的,也就是缺页异常。写一个经典的实验程序就能看得清清楚楚:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void show_status(const char *tag) {
FILE *f = fopen("/proc/self/status", "r");
if (!f) return;
char line[256];
printf("=== %s ===\n", tag);
while (fgets(line, sizeof(line), f)) {
if (!strncmp(line, "VmSize:", 7) ||
!strncmp(line, "VmRSS:", 6) ||
!strncmp(line, "VmData:", 7)) {
fputs(line, stdout);
}
}
fclose(f);
}
int main(void) {
size_t len = 1024UL * 1024UL * 1024UL; // 1 GiB
char *p = malloc(len);
if (!p) return 1;
show_status("after malloc, before touch");
for (size_t i = 0; i < len; i += 4096) {
p[i] = 1; // 每 4KB 页写一个字节
}
show_status("after touching every page");
getchar();
free(p);
return 0;
}
实测输出大概是这样的:
code复制=== after malloc, before touch ===
VmSize: 1251704 kB
VmRSS: 2304 kB
VmData: 1054728 kB
=== after touching every page ===
VmSize: 1251704 kB
VmRSS: 1053692 kB
VmData: 1054744 kB
malloc 之后 VmSize 涨了 1GB,VmRSS 几乎没动;把这一页页“摸”一遍之后,RSS 才跟上。这就是按需分配(lazy allocation)的直观体现。缺页又分两种:如果目标物理页已经在内存里(比如从 page cache 读取文件页),只需要更新页表,叫 minor fault;如果内容必须从磁盘读入,叫 major fault。任务丢给内核后,分配物理页、清零、建立映射,然后重新执行那条访问指令,整个流程对用户态完全透明。
2.3 RSS、VSZ、PSS、Shared/Private 到底怎么读
既然聊到了 RSS,顺便把几个容易混淆的指标一次说清:
| 指标 | 全称/含义 | 常见误区 |
|---|---|---|
| VSZ / VmSize | 虚拟内存大小,进程的地址空间总大小 | 高不代表占用高,很多是“空洞” |
| RSS / VmRSS | 进程当前驻留在物理内存中的页总量 | 含与其他进程共享的库页,并非独占 |
| PSS | Proportional Set Size,共享页按进程数平分后的驻留 | 更接近单个进程的真实成本 |
| VmData | 数据段 + 堆等私有数据区域的大小 | 跟踪分配活动很直观,但精确性不如 smaps |
| VmSwap | 已被换出(swap)到磁盘的匿名页 | RSS 看着低不代表没占用地址空间 |
RSS = Shared + Private,而 Private 又分 Private_Clean 和 Private_Dirty。其中 Private_Dirty 是最值得盯的值:它表示进程私有的、内容已经修改过的物理页,和别的进程没有任何共享关系,基本等同于“这个进程独占的真实成本”。买了台 4GB 内存的机器,同时跑四五个服务,想知道每个服务实际分走多少钱,看 PSS 比看 RSS 靠谱。
3. malloc 的内存调度:brk 与 mmap,以及 free 之后的“静默”
3.1 brk:经典堆的一根“伸缩棍”
进程传统上通过 brk/sbrk 系统调用来扩大堆区。堆的本质是一个程序断点(program break),位于数据段之后;brk(addr) 把这个断点移到新地址,堆就向上长或向下缩。
这种方式优点是快、简单,分配小块内存开销极低。缺点也很明显:它只能在堆顶做“伸缩”,中间的空闲块即便释放了,只要不是堆顶那块,整个堆顶就没法缩回来。这就埋下了一个后来困扰很多人的伏笔:小块内存 free 之后,RSS 往往降不下来。
3.2 mmap:大块分配的独立王国
对于较大的内存分配,glibc 会直接调用 mmap,创建一段 MAP_PRIVATE | MAP_ANONYMOUS 的匿名映射。每个这样的映射都是独立的一段地址区域,互不干扰;释放时调用 munmap,这段区域可以立刻整体交还内核,RSS 也随之下降。
这里有个动态阈值:glibc 默认对大块分配(MMAP_THRESHOLD,通常是 128KB)走 mmap,但内核会根据程序近期的释放行为动态调整这个阈值。如果程序频繁分配又释放大块,阈值会被抬高,后续同类分配改走 brk 以避免频繁建立/拆除映射的成本;反过来,如果 mmap 分配的块释放得很干净,阈值也可能回降。所以不要以为“大于 128KB 就一定走 mmap”,这是动态变化的。
3.3 free 之后为什么 RES 居高不下
很多人在线上查“内存泄漏”,查了半天并没找到增长点,其实问题出在分配器策略上。小块内存 free 以后,glibc 只是把它丢进空闲链表供后续重用,并不归还内核;如果日后 malloc 的量刚好能命中这些空闲块,内存使用就维持在一个“水位”。只有当空闲块实在用不完、堆顶又允许收缩时,brk 才会把一部分还给内核。
多线程场景更隐蔽。glibc 为了让每个线程的 malloc/free 尽量不互相抢锁,会创建多个 arena(分配区),每个线程绑定到其中一个 arena 上。arena 数量默认有上限,一般是 CPU 核数的若干倍;每个 arena 都可能有自己的堆空间,碎片也会留在各自 arena 里。线程特别多的服务,内存经常被这些 arena 瓜分掉一大块。
我处理过的一个某服务就是这个路数:核心业务线程几十个,RSS 稳定在 2.8GB,业务数据本身不超过 1GB。我把环境变量 MALLOC_ARENA_MAX=2 加上后重启,内存直接掉到 1.6GB 左右,服务行为没有任何变化。这类“伪内存浪费”不解决,加内存只是拖延问题。
bash复制export MALLOC_ARENA_MAX=2
这句简单,但线上改环境变量后记得做好 A/B 对比,确认业务性能不受影响再长期启用。
4. 给进程内存做一次全面体检:/proc 和命令行工具
4.1 /proc//maps 与 smaps:逐段看清地址空间
排查内存问题,/proc/<pid>/maps 是第一站。它把整个虚拟地址空间按映射段一行行列出来:
code复制562000000000-562000100000 r-xp 00000000 08:01 1234567 /usr/bin/somebin
562000100000-562000200000 r--p 00001000 08:01 1234567 /usr/bin/somebin
562000200000-562000300000 rw-p 00002000 08:01 1234567 /usr/bin/somebin
7f2c00000000-7f2c00001000 r--p 00000000 08:01 7654321 /usr/lib/some_lib.so
7f2c40000000-7f2c40100000 rw-p 00000000 00:00 0 [heap]
7ffd80000000-7ffd80010000 rw-p 00000000 00:00 0 [stack]
每行六列分别是:起始地址-结束地址、权限、文件偏移、主设备号:次设备号、inode、映射对象。权限中的 r/w/x/p/s 最后一位如果是 p,是私有映射;s 是共享映射,非常有用。
maps 只告诉你“有哪些段”,想知道每段占了多少物理内存,得看 smaps。每个映射段下面有一堆字段:Rss、Pss、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty、Swap、Anonymous 等。文件很长,但往往并不需要逐段读,直接看汇总即可。
Linux 4.14+ 提供了 /proc/<pid>/smaps_rollup,把整个进程的字段汇总成一份,一条命令就能拿到全局统计:
bash复制awk '/^Rss:/{rss+=$2}
/^Shared_Clean:/{sc+=$2}
/^Shared_Dirty:/{sd+=$2}
/^Private_Clean:/{pc+=$2}
/^Private_Dirty:/{pd+=$2}
/^Swap:/{swap+=$2}
END{printf "Rss=%dMB Shared=%dMB Private_Dirty=%dMB Swap=%dMB\n",
rss/1024, (sc+sd)/1024, pd/1024, swap/1024}' \
/proc/<pid>/smaps_rollup
4.2 高频命令选型对比
| 命令/文件 | 核心能力 | 典型场景 | 注意点 |
|---|---|---|---|
top/htop |
全局概览,RES/VSZ 实时刷新 | 第一眼确定嫌疑进程 | RES 含共享页,容易被骗 |
pmap -x <pid> |
每个映射段的 RSS 与 Dirty | 判断增长在 heap 还是匿名 mmap | 大进程输出很长,可配排序 |
/proc/<pid>/smaps_rollup |
进程级汇总与字段拆分 | 区分共享/私有/换出 | 内核版本过老时不可用 |
smem |
按 PSS/USS 排序多进程 | 评估多进程真实内存成本 | 部分字段需要 root |
pidstat -r 1 |
每秒 RSS 变化与缺页 | 观察内存增长趋势 | 需要 sysstat 包 |
4.3 持续跟踪内存曲线的采样脚本
排查内存问题最怕“回头看”——现象发生时你不在现场。我的习惯是让监控先跑起来。写个简单的采样循环,把关键字段落到文件里,回头一画趋势线,很多问题就清楚了:
bash复制while true; do
ts=$(date +%H:%M:%S)
vals=$(awk '/VmRSS|VmData|VmSwap/ {printf "%s=%s ", $1, $2} END {print ""}' /proc/<pid>/status)
echo "$ts $vals" >> /tmp/mem_track.log
sleep 1
done
这种手段虽然简陋,但比“事后查看当前值”有用得多。现场采上几分钟,增长速率、是否回落、是否伴随 Swap 升高,全部一目了然。真实场景里我靠这个脚本抓到过不少“定时任务每小时涨 200MB 但不回收”的问题。
5. 排查实例:某服务 RSS 只涨不降的完整链路
5.1 第一层:先拆共享与私有,确定是不是“自己人”
前一阵子有个服务报告“内存泄漏”:RSS 从 1.2GB 一路爬到 4.3GB,重启后过一天又涨回去。我先没急着看代码,而是把 smaps_rollup 拉了出来:
code复制Rss=4300MB Shared=1500MB Private_Dirty=2700MB Swap=0MB
Private_Dirty 在肉眼可见地增长——这不是 page cache 或共享库的锅,是进程自己持有的私有内存一直在涨。共享部分被排除,怀疑范围瞬间从“整个系统”缩小到“这个进程的堆和匿名映射”。
5.2 第二层:heap 还是 mmap,用 pmap 定向
接下来用 pmap -x <pid>,按 RSS 从大到小排序看:
bash复制pmap -x <pid> | sort -k3 -n -r | head -20
如果最上面是 [heap],说明增长主要来自 brk 堆,也就是 glibc 小块分配的路子;如果最上面是 7f... 开头没有路径名、权限 rw-p 的大段匿名映射,说明走了 mmap。实测看到 [heap] 和一堆大块匿名映射同时在涨。
这里有个判断技巧:brk 堆的增长往往和分配器碎片、arena 膨胀有关;匿名 mmap 的增长则更可能来自业务层的大块对象、缓存、线程栈或各类运行时内部结构。
5.3 第三层:分配器统计与代码确认
为了确认不是分配器“囤内存”,我写了个十几行的 C 小程序,调用 glibc 的 malloc_info 导出当前所有 arena 的状态:
c复制#include <stdio.h>
#include <malloc.h>
int main(void) {
malloc_info(0, stdout);
return 0;
}
从导出的 XML 里读出的各 arena 总分配量加起来,远小于 2700MB 的 Private_Dirty。这说明增长不是 glibc 空闲缓存堆出来的,而是业务代码确实申请并持有大量内存不释放。
接下来用 valgrind --tool=massif 跑了一个压测副本,ms_print 打出来后发现是某个运行时库的内部缓存——容量和业务并发量成正比,且只增长、从不淘汰。问题定位后,一件很小的事:给这个缓存加一条基于容量的淘汰策略,RSS 曲线就平了。
5.4 一套可以反复套用的排查顺序
| 步骤 | 动作 | 判读标准 |
|---|---|---|
| 1 | 看 smaps_rollup 的 Shared/Private_Dirty 比例 | Private_Dirty 涨才是应用自己的锅 |
| 2 | pmap -x 排序,锁定 heap 还是匿名 mmap | [heap] 与 7f... 匿名段谁在涨 |
| 3 | 采样 VmData/VmRSS 趋势,确认是否单调递增 | 周期性回落多半是缓存/清理逻辑问题 |
| 4 | malloc_info / massif 区分分配器缓存与真实持有 | 分配器总量远小于 RSS,说明业务持有 |
| 5 | 回到代码定位引用关系,确认为何不释放 | 修复后跑压测,对比 Private_Dirty 曲线 |
这套流程虽然针对的是“RSS 只涨不降”,但换成“容器内存居高不下”“Swap 异常增长”也适用。核心思想就一条:每步只回答一个最小问题,逐步收窄怀疑面。
6. 这些年攒下的几个经验和一直保留的小习惯
6.1 别单独盯着 RES 下结论
RES 是一堆概念的混合体:共享库、page cache、私有匿名页、THP 合并页,全都算在里面。看到 RES 高先别急着喊泄漏,花十秒钟拆一下共享和私有,结论往往完全不一样。我见过最典型的一个案例是:某服务 RES 常年 1.8GB,但 PSS 只有 300MB,其余全是和其他几十个进程共享的同一批常用共享库和配置映射。这种“虚高”根本不是问题。
6.2 容器里“cache 撑满”不一定是泄漏
很多人排查容器内存超限,一进容器随手 free -h 发现 cache 占了几个 G,就以为是内存泄漏。其实 cgroup 的内存统计把 page cache 也算在了总占用里。解法是先看 memory.stat 里的 anon 与 file 分项:anon 高才是真持有,file 高说明是文件缓存,内核在你需要内存时会回收它们。只有 dirty 页写不出去另当别论。所以容器内存限额设得离需求太近,又开了大量文件读写,经常会出现被 cache 顶到限额的误伤。
6.3 小习惯:跟踪峰值而不是瞬时值
有些内存问题是瞬时的,比如大请求瞬间分配了 2GB,处理完就释放了。你事后去 top 看一眼,可能一切正常。我的做法是两类工具配合:一次性程序用 /usr/bin/time -v 直接拿到 Maximum resident set size,常驻服务用开头那种 while 采样脚本记到日志。峰值出现过、趋势一直在,内存问题就逃不掉。
另外,排查内存问题的时候尽量把 THP 的影响放在心里:如果 smaps 里能看到 AnonHugePages,说明 THP 正在把零散的匿名页合并成 2MB 大页。这时 RSS 的陡增不一定代表业务分配量陡增,只是内核帮你调整了大页。在高延迟敏感的数据库类服务里,我通常建议先把 THP 关掉再谈内存优化,否则曲线跳变的解释成本很高。
我实际操作中的体会是:Linux 进程内存看似复杂,其实就那几件事——虚拟地址是账面的,物理页是实付的,malloc 是中介,分配器是缓存,内核是兜底。大部分“异常内存”都藏在这几层之间的灰色地带。把这层窗户纸捅破之后,再看到 htop 里那个 2.6GB 的 RES,你不会再觉得诡异,而是会下意识地先问一句:它是 Shared 还是 Private,是堆还是 mmap,是缓存还是真持有。这三个问题问完,问题基本就浮出水面了。
