深入Linux内存管理:从虚拟内存、页表到OOM Killer排查实战

发布时间:2026/10/17 0:23:28
深入Linux内存管理:从虚拟内存、页表到OOM Killer排查实战
1. 从应用闪退说起内存管理在系统里到底管什么做过几年后端或者客户端开发的同行大概率都有过这种经历某个服务跑着跑着首页接口开始大量超时紧接着收到监控告警说机器内存飙升再下一秒OutOfMemoryError或者Killed直接打在日志里。这时候排障的第一反应通常是是不是有人把大对象全塞进了缓存是不是连接池没关很少有人会往操作系统那一层想。但真正把内存用完、把进程杀掉、把整台机器拖垮的往往是底下那套内存管理机制先撑不住了。这个底下那套机制就是操作系统内核里最复杂也最关键的模块之一内存管理。它对普通应用开发者来说像透明玻璃平时看不见摸不着可一旦出了问题玻璃碎了谁都知道疼。操作系统里的内存管理到底在管什么往大了说就四件事隔离、抽象、效率、安全。隔离是让每个进程都以为整台机器的内存都是我的A进程往自己地址空间写脏数据绝不会把B进程的数据冲掉。抽象是让应用层只跟虚拟地址打交道不需要关心物理内存插在哪条通道、剩余多少、哪里连续。效率是让分配和回收尽量快让物理内存别闲着让缓存页尽量留在该留的地方。安全则是把用户态和内核态隔开你没法直接摸到别人的内存页也没法随便执行内核区域的数据。我见过不少入门者上来就背概念内存管理包括分页、分段、虚拟内存、页面置换……但背完依然不知道怎么排查一个dangling pointer也不知道为什么Redis在Linux上建议关掉大页。核心原因就是把内存管理当成了纯理论。这篇文章我会结合Linux的实际行为把虚拟地址、页表、页面置换、分配器这些串成一个完整链路最后落到内存报警时你该怎么想这个实战问题上。适合的人群很明确写C/C/Java/Go的服务端开发做嵌入式Linux的工程师以及所有被OOM折磨过、想过内存到底去哪了的人。2. 虚拟与物理程序眼前的假地址和硬件背后的真抽屉2.1 为什么程序不能直接操作物理地址如果让每个进程直接写物理内存那等于让一屋子人共用一张写字台谁都能伸手动别人的纸。别说恶意程序了一个野指针就能把别的进程的内核数据改了系统立刻崩给你看。所以现代CPU和操作系统都引入了虚拟地址机制进程看到的地址是假的CPU在每条访存指令真正落到内存前会把假地址翻译成真地址。这个翻译动作依赖的就是页表。每个进程都有自己的页表CPU里的MMU内存管理单元负责查表。进程A的地址0x400000可能映射到物理内存的0x1000进程B的地址0x400000映射到物理内存的0x8000。两者井水不犯河水因为页表不同物理目标也不同。从应用角度看虚拟地址带来了一个极其舒服的错觉我的地址空间是连续的、巨大的、私有的。实际上物理内存早就碎片化了是页表这个翻译官帮你把这些碎片拼成了一个连续的世界。2.2 虚拟地址空间的划分与直接好处在64位Linux上一个进程的虚拟地址空间理论上可以达到128 TiB的规模用户态占了低地址部分内核态占了高地址部分。实际可用范围还受架构约束但已经大到普通进程根本用不完。正因为虚拟空间这么大操作系统才有余力做这些事按需分配你malloc(1GB)的时候内核只是把虚拟地址区域划好并没有真的给物理页。直到你真正写入这些页触发缺页异常内核才姗姗来迟地找个物理页填上。这个机制叫demand paging效果就是你申请的假内存可以轻轻松松比物理内存还大但物理内存永远不会被瞬间掏空。共享内存两个进程可以把同一个物理页映射到各自虚拟地址空间的不同位置比如动态库的代码段。物理内存只存一份虚拟地址各看各的。这就是为什么你开十几个进程用同一个libc物理内存也不会复制十几份。内存映射文件mmap一个文件内核把文件内容按页映射到进程地址空间你读文件就像读内存一样脏页由内核定期刷回磁盘。省掉了一次次read/write的系统调用和数据拷贝。理解这一步很重要因为很多内存突然涨的现象其实不是进程真的alloc了那么多物理内存而是虚拟地址空间因为mmap扩大了一圈加上缺页未命中实际贡献的物理内存并没有那么多。排查的时候先看RSS驻留内存而不是VSZ虚拟内存这个区分我后面还会强调。2.3 从进程创建到映射的内核真相进程创建时内核会为它初始化一个mm_struct结构可以理解成整个内存布局的总控台。里面存着代码段的起始地址、数据段的边界、堆的起点、栈的起点以及指向页表根的指针。每次访问一个新地址CPU查页表发现没有对应项就会产生缺页异常内核在异常处理里判断这个地址合不合法合法就分配物理页、填页表、继续执行不合法就直接发SIGSEGV给进程。这个流程里有几个绕不开的性能点。一是缺页异常代价极大动辄几十万条指令所以缓存命中率高的程序跑得快主要原因之一就是不会频繁触发缺页。二是每次malloc之后立即memset是很愚蠢的你等于主动把按需分配的机会全浪费了哪个页都没逃掉物理分配。三是fork之后父子进程共享物理页写时复制技术会在某一方真正写入时才复制页面这也是为什么fork一个巨大的进程可以很便宜但如果在子进程里乱写物理内存会迅速膨胀。3. 地址翻译流水线多级页表与TLB如何联手提速3.1 分页机制地址翻译的基本单位操作系统把内存切成固定大小的块物理内存这一侧的块叫页框虚拟内存这一侧的块叫页。Linux默认页大小是4KB在ARM和x86上都差不多。进程访问虚拟地址时MMU把地址拆成两部分高位是页号低位是页内偏移。页号去页表里查查到对应的物理页框号再跟页内偏移拼起来就得到了物理地址。翻译一次很简单可问题在于进程的一次访存就要做一次翻译一个简单的数组遍历可能涉及上百万次访存。如果每次都去内存里翻一遍页表性能直接塌方。所以现代CPU在MMU旁边放了一个小而快的缓存叫TLBTranslation Lookaside Buffer专门缓存最近用过的虚拟页号到物理页框号的映射。命中TLB的翻译只需要一两个时钟周期不命中的话就要走完整的页表查询可能几十个周期起步并且还要去内存里多读几级页表项。3.2 为什么页表必须是多级的如果你天真地用一个数组做页面映射表进程虚拟空间假设是48位页大小4KB页表项数就是2的36次方约687亿项。每个页表项就算8字节一张页表也要512GB内存。这比物理内存还大显然不可能。所以实际用的是多级页表。x86-64下典型是四级PML4、PDPT、PD、PT外加最后的页内偏移。查询时逐级找索引每一级都指向下一级页表的物理地址。这样做的好处是进程大部分虚拟地址范围根本没有映射对应的上级目录项直接标成空子页表整个都不用存在。一个只用到几十MB内存的进程页表总大小可能只有几MB节省得相当可观。多级也有代价每次缺页需要逐级补建目录项查一次页表最多可能读四块内存。为了缓解这个问题CPU支持巨型页比如2MB大页。大页减少了页表层级和页表项数量TLB能覆盖的内存范围瞬间变大。Linux也有透明大页机制但如果应用程序的访存模式随机且零散透明大页反而可能带来延迟毛刺。我见过不少数据库和中间件踩过这个坑最后老老实实在启动参数里关掉THP。3.3 TLB命中率与上下文切换的成本TLB容量很小通常只有几百到几千项。一个循环里正在频繁访问的页可能就那几页但进程一切换老进程的TLB条目理论上就失效了新进程需要重新冷启动翻译。这带来了上下文切换开销的一部分。现代CPU和Linux支持ASIDAddress Space Identifier让TLB里同时缓存多个进程的映射切换进程时不用全部清空。但ASID本身数量也有限实际使用中切换代价依然不低。这也解释了另一个现象大量创建短命线程/进程的服务性能往往不如长驻几个进程的服务好除了调度开销和锁开销TLB和页表的反复重建也是隐性成本。我自己优化过一个小型网关把短连接改成长连接后吞吐量上去了十几个百分点其中一个重要因素就是TLB和页表相关的工作量变少了。4. 内存不够用时的裁决页面置换算法的博弈现场4.1 物理内存紧张时内核会怎么出手按需分配不是无条件保障。当系统物理内存不足时内核必须把一部分暂时用不上的页面请出去——要么写回磁盘要么直接丢弃干净页腾出物理页框给更需要的页面。这个过程叫页面回收而决定牺牲谁的策略就是页面置换算法。Linux用了一个基于LRU思想的近似方案把内存页分成active链表和inactive链表加上匿名页和文件页分别管理。内核有一个线程定期或者在水位压力下唤醒扫描这些链表把长期不用的页从active降级到inactive再把inactive里的页回收。你看到的Buff/cache占用高其实很大一部分是可回收文件页系统内存紧张时它们随时可以被丢了腾地方。所以Linux下free命令显示的cache高不代表内存不够。4.2 FIFO的经典翻车Belady异常课堂上第一个被讲的置换算法一般是FIFO先进先出。实现极其简单谁先进来谁先走。但FIFO有个很反直觉的缺陷叫Belady异常物理内存增多的情况下缺页次数反而可能增多。原因是FIFO完全不看页面的实际使用频率一个被频繁访问的页只因为来得早就会被无情换出过一会儿又得换回来来回折腾。这个缺陷从理论层面就否决了FIFO作为现代系统的选择。所以教科书总是把它拿出来当反面案例让初学者明白页面置换不能只看来多久了得看最近用没用。4.3 LRU看起来完美做起来难咽LRU最近最少使用是一种理想每次置换时找最长时间没被访问的那一页。它能精准反映局部性原理性能表现很好。但要实现真正的LRU你需要记录每个页的最后访问时间或者在每次访问时维护一个全局有序链表。每次访存都必须更新这些数据结构这开销对CPU和内存带宽来说几乎不可接受。因此实际操作系统中没人用全真LRU大家用的都是近似。比如Clock算法也叫第二次机会算法所有页排成环形每个页有个引用位被访问过就置1。需要置换时从头扫描遇到引用位为0的页就换走遇到1的就把位清零再跳过相当于给这个页一次改过自新的机会。4.4 Linux的妥协方案与实战观察Linux的页面回收算法没有直接叫Clock但本质是类似思路的组合拳维护活跃页链表和不活跃页链表用refault distance等指标做调参。内核里还区分了匿名页和文件页文件页可以直接丢弃脏页写回磁盘匿名页则要写入swap区。有一个实践心得值得分享当你的服务突然出现大量次缺页导致的卡顿别只盯着业务代码先看看/proc/meminfo里的SwapTotal和SwapFree。如果swap被大量使用说明物理内存真的告急内核开始把匿名页往磁盘上搬了。这时候即使你的Java进程RSS没涨多少整机性能也会急剧下降因为随机磁盘IO比内存访问慢几个数量级。线上遇到过一例业务高峰期缓存命中率掉了一半排查到最后是运维给容器设错了内存上限导致cgroup层级的内存回收频繁把热点文件页提前清掉。5. 连续还是离散伙伴系统与slab分配器的分工逻辑5.1 为什么内核要自己管物理页分配应用层的malloc最终会通过系统调用要内存但内核自己也有大量的内存分配需求创建进程要分配task_struct打开文件要分配file结构体网络收发要分配sk_buff。如果每次都从物理内存里逐个页分配然后手动管理这些零散内存的元数据效率和碎片都会失控。伙伴系统是Linux物理页分配的地基。它把物理内存按2的幂次方划分成块比如4KB、8KB、16KB、……一直到几个GB的大块。分配时从最小的能满足需求的块开始如果当前层没有空闲块就往更大一层找然后对半拆分释放时方向和它相反判断相邻块是否空闲且大小相同能合并就合并尽量把内存重新凑成大块。这个设计的核心价值是分配和释放都是操作链表和位图速度极快而且天然避免了小的分配把大块内存切得太碎。代价是可能产生外部碎片——很多小块分散在内存各处凑不成一个连续大块。伙伴系统通过反碎片机制和页面迁移来缓解但无法根除。5.2 slab分配器针对内核对象的对象池伙伴系统最小单位是页可内核里大量分配的是几十到几百字节的对象。如果每个task_struct都去伙伴系统单独申请一个页浪费惊人。于是Linux引入了slab分配器。它按照对象类型维护缓存比如专门给task_struct准备一个缓存里面预先划分好若干个固定大小槽位。需要时取一个槽位用完放回不重复初始化。slab还有一层好处可以给经常分配和释放的对象做着色让不同对象起始地址错开减少缓存行冲突。对于文件缓存这类比较复杂的情况还有percpu分配器减少多核竞争。这一整套层级下来内核里的内存分配路径可以做到纳秒级别远比用户态malloc到内核这一路要快得多。5.3 用户态malloc的选型策略到了用户态glibc的malloc底层是ptmalloc用的是另一套思路维护多个空闲链表按大小分bin小幅分配走brk扩大堆大幅分配走mmap映射匿名内存。这样设计的好处是大块内存分配后被mmap独立管理释放时可以真正归还给操作系统小块内存则留在堆里复用避免频繁系统调用。我经常看到有人问为什么malloc了2GB内存free之后ps看到的RSS还是很大答案通常是小块内存仍在malloc的空闲链表里存着没有归还给OS。大块mmap的则可能真正munmap了。所以在进程维度看内存不一定立刻降下来这是正常现象不是泄漏。想要进程内存快速还给系统可以在代码里对大块用mmap或匿名映射接口或者调整glibc的M_MMAP_THRESHOLD阈值但也要权衡频繁mmap/munmap的系统调用开销远大于堆内复用。6. 进程视角的堆与栈malloc、生命周期与碎片账单6.1 进程内存布局怎么看一个普通的Linux进程虚拟地址空间从低到高依次是代码段.text数据段.data和.bss堆heap向上增长内存映射区域mmap区向下管理共享库、匿名映射等栈stack向下增长内核区用户态不可见用pmap或者/proc/pid/maps可以看得清清楚楚。有一次排查内存问题时我用cat /proc/pid/smaps_rollup快速看到一个进程的RSS、共享内存、私有内存分别多大比top里的数字可靠得多。6.2 堆和栈的本质区别堆和栈虽然都叫内存区域但管理方式完全不同栈是不需要显式分配和释放的。函数调用时压入栈帧返回时弹出栈帧完全由硬件和编译器协同完成速度极快。栈上的数据生命周期跟作用域绑定你没有机会去手动free一个栈变量因为函数一返回它就没了。堆则必须由开发者管理。malloc/free或者new/delete是显式操作。堆内存的生命周期可以跨越函数边界想活多久活多久。这种灵活性的代价就是分配慢、可能碎片化、必须手动释放。这个区别直接解释了为什么递归深度太大时程序会栈溢出每一次递归都消耗一块栈帧栈空间默认只有8MB左右可以ulimit -s调整爆了就SIGSEGV。而堆可以申请几GB甚至更多因为它背后有虚拟映射来支撑。6.3 碎片的账单到底谁来付碎片化是堆管理的老大难。内部碎片是分配出去但没实际用到的缝隙比如你申请100字节分配器把你塞进一个128字节的槽位那28字节就浪费了。外部碎片是空闲内存分散在各处每个小块单独看都不够大凑也凑不出来连续大块于是明明总空闲内存够却申请不到一大块连续内存。减少碎片的方法不外乎几个方向用内存池维护固定大小的对象避免频繁交错的分配释放大对象尽量提前一次性分配好别反复搞多关注分配器本身的bin机制不要高频地申请稍微变化一点的尺寸。tcmalloc或者jemalloc这类现代分配器比glibc旧版在新场景下通常有更好的碎片控制和多线程扩展性实测下来很多高并发服务换成jemalloc后内存碎片和锁竞争都有明显改善这也是为什么Redis、RocksDB这类服务经常建议用jemalloc。7. 线上内存报警排查OOM Killer与泄漏的真实交战记录7.1 OOM Killer是怎么挑牺牲品的当物理内存和swap全部耗尽分配请求又必须立即满足时Linux会启动OOM Killer来杀进程腾内存。它有一个oom_score打分机制综合进程的内存占用大小、运行时间、权限、是否是root等因素。分值越高的进程越容易被杀。一般规律是占内存大且生命周期短的进程比长期存活的基础服务更容易被杀。这就是为什么数据库经常被误杀而一个占了好几个GB的批处理脚本反而可能活着。线上排查时千万不要只看free而要用dmesg查看内核日志里OOM Killer的详细输出里面会列出当时各进程的RSS和分数直接告诉你谁被盯上了。有一种常见误判因为一个进程内存暴涨触发了整机OOM结果杀掉的是别的进程真正的元凶反而没死。所以收到Killed告警第一反应不是重启而是去看OOM发生的完整上下文。7.2 内存泄漏的通用排查路径内存泄漏在现代高级语言里看似少见但C/C服务依然常见。泄漏的典型特征是RSS随时间单调上升而且GC或压测都没法让它降下来。排查路径建议按顺序来先确定是哪个进程涨得最凶。top -o %MEM看出进程smem看更准确的按比例统计。用pmap -x pid查看进程内部哪些映射区域在涨。如果某个堆区域从100MB涨到2GB方向就很明确。如果是C/C程序用valgrind或者AddressSanitizer跑一遍单元测试和压力场景。valgrind的memcheck能报告每次未释放的内存在哪一行分配缺点是非常慢我一般拿它做小规模复现。大型服务可以定期malloc_stats或者mallinfo打点观测分配器内部状态。Java/Golang服务优先用自带的分析工具jmap/arthaspprof看堆外还是堆内涨。7.3 两个很容易踩的非泄漏式内存陷阱有一种情况特别误导人某个服务的RSS涨得很高但实际没有泄漏。比如频繁创建线程每个线程默认栈是8MB虚拟内存虽然实际驻留的物理内存可能只有几KB但大量线程会让VMA数量和页表膨胀触发一些内核路径的慢变慢。另一个是分配器缓存导致的假高占用glibc里频繁分配又释放的小块内存会被暂存在bin里服务虽然瘦身了内存数字却没那么快下来。遇到过最经典的一个案例某服务每分钟写入一个大缓存文件代码里用mmap映射后忘记munmap导致每过一分钟新增一块映射很快虚拟地址空间和页表就爆了。排查时/proc/pid/maps里有几千条mmap记录一眼就看出来不对劲。修复就加了一行munmap内存曲线立刻拉平。内存管理这块纸上谈兵容易真到线上还是要多靠工具和系统视角去判断。我的习惯是任何内存告警先看/proc/meminfo和dmesg确认是物理压力还是虚拟空间膨胀再看进程级别的RSS和VMA分布最后才回到代码里找分配点。按照这个顺序来大多数内存疑难杂症都能在十分钟内定位到方向。