
最近把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 写时复制COWfork不复制内存的秘诀每次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就是进程独占内存这个估算在大致数量级上可行但严格说共享部分无法精确按比例分摊。最接近真相的数字是PSSps并不直接展示可以用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 ($1Size:) size$2; if ($1Pss:) {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-checkfull --show-leak-kindsall ./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也要留意。每个线程默认栈大小通常是8MBulimit -s控制但只有栈区实际触碰过的页才会计入RSS。如果开了几百上千个线程即便每个线程只用了很少的内存累积起来也很可观。处理方式是评估线程池上限或者显式创建线程时设置更小的栈。4.2 swap被占满但“感觉”内存不缺swap用满常常不是因为物理内存不够而是因为swappiness设置和回收策略配合不当匿名页提前被换出。比如原本可以保留在物理内存中的进程页被换到swap随后进程访问这些页又要换入产生无谓的换页抖动。此时需要看/proc/meminfo里的Committed_AS评估系统承诺了多少虚拟内存以及dmesg里有没有swap thrashing的征兆。严格模式下vm.overcommit_memory2时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清可回收slabecho 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进程被杀但宿主没OOMcgroup限制触发memory.events调整memory.max优化业务内存最后说点个人体会我自己在排查过程中养成了一个习惯不看单个进程的RSS而是定期把/proc/PID/smaps里每个映射的PSS和Private_Dirty打快照保存成历史记录。等出现异常时翻出前一天的快照一对比哪段VMA在涨、涨得有多快答案就出来了。这套方法论陪我处理过不少棘手的线上问题也修正了我自己对“内存占用”的很多误解。如果你刚开始接触Linux内存管理不要急着把所有内核参数都调一遍先从VMA和smaps入手把“看内存”这件事做扎实后面的水位、回收、OOM策略都是顺理成章的事。工具只是辅助理解内核为什么这么做才能真正避免在排查时走弯路。