从虚拟内存到OOM:操作系统内存管理原理与实战

发布时间:2026/9/29 1:12:42
从虚拟内存到OOM:操作系统内存管理原理与实战 1. 内存管理为什么值得花一万字去啃操作系统里的内存管理是那种你平时感觉不到、一出问题就要人命的东西。我当年第一次写C程序遇到段错误盯着屏幕上那行 Segmentation fault (core dumped) 发了半小时呆那会儿才真正意识到内存这东西不是你想用就能随便用的。后来做后端服务、调Linux参数、排查线上OOM越往下挖越发现操作系统内存管理这套机制几乎决定了你程序跑得稳不稳、快不快、扛不扛得住。这篇内容我打算把内存管理从原理到实操完整过一遍。不管你是正在准备操作系统期末复习的学生还是刚入行不久、想搞明白 malloc 背后到底发生了什么的开发者或者是要处理Linux服务器内存告警的运维都能从里面捞到能直接用的东西。我不会只讲概念每个机制我都会告诉你它为什么这么设计、不用它会出什么问题、实际环境下怎么观测怎么调。语言尽量说人话复杂的地方我会拿生活里的例子类比代码和命令都是能直接抄过去跑的。先把地图铺开内存管理要解决的核心矛盾就一个——物理内存是有限的、稀缺的、大家要抢的而每个进程都想要一大片连续的、独占的、随便用的地址空间。操作系统夹在中间干的就是欺骗和调度两件事用虚拟内存给每个进程造一个我有整块内存的幻觉用分页、置换、分配器这些手段把有限的物理内存高效地分出去、收回来。理解了这条主线后面那些 Page Fault、TLB、伙伴系统、slab、swappiness 就都能串起来了。2. 从地址说起虚拟内存这套障眼法怎么运作2.1 逻辑地址和物理地址到底谁在骗谁很多人学到这里会绕晕我程序里int *p malloc(4)拿到的那个地址和内存条上真实的位置是一回事吗答案是不是。你代码里、编译器生成的都是逻辑地址也叫虚拟地址CPU执行指令时用的也是虚拟地址。真正落到内存颗粒上的是物理地址。中间那层翻译工作由MMU内存管理单元通常集成在CPU里配合页表完成。为什么要多这一层三个理由。第一隔离。进程A写坏了内存绝不能碰到进程B的数据虚拟地址让每个进程都以为自己独占整个地址空间。第二灵活。程序可以被加载到物理内存的任意位置不用连续链接器也不用操心实际落在哪。第三超卖。物理内存只有8G我可以给10个进程每个都分配4G的地址空间因为大部分页面根本不会被真正访问到用到才分配这叫按需分页。我常跟人说虚拟内存就像公司给每个员工发一张工位号但实际座位是共享的、流动的。你拿着工位号去办公前台MMU查页表告诉你今天坐哪明天可能换地方但你自己不用关心。2.2 地址翻译的完整链路翻译过程是怎么走的虚拟地址被拆成两段高位是页号低位是页内偏移。32位系统上一页通常4KB所以低12位是偏移。MMU拿页号去查页表找到对应的物理页框号拼上偏移就是物理地址。问题是页表太大了。32位、4KB页、每项4字节一个进程的页表就是 2^20 × 4B 4MB。每个进程4MB几百个进程就吃掉几百兆甚至上G纯属浪费。所以现代系统用多级页表。x86-64上典型是四级PGD页全局目录→ PUD → PMD → PTE。页表项只在真正映射了内存的地方才存在稀疏的地址空间不会浪费。代价是每次翻译要查四层慢。为了补这个慢CPU里加了TLB翻译后备缓冲相当于页表的高速缓存命中了就一步到位。TLB命中率对性能影响极大这也是为什么大页Huge Page能提速——一个2MB大页只需一个TLB条目就能覆盖原本512个4KB页的范围TLB覆盖范围一下子扩大512倍。数据库、虚拟机这类内存密集型应用开大页性能提升是真的能测出来的。注意改页表本身是个牵一发动全身的操作。进程切换时如果换页表x86上写CR3寄存器TLB通常要刷新但像Linux这样给内核地址空间留了共享区域就能避免全刷。这也是为什么进程切换比线程切换贵不少的一个底层原因。2.3 分段与分页为什么最后是分页胜出早期有分段方案按代码段、数据段、栈段这种逻辑单位划分每段大小不定更贴合程序结构。但段长可变带来外部碎片——内存里东一个洞西一个洞加起来够用就是凑不出一块连续的大空间。而且段间大小不一分配回收都麻烦。分页反过来把内存切成大小一致的页彻底消灭外部碎片内部碎片还有最后那页用不满会浪费但最多浪费一页。代价是程序在物理上不再连续页表要维护映射。今天主流的x86-64是段页结合——分段基本被弱化成一个形式大部分段基址设0真正的活交给分页。生活类比分段像停车位按车大小划大车占大位小车占小位时间长了车位浪费严重分页像统一车位所有车都停格子骑摩托的也占一格有点浪费但调度极简单高效。3. 虚拟内存与页面置换内存不够用时会发生什么3.1 缺页中断虚拟内存的魔法时刻虚拟内存的核心是按需调页。进程启动时操作系统只建立虚拟地址到页表的映射真正的物理页并不马上分配。等你第一次访问某个地址MMU查页表发现该页无效present位为0触发缺页中断CPU切到内核内核这时候才去分配物理页、从磁盘swap区或可执行文件把内容读进来、更新页表然后返回让指令重新执行。这一下你感觉不到但背后发生了一整套动作。缺页分几种。硬缺页要从磁盘读数据慢动辄几毫秒甚至十几毫秒软缺页不用IO比如写时复制、访问已加载但未映射的页快得多。还有次要缺页和主要缺页的区分性能分析时要看的就是主要缺页率。我见过一个服务QPS上不去最后查出来是内存吃紧导致大量主要缺页频繁读swap磁盘IO被打满加了几条内存立刻就顺了。3.2 页面置换算法谁该被踢出去物理内存塞满了要腾地方就得选一个牺牲者。这就是页面置换算法。FIFO先进先出谁最老踢谁。实现简单但有个致命毛病——Belady异常内存增大反而缺页更多因为它会踢掉常用的老页面。LRU最近最少使用踢最久没被访问的理论上最贴近最优。但严格实现要维护访问时间戳或者用栈代价大。实际系统多用近似LRU。Clock时钟算法给每页一个访问位指针转圈扫访问位是1就置0放过是0就淘汰。这是LRU的廉价近似Linux的活跃/非活跃链表本质是这个思路的进化版。OPT理论最优但需要预知未来只能当基准来评估其他算法没法落地。我给学生讲的时候喜欢用一句话总结置换算法的本质是在猜哪个页面以后用不到了。LRU赌的是最近用的以后还会用工作集理论支撑这一点。现实中程序确实有局部性——时间局部性刚访问的还会访问和空间局部性相邻地址会被一起访问所以LRU系才管用。3.3 抖动与工作集别让系统陷入颠簸如果进程活跃页面多于物理内存能容纳的量就会出现抖动Thrashing——刚换出去的页马上又要用不停地缺页、换入换出CPU全耗在换页上有效吞吐暴跌。这是最糟的状态表现是CPU看着不高但系统卡死磁盘灯狂闪。解决办法核心是工作集模型估算进程当前真正频繁使用的页面集合保证这个集合能常驻内存。如果工作集总和超过物理内存正确做法是挂起部分进程减负载而不是硬撑。Linux里 kswapd 内核线程负责后台回收一旦它占CPU过高通常就是在疯狂换取页面这时候就得看内存是不是真的不够了。4. 分配器与回收从malloc到内核伙伴系统4.1 C程序的堆内存malloc背后发生了什么写C/C的人天天用 malloc/free但真正理解它的不多。程序的内存布局大致是代码段、常量区、全局/静态区、堆向上增长、栈向下增长。局部变量在栈上函数返回自动回收malloc出来的是在堆上必须手动free。malloc是库函数不一定每次都进内核。glibc的 ptmalloc 在用户态维护空闲链表小块内存直接从池子里切给你只有池子不够了才通过brk或mmap系统调用找内核要。所以你会看到程序 free 了一堆内存top里的 RSS 却不降——因为内存还留在用户态分配器手里没还回去。几个必踩的坑内存泄漏malloc了没free。长时间运行的服务必炸。用 valgrind、AddressSanitizer 或mtrace查。野指针/悬垂指针free之后指针没置NULL或者返回了局部数组地址。访问它们行为未定义可能不报错但数据是脏的最难查。越界写strcpy经典问题多写一个字节就可能破坏堆的管理元数据崩溃点往往离出错点很远。关于内存对齐CPU访问对齐地址更快某些架构上不对齐直接硬件异常。结构体默认按成员最大对齐要求填充sizeof往往大于成员之和。网络协议、驱动、共享内存的布局必须精确这时候要用#pragma pack或手动排列成员。实操心得跨模块传内存有个铁律——谁分配谁释放。你在A模块malloc、B模块free一旦两边用的分配器不同比如一个静态链接了一个动态链接glibc崩溃必来。4.2 内核分配器伙伴系统与slab内核自己也要内存但它不能用malloc。Linux物理内存管理核心是伙伴系统把空闲页框按2的幂2^0到2^10个连续页即4KB到4MB分组成块分配时找最合适的块不够就往上找大块劈开释放时如果相邻伙伴也空闲就合并成更大的块。这套机制专门对付外部碎片——小块能合并回大块。但伙伴系统最小粒度是页4KB。内核里大量小对象inode、文件描述符、task_struct只有几十到几百字节如果每个都占一整页浪费到离谱。于是有了slab 分配器现在主流是SLUB为每种常用对象预先建一个缓存池对象用完回收进池子复用不还给伙伴系统。相当于内核给每个高频对象开了个专属仓库。内核里打内存相关的关键数据结构值得记住几个结构作用mm_struct描述一个进程的整个地址空间vm_area_struct描述一段连续的虚拟内存区域VMApg_data_t/zone内存节点与区域DMA、Normal、HighMempage描述一个物理页框address_space文件与页缓存的映射关系anon_vma反向映射用于回收匿名页时快速找页表项理解这些结构的关系看/proc/pid/maps和/proc/pid/smaps就能对上了。maps列出的每一行基本对应一个 VMA。4.3 内存回收与OOM最后一道防线内存不足时内核的回收逻辑大致是先回收页缓存文件页脏页先写回干净页直接丢以后再读回来不够就回收匿名页写进swap再不够触发直接回收让申请内存的进程自己去做回收很慢。如果这都救不回来OOM Killer出手挑一个进程杀掉腾地方。OOM Killer 挑谁下手有个打分机制oom_score一般内存占用大、优先级低的先死。生产环境里最怕的就是核心服务被误杀所以要用cgroup给关键服务设内存上限和优先级关键进程设oom_score_adj调低被杀的权重甚至设-1000彻底免疫但慎用可能让系统整体崩。提前监控/proc/meminfo里的MemAvailable而不是只看MemFree——Linux会把大量空闲内存拿去做缓存MemFree低是正常的。5. 实战观测怎么判断内存到底哪里出问题5.1 常用工具与关键指标解读光看free -h那个 free 数字容易被吓到其实要看available。下面是我排查时常用的一组命令。free -h # 总览重点看 available 和 swap vmstat 1 10 # 每秒采样看 si/so换入换出是否非零 cat /proc/meminfo # 最全的内存明细 ps aux --sort-rss | head # 按物理内存占用排序进程 pmap -x pid # 看某进程的地址空间分布 slabtop # 内核slab占用排行解读要点si/so长期非零说明在频繁换取性能必然受影响cat /proc/meminfo里Slab、PageTables、Cached要分开看PageTables 特别大说明有进程用了巨量映射vmstat的free列配合buff/cache一起看。5.2 一次内存泄漏的排查实录之前有个Java服务跑几天RSS就涨到几十G然后被OOM。复盘思路先ps确认是它pmap -x看堆段一直在涨排除内核slab问题。JVM内部用jmap -histo:live看对象直方图发现某缓存Map条目数只增不减定位到是本地缓存没设上限。改成有界缓存LRU配maxSize后稳定。如果是C服务我会先用 valgrind --leak-checkfull 跑一轮或者直接编译时加-fsanitizeaddress。ASan 的优势是能精确指出泄漏点在哪一行、哪次分配没释放。Julia 这类带GC的语言看的是GC日志和内存分配统计time宏和--track-allocationuser都能帮忙定位频繁分配的热点。5.3 几个值得调的参数参数作用建议vm.swappiness换出匿名页的倾向数据库类可调低到1-10vm.overcommit_memory内存超卖策略Redis等建议设1vm.min_free_kbytes保留最小空闲视内存规模调vm.dirty_ratio脏页占比阈值写密集型可调低transparent_hugepage透明大页延迟敏感服务评估后再定改之前务必先压测。参数不是越大越好调错了反而引发卡顿尤其是大页和脏页参数。6. 常见问题速查与避坑清单6.1 典型问题速查表现象可能原因排查方向进程RSS持续上涨不降内存泄漏/缓存无上限valgrind、对象直方图free显示内存快满但系统流畅缓存占用看available而非free系统卡顿、磁盘灯狂闪抖动/频繁换页vmstat看si/so进程莫名被杀OOM Killerdmesg编译报内存不足单次分配过大拆分任务、加swap结构体sizeof超预期内存对齐填充调整成员顺序或用pack6.2 我踩过的坑最后分享几条真金白银换来的经验。第一dmesg是排查OOM的第一现场grep -i -E oom|killed process就能看到谁杀谁、当时各进程的评分和内存占用比事后瞎猜高效得多。第二别迷信内存越大越好——给JVM堆设太大GC停顿会变长给容器设的limit如果小于堆加元空间的开销容器会被OOM而不是JVM自己报错排查时会绕大弯。第三写C代码时free完立刻把指针置NULL这个小习惯帮我省了无数个深夜调试。第四容器环境里free看到的是宿主机内存要看cgroup的memory.limit_in_bytes和memory.usage_in_bytes才是真实约束。内存管理这块知识看一遍记住了是假的只有在真正调过参数、复现过缺页、被OOM杀过进程之后那些概念才会变成你脑子里的直觉。我建议你拿台虚拟机故意把内存配小跑个吃内存的程序亲手观察一遍从内存增长到swap换入换出再到OOM的完整过程比看十篇文章都管用。