做Linux底层开发和运维这些年,我越来越觉得"虚拟地址空间"是所有内存问题的主干道。很多看起来古怪的现象——进程明明占着几十GB虚拟内存,RSS却不涨;fork之后父进程改一个变量,子进程毫无感觉;malloc返回了指针,一访问就段错误——根子都在虚拟地址空间这张映射关系图上。我之前排查过不少线上内存问题,每次绕来绕去最后都要回到这里,把进程地址空间里的每一段VMA理清楚,问题才真正浮出水面。
这篇文章把虚拟地址空间这个主题拆开揉碎来讲:为什么每个进程都活在一套"假地址"里、x86-64下进程的地址地图长什么样、虚拟地址和物理地址是怎么翻译的,以及用哪些实用工具能给进程做一次地址空间体检。适合正在学系统编程的初学者,也适合写服务端程序但是被内存问题折磨过的开发者。全文配合我实际排查问题时踩过的坑,尽量把底层机制和线上现象对应起来,让你看完之后遇到类似问题能有一个清晰的排查入口。
1. 为什么每个进程都活在一套"假地址"里:隔离、连续与按需分配
1.1 如果没有虚拟地址空间,进程会互相踩踏
先做一个思想实验:假设CPU直接拿着程序里的地址去访问物理内存。进程A在地址0x1000写了一个值,进程B同一时刻也在0x1000写另一个值,那物理内存的同一个单元就会被互相覆盖。更要命的是,每个程序编译时根本不知道物理内存里哪些区域是空闲的,它只能假设"地址0x400000就是我的地盘",这个假设在单进程系统里勉强成立,在多进程系统里就是灾难。
虚拟地址空间的核心思路就是:每个进程看到的都是自己的一套连续地址,从0开始往上铺开;这个地址不是真实的物理位置,而是一个"门牌号"。进程拿着门牌号去访问内存,内核和硬件(MMU)负责把这个门牌号翻译成真实的物理房间号。这样一来,进程A的0x1000和进程B的0x1000物理上根本不在同一个地方,互不干扰。每个进程都觉得自己独占了一整台机器,这是虚拟地址空间存在的第一价值。
我在实际工作中经常用一个类比:虚拟地址空间就像酒店的房间号,所有房卡上写的都是"302"、住客以为整层只有自己,但前台知道302实际对应哪一间,而且不同客人拿到的302可能根本不是同一个房间。客人和客人之间永远不会撞在一起。
1.2 隔离是第一诉求:地址空间就是进程的"私人领地"
隔离的价值不只是安全,更多的是稳定性。一个进程要崩溃就崩它自己,不能把整台机器拖下水。虚拟地址空间划清了边界:进程A永远没有权限去访问进程B的地址,除非双方显式地通过共享内存、文件映射等方式交换物理页。就算A的代码里写了恶意逻辑或者有野指针,它顶多在自己那块地界里撒野,碰到不属于自己的VMA区域,CPU直接触发异常,内核就会用SIGSEGV终结这个进程。
这里补充一个细节:虚拟地址空间隔离和权限检查是同时做的。每一个内存区域(内核里叫VMA,Virtual Memory Area)都有单独的权限位,标记可读、可写、可执行。所以"地址不在我的地址空间里"和"地址在我空间里但没权限"是两种不同的异常来源,前者是越界,后者是权限违规。排障的时候从dmesg里看segfault信息时,这两类我都会留意,帮助区分是逻辑写坏了指针还是真的踩到了别人的地盘(实际上进程A永远踩不到B的地盘,只会踩到未映射的洞)。
1.3 连续假象与按需分配:虚拟空间让你以为拿到了整块内存
物理内存的分配是非常碎片化的。不同时间申请、释放,物理页框会像拼图一样东一块西一块。如果没有虚拟地址空间,一个程序想申请一块1GB的连续缓冲区,很可能物理内存里根本凑不出这么大一块连续区域,申请直接失败。虚拟地址空间把这件事彻底抹平了:进程看到的是连续的虚拟地址,底层物理页可以散落在任意位置,只要页表能把它们串起来就行。程序根本不需要知道自己用到的物理页长什么样。
更有意思的是按需分配。进程malloc(1GB)之后,内核只是帮你把一块1GB的虚拟地址范围标记为"可用",并不会真的找256K个物理页(按4KB页算)放进去。真正访问某个页的那一刻才会触发缺页异常,内核才分配物理页。这就是为什么你看到进程的虚拟内存(VSZ)涨了一大截,但实际物理内存占用(RSS)纹丝不动。这个机制叫"惰性分配",它让几个进程加起来可以"吃掉"超过物理内存总量的虚拟内存,反正多数程序申请了大块内存也不会马上全用。
提示:这里有个常见误区——用
top看进程内存时,VSZ大不代表物理内存占用高。真正需要考虑物理内存压力的时候,要看RSS,或者更精确地看/proc/pid/smaps里的PSS。
2. x86-64下的一张完整进程地址地图:从代码段到栈顶
2.1 用户态与内核态的领地划分,以及那道不可逾越的鸿沟
x86-64的虚拟地址空间被清晰地划成了两半:低地址部分(0x0000000000000000到0x00007fffffffffff)是用户空间,高地址部分(0xffff800000000000往上)是内核空间。用户进程的地址空间通常只关心前128TB,内核区域对用户态程序来说完全不可访问。CPU的权限级机制(ring 0 / ring 3)与页表项的权限位配合,保证用户代码只要一跳进内核地址就会被CPU拦截,只有通过系统调用才能规范性进入内核。
中间那块巨大的空洞不是浪费,而是x86-64地址编码的特性。用户态程序里即使出现野指针,也非常难跳到内核区,因为那超过canonical地址范围后CPU直接认为是非法地址。我遇到过一次比较隐蔽的问题:一个C程序里指针被写坏成了0xffff880000000000这种内核区地址,结果程序不是段错误,而是直接卡在某种奇怪的状态里,后面查下来是某个驱动模块的地址从内核空间泄漏到了用户空间。
2.2 从低地址到高地址:代码段、数据段、堆、mmap区、栈的分布
一个典型的Linux进程,地址空间从低到高大概长这样:
- 代码段(.text):包含编译后的机器指令,从
0x400000(非PIE)或0x555555554000(PIE)附近开始。只读可执行,程序想改写自己的指令会触发段错误。 - 只读数据段(.rodata):字符串字面量、常量表等,只读。
- 数据段(.data):已初始化的全局变量和静态变量,可读写。
- BSS段(.bss):未初始化的全局变量,不占文件空间,映射进来时全零。
- 堆(heap):低地址向高地址增长,
brk/sbrk扩展的经典区域。glibc的小块malloc很多在这里。 - 内存映射区(mmap):动态库、共享内存、匿名映射、大块malloc(超过
MMAP_THRESHOLD默认128KB时)都在这个区域,地址通常在0x7f...开头。 - 栈(stack):高地址向低地址增长,局部变量、函数调用栈、环境变量、命令行参数。栈顶附近还有一个随机偏移。
- vsyscall/vdso:内核提供的一些无需陷入内核的虚拟系统调用入口,也在高地址附近。
为什么堆向上、栈向下?因为这样设计可以让两者共享中间的空白区——理论上堆和栈各自往中间挤,直到撞上。实际运行中中间还有mmap区域,并不会真正撞在一起,但这种"相向而行"的设计让地址空间利用率更高。ASLR开启后,代码段、堆、栈、mmap区的位置每次启动都可能不同,这也是为什么你能看到每次运行程序打印出来的地址都不太一样。
2.3 串起布局:一个真实的小C程序实验
理论上说再多,不如自己看一眼。写一个最简C程序,打印各段地址:
#include <stdio.h> #include <stdlib.h> int global_data = 42; // .data char *global_bss; // .bss int main(void) { int stack_var = 0; // 栈 char *heap_var = malloc(128); // 堆 char *mmap_var = malloc(200 * 1024); // 超过阈值走mmap printf("code : %p\n", (void*)main); printf("rodata : %p\n", (void*)"hello"); printf("data : %p\n", (void*)&global_data); printf("bss : %p\n", (void*)&global_bss); printf("heap : %p\n", (void*)heap_var); printf("mmap : %p\n", (void*)mmap_var); printf("stack : %p\n", (void*)&stack_var); return 0; }编译运行后,你会看到代码段地址在0x55...附近,堆在0x55...后面不远,mmap区域直接跳到0x7f...,栈在0x7ffe...或者0x7fff...附近。这个地址分布基本就是所有Linux进程的缩影。把这几个地址和下面要介绍的/proc/pid/maps对应起来,一张图就能在脑子里建立起来。
注意:如果你期望看到堆紧贴着BSS后面一点点,但程序里先做了大块malloc,
[heap]区域可能反而不明显,因为大块分配走了mmap。这是glibc的分配策略,后面章节会详细讲。
3. 虚拟地址到物理地址的翻译过程:页表、TLB与缺页中断的配合
3.1 页与页表:翻译的最小单位是4KB
虚拟地址不是按字节单独翻译的,而是按"页"为单位。x86-64下默认页大小是4KB,也支持2MB大页和1GB巨页。一个虚拟地址被拆成两部分:虚拟页号 + 页内偏移。页表项(PTE)里记录了虚拟页号对应的物理页框号,同时还有一组长标志位:Present(物理页是否在内存)、Read/Write、Execute、User/Supervisor、Dirty、Accessed等。
翻译过程就是:CPU拿着虚拟页号去查页表,找到对应的物理页框号,然后加上页内偏移,拼出物理地址。整个过程由MMU硬件完成,每次访问内存都要翻译一遍。如果页表项里的Present位是0,CPU就会触发一次缺页异常(page fault),把控制权交给内核。
页表是进程私有的,每个进程都有自己的一套。这就是为什么两个进程的同一个虚拟地址会翻译到不同的物理页。进程切换时,内核会换掉页表基址寄存器(CR3),让MMU知道应该查哪一套页表。
3.2 多级页表:为什么不直接搞一张巨大的线性表
如果每一页都要一个页表项,一个进程的128TB虚拟地址空间按4KB分页,页表项数量是天文数字。哪怕只映射其中一小部分,线性页表也会浪费大量内存。x86-64用多级页表解决这个问题:四级结构(PML4 -> PDPT -> PD -> PT),每一级有512个表项,逐级索引。这样有个好处:如果一个上层表项是空的,下面整棵子树都不用分配。即使进程只映射了少量内存,也只需要为实际用到的地址创建对应层级的页表。
对这个机制的直观理解,可以想象图书馆找书:先查几楼(PML4),再查哪个区域(PDPT),再查哪个书架(PD),再查哪个格子(PT),最后拿到具体书(物理页)。正因为各级表只在实际需要时才建立,虚拟地址空间的"稀疏性"才成为可能——你可以声明一块巨大的虚拟区域,但页表子树是空的,真正访问时才会一层层补上。
读/proc/pid/status时注意一个字段:VmPTE,它的数值就是进程页表本身占用的物理内存。如果进程的地址空间映射特别碎、特别多,这个值会明显偏高,也是虚拟内存开销的一部分。
3.3 TLB快表与上下文切换的成本
让MMU每次访问内存都查四级页表,性能开销会很大。于是CPU里加了一个高速缓存——TLB(Translation Lookaside Buffer),专门缓存最近用到的虚拟页号到物理页号的翻译结果。只要命中TLB,翻译几乎零成本。但TLB容量很小,而且进程切换后旧进程的翻译结果对当前进程没有意义。
传统的做法是进程切换时flush整个TLB,之后新进程跑起来会频繁miss,需要重新走页表翻译。这就是为什么线程切换(同进程内)比进程切换便宜得多——线程切换时页表没变,TLB可以保留大量有效条目。现代CPU引入了ASID(Address Space Identifier)机制,让TLB里同时缓存多个进程的翻译结果,减少flush,但本质上的成本仍然存在。做性能分析时如果看到系统大量进程频繁切换,TLB的miss率飙升,对吞吐的影响就会很直观。
3.4 缺页中断:懒惰分配的真相
缺页中断分两种。一种是"合法缺页":地址在进程的VMA范围内,但物理页还没建立映射——通常是第一次访问刚malloc的内存,或者很久没访问被换出到swap了。内核在缺页中断处理里分配物理页、填页表、恢复执行,对进程来说感知不到。另一种是"非法缺页":地址根本不在任何VMA中,或者权限不符,内核会向进程发送SIGSEGV。
因为有了合法缺页机制,内核才敢搞"超额承诺"(overcommit):明明物理内存只剩2GB,进程却申请了10GB虚拟内存,malloc照样返回成功。当真的一页页访问到超出物理上限时,OOM killer才会出来收拾残局。很多人第一次在Linux上看到进程被莫名其妙杀掉,就是这个机制在背后起作用——不是你写错了,而是系统内存确实不够了,内核挑了一个"罪魁祸首"牺牲。
4. 用 /proc 给进程做一次地址空间体检:maps 与 smaps 逐行解读
4.1 /proc/pid/maps:一份不需要调试器的布局图
排查内存问题最常用的入口就是/proc/<pid>/maps。随便拿一个运行中的进程,比如cat /proc/$$/maps,每一行大概长这样:
555555554000-555555555000 r-xp 00000000 00:15 234567 /usr/bin/someapp 555555555000-555555556000 r--p 00001000 00:15 234567 /usr/bin/someapp 555555556000-555555557000 rw-p 00002000 00:15 234567 /usr/bin/someapp 7f0000000000-7f0000003000 r-xp 00000000 00:15 891011 /usr/lib/x86_64-linux-gnu/libc.so.6 7f0000003000-7f000000a000 ---p 00003000 00:15 891011 /usr/lib/x86_64-linux-gnu/libc.so.6 7f000000a000-7f000000c000 r--p 0000a000 00:15 891011 /usr/lib/x86_64-linux-gnu/libc.so.6 7f000000c000-7f0000010000 rw-p 0000c000 00:15 891011 /usr/lib/x86_64-linux-gnu/libc.so.6 7ffe00000000-7ffe00001000 rw-p 00000000 00:00 0 [stack] 7ffe00001000-7ffe00002000 r-xp 00000000 00:00 0 [vdso]每一列的含义是:地址范围、权限、文件偏移、块设备号、inode、映射路径。权限位里r/w/x/p或s组合非常关键:--p这种"不可访问但占位"的映射经常出现在动态库段的中间位置,起保护作用;r--p是只读映射,一般对应ELF的rodata;rw-p是可读写映射,全局变量和堆就在这类区域里。
看这个文件最重要的技能是"快速定位地址属于哪一段"。程序崩溃时打印的地址、或者你在gdb里info proc mappings得到的信息,都可以回来跟maps对照。我之前排查一个堆损坏问题,就是反复看堆段附近是否有异常的匿名映射([anon]),最终发现是某个库申请了大量私有匿名页,把堆周围的地址空间挤得七零八落。
4.2 /proc/pid/smaps:把每段内存的"账单"摊开
smaps是maps的加强版,对每个VMA区域给出更细的统计,包括:
Rss:该段在物理内存中的实际占用Pss:按共享比例折算后的物理占用,是衡量进程真实内存成本的最佳指标Shared_Clean/Shared_Dirty:与其他进程共享、干净/脏的页Private_Clean/Private_Dirty:进程私有、干净/脏的页Swap:被换出到swap的页数KernelPageSize/MMUPageSize:页大小,可以看出是否用上了大页
看一个多进程服务的内存占用时,我基本不看RSS,因为共享库被所有进程共享,会出现"每个进程RSS都很大,但总内存没那么多"的现象。Pss已经把共享部分按进程数平分了,加起来的和跟系统实际内存开销对得上。我曾经帮一个同事排查某网关进程内存异常,几个进程的RSS加起来远超物理内存,所有人以为是内存泄漏,结果一查smaps发现一大半是共享的libc和业务so的映射,Pss总量实际上非常健康。
4.3 用status、pmap和time组合诊断
/proc/pid/status里有一组Vm开头的字段:VmSize(总虚拟内存)、VmRSS(物理内存)、VmData(堆+数据段)、VmStk(栈)、VmExe(代码段)、VmLib(共享库)、VmPTE(页表占用)、VmSwap(swap占用)。其中VmData和VmStk是最容易出现异常波动的两个值。
命令行的pmap -x <pid>把maps和smaps汇总成一页报表,格式友好;top里的VIRT和RES则是对VmSize和VmRSS的粗略展示。更细一点的用法是/usr/bin/time -v ./your_program,退出时会打印Minor (reclaiming a frame) page faults和Major (requiring I/O) page faults。如果major fault很高,说明访问的页面经常要从磁盘换入,程序卡顿的第一个怀疑对象就是它。
提示:看内存问题要结合两个维度——虚拟地址空间的范围变化(通常体现为VmSize)和实际物理页占用(VmRSS)。前者疯涨一般说明地址空间映射越来越多,比如频繁malloc但没释放;后者疯涨说明物理页被持续写入,可能是真的在用,也可能是一遍遍写脏页。两个维度对着看,才能判断是不是真泄漏。
5. 地址空间在 fork 和 exec 前后的变形记:写时复制与ELF重映射
5.1 fork瞬间:子进程不是复制内容,而是复制"地图"
很多人以为fork是把整个进程的内存内容复制一份,这个理解不准确。实际fork时,内核只是把父进程的页表复制了一份给子进程,父子进程的虚拟地址空间内容指向完全相同的物理页。为了安全,这些共享物理页会被标记为只读。如果父子双方都只是读,那大家相安无事,物理内存里只有一份数据。
真正发生写入时,CPU发现权限是只读但进程有写意图——不对,页表里还有一层"这个页本来是共享的"标记,于是触发缺页。内核处理这个缺页时会分配一个新物理页,把原内容复制过去,再把页表项的写权限打开,让写入落到新页上。这个机制叫"写时复制"(Copy-On-Write,COW)。注意这个只读不是绝对的只读,而是内核在页表里做的临时权限控制,一旦触发COW就放开。
COW带来的直接好处是:fork不再依赖于进程内存大小,只受页表规模和映射项数量的影响,所以fork很快、很便宜。代价是fork之后如果父子双方都大量写各自的内存,那每一页都会被复制一遍,物理内存会出现短暂的翻倍增长。
5.2 COW的坑与观察技巧
COW机制很容易让人产生两个误解。
第一个误解是"fork之后修改子进程里的变量,会不会意外改到父进程"。答案是绝对不会。上面说了,写入会触发COW,父子各自独立。我见过有同学在父进程里改了个全局变量后以为子进程也变了,实际上子进程拿到的是fork瞬间的快照,后面各自演进。
第二个误解是"fork之后物理内存应该立即翻倍,但为什么free看到的没涨太多"。因为只有写过的页才真正复制,fork完马上exec的话,几乎不需要复制内容——exec会直接重建整个地址空间,旧映射全部作废。这也是shell里fork + exec成为经典组合的原因:fork开新进程,exec换程序,COW让整个过程非常轻量。
观察COW的一个实用技巧:在fork之前的程序里故意分配一个大数组并写入值,fork后子进程启动时只读数组,同时父进程不断修改另一个大数组。这时/proc/子进程pid/smaps里的Private_Dirty会很低,而/proc/父进程pid/smaps里的Private_Dirty会不断增长——说明父进程的脏页都是自己独有的,而子进程还在共享着未修改的页。
5.3 exec:干净利落地扔掉旧地图
exec执行时,内核会销毁当前进程绝大部分地址空间映射,然后按照新程序的ELF文件重新建立布局。具体来说,ELF loader会解析程序头表,把PT_LOAD类型的段映射到对应地址:文本段映射为只读可执行,数据段映射为可读写,BSS段映射为零填充的匿名私有页。然后设置好栈、环境变量、程序参数和辅助向量,最后跳转到入口地址。
这也解释了为什么某些在fork后、exec前写的代码会影响子进程:这段代码跑在旧地址空间上,exec之后全部作废。反过来,exec之后旧映射的所有页(包括共享物理页)都会回到空闲列表,物理内存压力很快释放。常见的"fork之后子进程查环境变量设置不对"问题,往往就是exec前在父进程环境上做的修改没有正确传下去,而不是exec本身弄丢了什么。
5.4 从COW到共享内存:按需调整地址空间语义
如果多个进程确实需要共享数据,那就不能用fork的简单拷贝语义,而是要显式使用共享内存(shm)或mmap的MAP_SHARED。这类映射的页表项天然允许跨进程访问同一物理页,不会触发COW。用mmap(MAP_SHARED)和普通文件映射,就能实现进程间通信,同时保持地址空间隔离——这种"显式共享"比"意外共享"安全得多。
在观测层面,一个进程的共享内存映射会在maps里标记为rw-s而不是rw-p,其中s就是shared的标志。排查多进程服务之间的通信问题时,我常先看smaps里的Shared_Dirty,确认数据到底落在谁的页上,这比在业务逻辑里追代码快得多。
6. 从地址空间视角看三种常见内存事故:段错误、内存泄漏与OOM
6.1 段错误:访问了不该访问的门牌号
段错误(SIGSEGV)的本质是CPU翻译虚拟地址失败:要么虚拟地址落在任何VMA之外,要么落在某个VMA内但权限不匹配,比如往只读段写数据。这两种情况都会触发非法缺页,内核直接给进程发SIGSEGV。
常见触发场景:
- 空指针解引用:地址0附近通常完全不可映射,访问它立即段错误。
- 野指针/悬垂指针:指针指向一块已经munmap或free的区域,那要么触发段错误,要么访问到被重新映射的其他数据,表现鬼畜。
- 栈溢出:栈底往下通常有一个不可访问的guard页,递归过深、局部数组过大都会一头撞上。
- 写只读内存:比如往字符串字面量里写字符。
排查段错误的正确顺序是:先看崩溃时留下的核心转储文件(core dump),用gdb加载后bt看调用栈,再info registers看执行现场,然后用info proc mappings对比崩溃地址是否落在某个映射段内。结合/proc/pid/maps思考"这个地址按道理应该在哪里"——如果完全不在映射范围内,就是典型的越界写坏了指针;如果在映射范围内但权限不符,就要看是不是常量的只读段被写了。
6.2 内存泄漏:地址空间只涨不跌的信号
内存泄漏最直接的表现是进程的VmSize和VmRSS随时间持续增长,永不回落。但细节上分两种:一种是堆一直涨,说明小块malloc持续分配而free丢失;另一种是mmap区域涨,说明大块分配或文件映射一直增加。通过/proc/pid/smaps里[heap]段和[anon]段各自的Pss变化趋势,可以快速缩小范围。
我有一次排查线上服务内存缓慢增长的经历:进程稳定运行一周后RSS从300MB涨到1.2GB,一开始所有人都怀疑C++代码泄漏。后来用pmap连续采样,发现不是[heap]涨,而是某个[anon]映射区域在疯狂增加,顺着smaps里的路径名找到是某个监控SDK在反复mmap匿名页,把每次上报的数据都缓存了一份没有释放。如果只盯着代码里的new/delete找,这个问题可能要找很久。所以我的经验是:泄漏排查先看地址空间增长的位置,再回代码里找元凶。
6.3 OOM机制:虚拟内存和物理内存的错位引发的"处决"
Linux的overcommit策略让进程能申请到远超物理内存的虚拟空间。默认情况下(vm.overcommit_memory=0)内核会用启发式策略决定是否允许malloc/mmap申请成功——它允许一定程度的超卖,但拼得太离谱时也会拒绝,malloc返回NULL。如果把vm.overcommit_memory设为2,内核就严格按"物理内存+swap"的额度来限制,超了直接拒绝。
但"申请时允许"不等于"用的时候一定够"。当进程真的把虚拟页一个个访问过去,物理内存和swap完全耗尽时,内核只能启动OOM Killer:给每个进程算一个oom_score,杀掉占用内存多、存活时间短、优先级低的进程来回收内存。很多人看到日志里"Out of memory: Kill process xxx"就懵了,其实进程多半只是倒霉——它是当时内存大户里最容易被牺牲的那个。
针对OOM,实用做法是:先free -h看物理内存和swap是不是真的耗尽了,再看dmesg确认被杀的进程和当时的内存账目。如果业务确实需要大内存,可以调大vm.overcommit_memory限制申请;如果不想让关键进程被杀,可以调低它的oom_score_adj,直接把它的优先级降下来。这里要澄清一个常见误区:OOM不是一次普通的内存分配失败,而是系统已经"揭不开锅"时的最后手段,所以避免OOM的根本是控制进程实际物理内存的使用量,而不是靠改“允许申请更大虚拟内存”的参数。
6.4 一个综合案例:如何从零开始定位一次"内存暴涨"
把前面所有部分串起来,给你一个实际排查链路。假设某进程从某个版本开始,运行两小时后内存占用从200MB涨到2GB,还偶发OOM被kill。
第一步:拿到进程pid,cat /proc/pid/status看VmSize、VmRSS、VmData、VmStk的绝对值。
第二步:连续多次pmap -x pid采样间隔5分钟,看哪个地址段的增长最明显。如果[heap]段是增长主力,说明堆上分配在涨;如果是[anon]其他段在涨,要去smaps里逐段确认。
第三步:cat /proc/pid/smaps看增长段的Private_Dirty是否持续增加——如果是,说明确实在不断写新页;如果只是Virtual大小涨但RSS稳定,那多半是地址空间映射了很多但不用的页。
第四步:回头代码里定位。堆涨优先看malloc/new调用点,mmap涨优先看文件映射、共享内存、线程栈创建。配合valgrind massif或jemalloc的profiling能更快圈定函数。
这套链路看起来朴素,但非常稳定。因为在Linux上,几乎所有的内存问题最终都会以"地址空间形态变化"的形式暴露出来,而maps/smaps就是直接拍下这个形态变化的工具。
写在最后的实操体会
关于虚拟地址空间,我个人的体会是:不要把它当成一个孤立的操作系统概念,而要当成一张连接"程序行为"和"物理资源"的活地图。每次程序崩溃、内存告警、性能劣化,第一反应不应该是急着查业务代码,而是先看这张地图哪里长得不对劲——地址空间里的每一个异常凸起,背后都对应着一次具体的程序行为。花点时间把maps、smaps、status这几个文件读熟,比看任何内存管理教程都管用。最后再分享一个小技巧:排查前先在/proc/pid/maps里数一下总结VMA的数量,如果超过几千个,即使RSS不高,系统层面的页表开销和TLB压力也已经不小了,这种"碎映射"问题往往被你忽略,但它确实可以让一条简单的内存操作慢好几倍。