最近把Linux进程内存管理的英文资料和内核源码注释重新捋了一遍,参考一份译文笔记做了大量实测验证。说实话,很多开发同学和运维同学对“进程内存”的理解还停在top命令的RES那一列上,可一旦出现“进程看着不高,服务器内存却被占满”“刚启动就OOM”这类问题,光看RES根本无从下手。搞清楚Linux进程内存管理,需要把虚拟地址空间、页表、缺页异常、VMA、页回收、OOM killer这几条线串起来看,每一环都对应着实际排查时的关键参数和隐患点。
这篇文章会把这套体系用尽量通俗的方式讲透,并且给出可直接复用的排查命令和实验步骤。适合刚接手服务器运维的同学、准备Linux面试的开发人员,以及所有被“内存一直在涨但不知道涨在哪里”折磨的排查党。你可以把它当成一份带注释的内核学习笔记来读,也可以直接跳到第3节和第4节抄作业。
1. 先搭好框架:进程内存管理到底管了什么
1.1 需要先建立的三个观念
第一个观念,物理内存是稀缺资源,所以内核不会你一提需求就照单全给,而是在“容量配额”和“按需分配”之间找平衡。这就好比食堂窗口虽然接待一千个人,但不会一开始就做一千份饭,而是看谁真的坐下来了再下锅。
第二个观念,每个进程拥有独立的虚拟地址空间,进程看到的内存和物理内存之间隔着一层映射关系。这个映射关系由页表(Page Table)维系,而触发虚拟地址到物理页转换的机制就是缺页异常(Page Fault)。缺页异常是虚拟内存和物理内存之间的“执行契约”,进程以为自己一直拥有一块连续内存,实际上可能分散在不同物理页上,甚至有一部分内容还躺在磁盘交换区里。
第三个观念,内核内存管理分为四条主线:分配(谁拿到页)、映射(地址怎么对应)、回收(内存不够时如何腾挪)、保护(隔离和权限校验)。理解了这四条线,后面所有的参数和日志就都顺了。
1.2 进程地址空间的一张地图
Linux在64位x86-64架构下,用户空间一般占据低地址的128TB,也就是0x0000000000000000到0x00007fffffffffff。内核空间独占高地址部分。进程的虚拟地址空间并不是一整块大饼,而是按用途分成了若干区间:
| 区间 | 典型内容 | 对应/proc/PID/maps中的标记 |
|---|---|---|
| 代码段 | ELF的.text段,只读可执行 | /path/to/binary |
| 数据段 | .data和.bss,已初始化/未初始化全局变量 | /path/to/binary |
| 堆区 | 动态分配的小块内存,由brk扩展 | [heap] |
| 内存映射区 | 共享库、mmap大块内存、共享内存 | /usr/lib/...、[anon] |
| 栈区 | 线程栈、函数调用栈 | [stack] |
| vsyscall/vdso | 内核提供给用户态的快速系统调用入口 | [vdso]、[vsyscall] |
这里面有几个值得注意的细节。堆区和栈区之间通常隔着巨大的空洞,这是为两者相向增长留下的余量。64位下地址空间足够充裕,所以空洞往往大得惊人,这也直接导致你看到top里的VIRT列数值巨大时,不要慌——VIRT只是进程“见过”的地址空间总量,和实际占用的物理内存是两码事。另外,每个进程的第一个页通常会被标记为不可访问,用于捕获空指针解引用,这也是为什么C语言里对空指针操作通常会直接段错误而不是悄悄读到别的数据。
1.3 从虚拟地址到物理页:页表与缺页异常
64位Linux采用四级页表(PGD、PUD、PMD、PTE),每一级都像一个多级菜单:先查PGD定位到PUD的目录页,再层层下钻,直到PTE指向具体的物理页。单页大小默认4KB,但映射的单位不是字节,而是整个页。虚拟地址从最高位开始被拆分成对应每级目录的索引,低位则是对应页内的偏移量。
这种多级结构带来的最大好处是,进程地址空间再大,也不用为“从没碰过”的区域预分配页表项。只有真正访问过的地址才会建立对应的映射,这就是“按需调页”的基础。但多级查表有成本,所以CPU引入了TLB(快表)来缓存最近使用的虚拟地址到物理地址的转换结果。TLB命中时,CPU不用再去内存里逐级翻页表;一旦缺失,就要软件或硬件遍历页表,开销明显上升。
缺页异常不只是“页不存在”一种情况。进程读一个还没加载进来的代码段页,内核会从磁盘文件加载;malloc后第一次写堆内存,内核会现场分配一个零页;fork之后写内存,则触发写时复制。这个过程中,如果只需要分配内存页,属于minor fault;如果需要从磁盘读文件内容或从swap分区换入,属于major fault。通过time命令里的Faults信息,或者/proc/PID/stat的第10和第12个字段,可以分别看到minor和major fault的次数。major fault数量高通常意味着程序在做大量磁盘交换,性能损耗非常明显。
2. 核心机制拆解:malloc、COW、回收、OOM背后的门道
2.1 VMA是真正的“地图”
进程的地址空间不是内核凭空想象的,而是由一坨vm_area_struct结构体描述,简称VMA。每个VMA代表一段连续的、具有相同权限和映射来源的虚拟地址区域。内核把所有VMA组织成红黑树,这样在查找某个地址属于哪个VMA时,可以在对数时间内完成。
VMA里有几个关键属性:起始地址、结束地址、读写执行权限、标志位,以及映射的文件对象。匿名映射表示没有文件支撑,比如堆和栈;文件映射则表示背后有具体文件,比如动态库。读懂/proc/PID/maps就是在罗列这些VMA。而/proc/PID/smaps则进一步给出了每个VMA的详细内存统计,包括RSS、PSS、私有页、共享页、脏页等。排查内存增长时,没有比逐段看VMA更靠谱的入口。
注意:不要把VMA和物理页搞混。一个VMA可以对应很多物理页,也可以一个物理页都没有(只是声明了地址范围)。很多所谓“内存泄漏”其实就是VMA不断扩张,但物理页并没有立刻变多,需要等到实际写入才会体现出来。
2.2 malloc并不等于直接向内核要内存
malloc是用户态的行为,背后由glibc实现。对于小块内存,glibc会通过brk/sbrk修改堆顶指针,也就是扩展“堆”这个VMA。对于大块内存,比如超过128KB的分配,glibc会改用mmap,在内核的映射区创建一个匿名VMA。为什么大块内存要用mmap?因为mmap出来的区域,释放时可以直接munmap把VMA摘掉,物理页被释放得非常干净;而brk管理的堆区一旦堆顶不连续,小块释放容易造成“空洞”,累积下来会形成堆碎片。
实际上glibc的M_MMAP_THRESHOLD是动态调整的,默认128KB左右,会根据程序行为在64KB到32MB之间浮动。它存在是为了平衡两种分配方式的代价:brk速度快但释放时机被动,mmap慢但释放彻底。你在strace里看到某个程序频繁出现mmap(NULL, 1048576, ...),说明它走的是大块分配路线。
另一个必须弄清楚的参数是vm.overcommit_memory。它有三个值:0是启发式,内核自己判断申请是否“靠谱”;1表示总是允许,malloc基本不失败;2表示严格模式,申请量超过总配额就直接拒绝。配额的计算公式大约是:可提交总量 = swap总量 + overcommit_ratio% * 物理内存,其中overcommit_ratio默认50。生产环境很多人喜欢设置成2来防“把内存申请爆”,但副作用是某些数据库或JVM启动时想预分配大块虚拟内存,会被直接拒绝。所以改这个参数前,要先确认业务是不是真的需要超额申请。
2.3 写时复制(COW):fork不复制内存的秘诀
每次fork就把父进程所有内存全部复制一份,那代价高得离谱。Linux的做法是,fork后父子的虚拟地址空间映射到同一批物理页,同时把这些页的PTE标记为只读。只要父子都不写,大家就共享同一份物理内存,读操作永远命中。一旦某一方试图写入,CPU触发缺页异常,内核才把物理页复制一份,然后更新PTE,让触发写入的一方拥有独立副本。
这个过程就是写时复制(Copy-on-Write, COW),内核源码里通常用copy_page_range和wp_page_copy等函数实现。这也是为什么fork一个1GB内存的进程往往毫秒级完成,但紧接着父子双方大量写入时,RSS会突然变高,因为物理页开始被真正复制了。批量fork大量子进程时,如果每个子进程都立刻写内存,瞬时内存压力会非常大,容易把系统推到OOM边缘。实际工程中,用vfork或posix_spawn可以避开这个坑,但语义限制更多,要用之前得先确认场景是否合适。
COW机制还直接影响top里的RES统计。父子进程共享的那部分物理页可能被分别计入两个进程的RSS,所以你在top上看到的总RSS加起来超过物理内存,并不是bug,而是共享页被重复计数。更准确的做法是看PSS(按比例分摊),这个后面实操部分会详细讲。
2.4 页面回收与swap:内存是怎么被“挪”出来的
当系统觉得内存不够时,内核会启动页回收。内存页分为文件页和匿名页两大类。文件页背后有磁盘文件,回收时如果页是干净的,直接丢弃;如果是脏页,得先把内容写回磁盘。匿名页没有文件后台,只能换到swap分区里,以后需要时再换回物理内存。
内核为页回收建立了角色分明的LRU链表,分成active和inactive,再按文件页和匿名页各分开。没有进程访问的页逐渐从活跃链表退到非活跃链表,回收线程优先清理非活跃链表尾部的页。触发回收的负责人是kswapd,它根据水位线(watermark)判断压力,水位越低动作越激进。min_free_kbytes设置的是最低水位线,低于这条线,直接内存回收就会同步阻塞调用者,表现为进程卡顿。
控制脏页回写的参数也在这里派上用场。vm.dirty_ratio是进程同步刷脏页的百分比阈值,vm.dirty_background_ratio是后台刷脏页的百分比阈值。前者更像“被迫交作业”,后者则是“定时主动整理”。如果服务器上大量写文件导致IO卡顿,可以适当拉高dirty_background_ratio并降低dirty_ratio,让系统在压力变严重前就用后台线程慢慢刷。
vm.swappiness则决定内核在多愿意换出匿名页。默认60表示匿名页和文件页的回收权重差不多。把它调低,比如10或0,意味着更倾向于回收文件页而不是换出匿名页,这通常对响应延迟敏感的应用更友好。但如果系统里确实有长期不用的匿名页且内存又紧张,强行压低swappiness反而可能导致频繁回收文件页,加剧缓存抖动。生产上最常见的误区是“只要内存够用就无脑设0”,实际效果可能南辕北辙。
2.5 OOM killer是如何选中受害者的
内存彻底不够时,内核会启动OOM killer选择受害进程。打分时主要看进程的RSS大小、页表消耗、swap使用量,以及进程的运行时间、优先级等因素。总分越高越容易被杀,但用户可以通过/proc/PID/oom_score_adj手动调整,范围是-1000到1000。设置为-1000表示完全禁用该进程被OOM killer挑选,通常给sshd、监控agent这类关键进程用。
内核日志里会有一段类似Out of memory: Killed process 1234 (java) total-vm:...的输出,里面包含了total-vm、anon-rss、file-rss等信息。注意这里anon-rss才是真正不可回收的匿名内存,file-rss则包含文件缓存,理解这两者的区别对分析OOM原因很重要。很多Java进程OOM时,anon-rss远大于堆大小,因为还包括元空间、线程栈、直接内存;排查时必须拆开看,不能只盯着堆配置。
3. 实操:给进程做一次完整的内存体检
3.1 全局水位怎么看:free 与 vmstat
先用free -h看全局内存格局。输出里的free和available含义完全不同:free是完全没有被占用的页,available则是“在不触发严重swap的前提下还能分多少出去”的估算值。Linux优先把空闲内存用作缓存,所以free很小并不代表内存告急,available才是应用关心的真实可用量。
buff/cache这一列经常被误解,它既包含磁盘读缓存,也包含页面缓存。查看/proc/meminfo里SReclaimable和SUnreclaim可以对slab缓存做进一步区分。判断内存是否真的很吃紧,比起看free,更值得看vmstat 1 5里的si、so两列,也就是swap换入换出。这两列持续非零,说明系统正经历真正的swap压力。此时cs上下文切换次数也会明显飙升,表现为进程卡顿、响应变慢。生产环境如果长期swap in/out很高,基本可以断定内存不够,而不是单纯缓存太多。
3.2 进程维度:top 和 ps 怎么组合用
top里的VIRT是进程虚拟地址空间总大小,RES是物理驻留内存,SHR是共享内存。很多人以为RES减去SHR就是进程独占内存,这个估算在大致数量级上可行,但严格说,共享部分无法精确按比例分摊。最接近真相的数字是PSS,ps并不直接展示,可以用ps aux --sort=-rss先按RSS排序,锁定最可疑的进程。
判断一个进程内存是否“持续增长”,单看当前RES不够,得看历史峰值。查/proc/PID/status里的VmPeak字段,它记录了这个进程启动以来虚拟内存的峰值。如果VmPeak远大于当前VmSize,说明曾经申请过很多内存又释放了一部分,这种“你方唱罢我登场”的情况通常暗示存在一次性大分配;如果VmRSS和VmPeak同步缓慢爬升,则更像真正泄漏或缓存累积。
/proc/PID/status里还有几个关键字段:VmData是私有数据段大小,VmExe是代码段大小,VmStk是栈大小,VmLib是共享库大小。看到某一段数值异常膨胀,下一步就去smaps里精确定位。
3.3 用smaps定位“哪一段映射吃内存”
/proc/PID/smaps是逐VMA的内存明细,比status细一个量级。里面的字段虽多,日常排查重点看几个:Size是虚拟区间大小;Rss是该区间在物理内存中的驻留量;Pss是把共享页按比例分摊后的实际归属量;Private_Dirty是进程独占且被修改过的脏页,这是最值得盯的“真实独占内存”。
比如一段Java进程的smaps可以看出,[heap]的Rss可能不大,但一堆[anon]映射的Private_Dirty合计非常大,这些往往是JVM的堆外内存、线程栈或DirectByteBuffer。定位到具体VMA地址后,用gdb或追踪malloc源码就可以进一步落到具体分配点。
想要快速列出内存占用最大的映射,可以使用:
awk '/^Size|^Pss/{if ($1=="Size:") size=$2; if ($1=="Pss:") {print size, $2, $3}}' /proc/PID/smaps | sort -k2 -n -r | head这个脚本段对所有人友好,awk对每行做标记,最后按Pss排序,一眼就能看到谁占比最夸张。也可以用现成的smem工具,它内部同样读取smaps,按PSS/RSS/VSIZE输出,比手写awk更方便。
3.4 泄漏定位实践:一个C程序案例
为了演示完整流程,我写了一个最简单的“泄漏制造机”,每100毫秒malloc一个4KB页面并写入一个字节,确保物理页真实产生。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { char *p[10000]; int i = 0; while (i < 10000) { p[i] = malloc(4096); if (p[i]) p[i][0] = 'a'; usleep(100000); i++; } return 0; }编译运行后打开另一个终端,用watch -n 1 'ps -o pid,rss,cmd -p $(pgrep -f leak_demo)'观察RES变化。你会发现RSS稳定增长,但程序本身逻辑正常,这时候可以判断为“内存持续增长”,但还不能直接定性为泄漏。接着用valgrind跑一个缩短版:
valgrind --leak-check=full --show-leak-kinds=all ./leak_demovalgrind会在进程退出时输出泄漏摘要,明确告诉你哪一行malloc没有对应free。如果程序还在运行中,不方便重启,可以用gdb -p PID加断点,或者在/proc/PID/smaps里发现某个[anon]区间的Private_Dirty持续增长后,再结合perf追踪malloc调用栈,定位分配点。
这个实验给我的一个最大体会是,别看到RSS上涨就断言泄漏。先用smaps确认增长集中在哪个区间,再看这个区间是不是缓存、线程栈还是匿名分配,最后用valgrind或gdb确认分配点。排查路径越清晰,误杀程序的概率越低。
3.5 cgroup限制:把进程内存关进“笼子”
现代容器依赖cgroup限制内存,生产排查中经常要处理“容器内进程OOM,但宿主内存还很充裕”的现象。cgroup v2下,限制内存用memory.max,把进程放进cgroup后再设置限制值:
mkdir /sys/fs/cgroup/example echo 500M > /sys/fs/cgroup/example/memory.max echo $$ > /sys/fs/cgroup/example/cgroup.procs设置后,如果这个cgroup里的进程超过500MB,内核会优先回收该cgroup内的页,实在回收不动才触发OOM kill。观察命中情况看memory.events里oom字段。用这个机制,你可以给每个业务进程独立限流,即使泄漏暂时无法修复,也不会拖垮整台宿主机。
需要注意,cgroup的OOM并不完全等同于全系统OOM,它发生在cgroup内部,日志未必出现在dmesg的系统级OOM记录里,需要同时看memory.events。而且一旦容器内进程被cgroup杀死,容器管理程序可能自动重启它,造成“进程老死但宿主无感知”的假象。
4. 实战中那些坑:常见问题与排查技巧
4.1 RES为何比想象中高那么多
一个常见的场景是:某个进程的RSS高达几个GB,但它的堆和栈都不大,怎么看都不合理。这时候要怀疑共享映射里的文件页。比如MySQL的binlog或redo日志文件被映射进内存,又比如JVM的classes.jsa或so库被多个进程共享读取,这些页都会被算进RES,但并不算真正的“进程独占内存”。对照/proc/PID/smaps里的PSS,才能看清真实占用。
还有一种情况是zombie进程。zombie本身不占内存,但它的父进程如果持续fork而不wait回收,残留的task_struct和退出信息会累积,虽然不体现在RSS,但一样消耗内核内存。这种问题用ps -ef | grep defunct快速排查就能找到。
多线程程序的RSS也要留意。每个线程默认栈大小通常是8MB(ulimit -s控制),但只有栈区实际触碰过的页才会计入RSS。如果开了几百上千个线程,即便每个线程只用了很少的内存,累积起来也很可观。处理方式是评估线程池上限,或者显式创建线程时设置更小的栈。
4.2 swap被占满但“感觉”内存不缺
swap用满常常不是因为物理内存不够,而是因为swappiness设置和回收策略配合不当,匿名页提前被换出。比如原本可以保留在物理内存中的进程页被换到swap,随后进程访问这些页又要换入,产生无谓的换页抖动。此时需要看/proc/meminfo里的Committed_AS评估系统承诺了多少虚拟内存,以及dmesg里有没有swap thrashing的征兆。
严格模式下vm.overcommit_memory=2时,Committed_AS超过允许配额也可能导致奇怪的失败。生产调整建议是:先按业务类型设置合理的swappiness,比如数据库或缓存类应用可以设低一些;再确认swap分区大小有兜底;不要简单粗暴地关闭swap,因为即使物理内存很大,系统也需要一个“缓冲垫”来应对瞬时分配高峰。
4.3 OOM killer乱杀无辜怎么应对
我最先处理的几个生产OOM案例,基本都能在dmesg -T | grep -i oom里看到被杀进程的完整内存画像。比如total-vm是虚拟内存,anon-rss是匿名页,file-rss是文件页。如果被杀的是监控agent这种“看起来很小”的进程,往往是因为系统里确实没有足够的内存可回收,只能挑一个打分不高的进程动手。
降低被杀风险的方式是给关键进程设置oom_score_adj为负值,比如-500或-800,让它尽量排在候选名单后面。对真正重要的进程可以设-1000,但代价是如果内存真正被耗尽,系统宁可卡死也不会杀它,这需要业务自己权衡。另一方面,如果业务能接受重启,就做好进程守护,比单纯压低分数更可靠。
4.4 手动回收页缓存和slab的时机
/proc/sys/vm/drop_caches可以手动释放页缓存和slab对象。echo 1只清页缓存,echo 2清可回收slab,echo 3都清。执行之前必须sync,否则脏页还没写回就被清掉,后果自行体会。
这里的关键误区是:这种操作不能当作常规内存释放手段。正常情况下内核自己回收已经够勤快,手动drop只会造成缓存命中率下降,磁盘IO反而增多。它适合的场景是:刚做完大批量文件处理,缓存里塞满不再用到的内容;或者升级内核、做性能测试前,为了拿到干净的基准数据。线上不建议频繁操作。
4.5 容易被忽略的“隐形内存消耗”
很多看起来与内存管理无关的问题,最终都落在几个角落里。Java NIO的DirectByteBuffer会申请堆外内存,这部分不在堆统计里,却真实占物理页,通常通过JVM参数MaxDirectMemorySize限制。mmap大文件时,如果用了MAP_SHARED且写入脏页,即使文件被删除,脏页也必须在swap或文件系统里回写,不能通过drop_caches释放。这类问题在smaps里会表现为某个文件映射的Private_Dirty特别大。
另外还有内核把用户态栈映射到[stack],但线程栈的动态增长不会像堆一样显示[heap]。线程数量特别多的服务,内存增长往往就藏在多个[anon]映射的Private_Dirty里,列出来对比就非常直观了。
| 常见现象 | 可能原因 | 优先排查方式 | 处理建议 |
|---|---|---|---|
| RES高但业务堆很小 | 共享库文件页、mmap文件缓存 | smaps里的PSS | 按PSS评估,必要时调整mmap策略 |
| swap持续读写 | swappiness偏高、内存压力大 | vmstat si/so | 调低swappiness,增加物理内存 |
| OOM杀关键进程 | 不可回收内存耗尽 | dmesg oom日志 | 调整oom_score_adj,限制容器配额 |
| 进程RSS缓慢上升 | 堆缓存增长、真实泄漏 | smaps快照对比、valgrind | 定位分配点,避免过早下结论 |
| cgroup进程被杀但宿主没OOM | cgroup限制触发 | memory.events | 调整memory.max,优化业务内存 |
最后说点个人体会
我自己在排查过程中养成了一个习惯:不看单个进程的RSS,而是定期把/proc/PID/smaps里每个映射的PSS和Private_Dirty打快照,保存成历史记录。等出现异常时翻出前一天的快照一对比,哪段VMA在涨、涨得有多快,答案就出来了。这套方法论陪我处理过不少棘手的线上问题,也修正了我自己对“内存占用”的很多误解。
如果你刚开始接触Linux内存管理,不要急着把所有内核参数都调一遍,先从VMA和smaps入手把“看内存”这件事做扎实,后面的水位、回收、OOM策略都是顺理成章的事。工具只是辅助,理解内核为什么这么做,才能真正避免在排查时走弯路。