多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Linux虚拟内存机制详解:从MMU页表到OOM故障排查实战

Linux虚拟内存机制详解:从MMU页表到OOM故障排查实战 从一次线上事故说起上周我值班时遇到一件挺典型的事一台 8G 内存的服务器跑了三个 Java 服务加一个 MySQL原本一直很稳突然某天下午开始频繁出现java.lang.OutOfMemoryError紧接着部署在上面的支付回调服务被系统直接杀掉了。登录上去一看free -h显示物理内存明明还剩 2 个多 G但top里每个 Java 进程的 VIRT 值都高得吓人其中一个居然到了 15G。这就是虚拟内存和物理内存最容易让人混淆的地方——VIRT 是进程眼中的地址空间不代表它真的占用了那么多物理内存。这类问题在 Linux 服务器上太常见了。无论你是刚入门的运维、做后端开发的程序员还是在准备 Linux 面试的求职者只要跟 Linux 打交道绕不开虚拟内存这关。它既是面试官最爱问的底层原理也是线上故障排查时绕不过去的坎。这篇文章我想把自己这些年对 Linux 虚拟内存的理解、常用的排查工具、以及踩过的坑一次性讲清楚。需要提前说明的是文章里涉及的一些内核行为描述是基于主流发行版如 CentOS 7、Ubuntu 18.04的 3.x/4.x/5.x 内核来写的不同内核版本在细节上会有差异但整体框架是一致的。1. 为什么物理内存不够用系统还能同时跑那么多进程想理解虚拟内存得先回到一个最原始的问题物理内存到底有几个让人头疼的毛病。1.1 物理内存的三大先天缺陷第一是容量太小。一台服务器物理内存就 8G、16G但运行的程序加起来对内存的需求可能远超这个数。总不能因为内存不够就不让程序跑了这就像图书馆座位有限但读者很多你得想办法让更多人看起来有座位。第二是碎片化严重。程序申请内存时是按连续地址申请的但物理内存经过反复分配和释放之后会出现大量细碎的空洞。你要一个 100MB 的连续区域物理内存里可能只有几十个 3MB、5MB 的小碎片拼不出一块大的。这就像拼图碎片越多越难找到一整块。第三是进程之间没有隔离。如果每个程序直接操作物理地址程序 A 一不小心写坏了程序 B 的内存整个系统就可能崩溃。这太可怕了——你运行一个浏览器难道要让操作系统和其他程序都跟着提心吊胆吗1.2 虚拟内存给出的解法给每个进程发一个假地址空间Linux 的做法是让每个进程都认为自己独占了整个内存地址空间。在 64 位系统上这个空间大到夸张——理论上限是 2^64也就是 16EBExabyte。当然进程实际能用多少受内核限制但每个进程有自己独立地址空间这个假象是真实的。这个假象由 CPU 里的MMU内存管理单元和内核一起配合完成。进程访问内存时CPU 发出的不是物理地址而是一个虚拟地址MMU 负责把这个虚拟地址翻译成真正的物理地址如果发现这个地址对应的物理页不在内存里就会触发一个异常交给内核处理。我特别喜欢用一个类比来解释这件事虚拟内存相当于给你一个超大号的书架图但实际你只租了一小块仓库。程序照着书架图去取书图书管理员MMU帮你跑腿去仓库拿仓库没有这本书时管理员就去硬盘这个总部文件柜里翻出来再运过来。1.3 虚拟内存带来的三个实际好处理解了这个机制你就能明白为什么虚拟内存是现代操作系统的基石进程隔离进程 A 无论如何也访问不了进程 B 的地址空间因为翻译过程由内核和 MMU 接管非法访问会被直接拒绝段错误就是这样来的。按需加载程序启动时内核只是把可执行文件的映射关系建好不会真的把整个程序读进内存。等程序真的执行到某段代码时才通过缺页中断去硬盘里加载那一页。这就是为什么一个 500MB 的程序启动速度可以很快。内存共享多个进程可以把自己的虚拟地址映射到同一块物理内存上最典型的就是动态库.so。你开了 10 个 Java 进程libc.so 在物理内存里可能只有一份拷贝但在每个进程的虚拟地址空间里都有它的位置。提示我们常说的程序占用内存准确说法应该是程序占用的物理内存RSS而不是虚拟内存VIRT。很多人被 VIRT 吓到其实 VIRT 里面包含了大量从未被访问过的地址空间它们不占物理内存。2. 一次内存访问的完整旅程MMU、页表与缺页中断有了虚拟地址和物理地址之后最核心的问题来了翻译过程到底是怎么实现的如果每次访问内存都要软件翻译一遍性能会差到没法用所以硬件 MMU 承担了绝大部分翻译工作软件只在翻译不了的时候兜底。2.1 分页机制把地址空间切成固定大小的格子Linux 把虚拟地址空间和物理内存都切成固定大小的块叫做页Page。x86-64 架构下默认页大小是 4KB。为什么要用固定大小的页你可以想象成把书架和仓库都按同样的格子划分每个书架格子对应一个仓库格子管理起来就非常规整也方便用索引页表快速查找。虚拟地址本身被拆成两部分页号 页内偏移。页号用来索引页表找到物理页框号页内偏移直接拼到物理地址末尾。比如一个 4KB 的页页内偏移占 12 位剩下的高位都是页号。2.2 多级页表为什么不能只做一张大表如果每个进程都维护一张完整的页表那开销会非常恐怖。64 位系统下虚拟地址空间巨大一张扁平页表需要的空间比整个物理内存还大。所以 Linux 采用多级页表x86-64 下默认是四级页表PGD - P4D - PUD - PMD - PTE每一级只索引地址的一部分。多级页表的精妙之处在于上一级页表项如果为空就不用为下一级分配任何页表。进程刚启动时地址空间用得很少可能只有最顶级的几个页表目录有内容整棵页表树非常稀疏。这就像公司组织架构某个部门一个人都没有就不用给这个部门单独安排办公室了。2.3 缺页中断翻译失败的兜底方案MMU 翻译地址时如果发现页表项里没有对应的物理页就会触发缺页异常Page Fault暂停当前进程跳进内核的缺页处理程序。我在排查性能问题时最常看的两个概念就是minor fault轻微缺页物理页其实在内存里只是页表项还没建立或者页面被换到了页缓存里。这种缺页处理速度很快几十纳秒量级基本不影响性能。major fault严重缺页真的要从磁盘读数据比如程序首次访问某个代码段、或者从 swap 换入一个被换出的页。这种缺页可能耗时几毫秒甚至更久如果频繁发生系统就卡得没法看了。判断系统是否在进行大量磁盘交换有个很直观的命令vmstat 1盯着si和so两列如果持续大于 0说明系统正在疯狂换入换出这是性能告急的明确信号。2.4 进程地址空间的经典布局每个进程的虚拟地址空间不是一坨混沌而是有清晰结构的。从低地址到高地址大致是区域用途增长方向代码段 text可执行指令固定数据段 data/bss全局变量、静态变量固定堆 heap运行时动态分配malloc向高地址增长内存映射区 mmap动态库、mmap 文件映射向低地址增长栈 stack函数调用、局部变量向低地址增长内核空间内核代码与数据结构高地址固定区域这里面有个很有意思的细节栈和堆是相对而长的。堆从低地址往高地址长栈从高地址往低地址长中间隔着一大片空闲区域。正常情况下两者永远不会碰头但如果堆长得太疯、或者栈递归太深就会在中间区域相遇结果就是内存分配失败或者栈溢出。我见过一次 C 程序因为递归死循环导致堆栈重叠整个进程直接 Segmentation Fault排查起来相当诡异。3. 实战观测free、vmstat、top 里的数字到底在说什么这一节可能是大家平时最常用的部分。很多运维同事每天看free -g但问起输出里每个字段的含义却不一定能答全。我帮你把这些数字一个个掰开揉碎。3.1 free先搞懂 buff/cache 与 available 的区别在一台 CentOS 7 机器上执行free -h通常看到这样的输出total used free shared buff/cache available Mem: 15G 4.1G 1.2G 156M 10G 10G Swap: 2.0G 0B 2.0G很多人一看 used 才 4.1G、free 只有 1.2G 就开始慌其实这是个误会。buff/cache那 10G 是内核用空闲内存来缓存文件数据page cache和块设备缓冲buffer这部分内存在应用需要时内核能立刻回收给它用。所以真正要看的是available它才是在不触发 swap 的情况下还能分给进程的内存估算值。available的计算考虑了可回收的 cache、slab 可回收部分等一般而言比free大很多。判断内存是否充足应该盯available而不是free。我在线上判断内存告警习惯写个简单的监控表达式available / total 0.2就触发报警。3.2 vmstatsi 和 so 是内存压力的晴雨表vmstat 1的输出里除了 CPU内存相关的siswap in和soswap out需要格外注意。这两个数如果持续为非零说明系统内存已经不够用了正在频繁把内存页换到磁盘swap out或者从磁盘读回swap in。一旦进入这种状态应用延迟会急剧上升因为磁盘 IO 比内存慢了好几个数量级机械盘尤其明显。我经历过一个案例一台数据库服务器内存 32G跑着几个大批量查询把内存吃光后开始 swap结果就是磁盘 IO 飙到 100%数据库查询全部变慢业务反馈卡死。当时的处理办法是先杀了一批占用内存最多的查询进程系统才缓过来——但根因是内存规划不足后来加了物理内存并限制了单查询的内存上限才彻底解决。3.3 top/psVIRT、RES、SHR 三兄弟要认清top里进程那一行有三个跟内存相关的列很多新手会混淆VIRTVirtual Memory Size进程虚拟地址空间的总大小。只要进程有没有访问过那些地址都会被算进去。所以 Java 进程 VIRT 动不动几十 G 是很正常的不代表真的占那么多。RESResident Set Size进程实际占用的物理内存大小这才是真金白银。看内存占用RES 才是首要指标。SHRShared Memory进程使用的共享内存量主要是动态库和共享内存段。它包含在 RES 里但多个进程可以共享同一份。我经常用小技巧来判断 Java 服务的内存泄漏持续观察 RES 是否随时间稳定上涨而 cached 又在不断下降。如果 RES 不降、free 的 available 持续走低基本可以断定有内存没被释放。3.4 /proc/meminfo一切监控数据的源头如果上面的工具都不够用了直接去看内核的账本cat /proc/meminfo里面有MemTotal、MemFree、MemAvailable、Buffers、Cached、SwapTotal、SwapFree、Dirty、Writeback等关键字段。free、top这些工具本质上都是读这个文件。Dirty字段尤其值得关注它表示还在 page cache 里、尚未写回磁盘的脏页数量。如果Dirty持续很高说明磁盘写速度跟不上应用产生脏数据的速度这时候用sync命令强制刷盘往往能让系统立竿见影地稳定下来。4. 内存不够时系统如何自救回收、Swap 与 OOM 裁决虚拟内存不是无限的数字游戏物理内存总有耗尽的一天。内核设计了一套分层次的自救机制理解这套机制线上出问题时你才知道该往哪个方向查。4.1 内存水位线与 kswapd 后台回收内核不是等到内存彻底耗尽才开始回收而是维护了几条水位线watermarkhigh、low、min。当空闲内存低于low水位线时后台内核线程kswapd会被唤醒开始回收内存页如果回收速度赶不上消耗速度空闲内存继续下探到min以下内核就会进入**直接回收direct reclaim**路径此时申请内存的进程会被阻塞亲自参与回收工作——这就是系统卡住的常见原因之一。/proc/sys/vm/min_free_kbytes控制着 min 水位线的绝对值生产环境我建议不要随意调低否则内存紧张时系统会进入假死状态。相反在内存较小的机器上适当调大这个值可以避免陷入慢性内存抖动。4.2 LRU 链表回收也是有优先级的回收不是随机挑页面扔掉。内核维护了 page cache 的 LRU最近最少使用链表同时区分匿名页进程堆栈要写回 swap和文件页文件缓存直接丢弃或写回即可。回收顺序大致是优先回收干净的 file-backed 页这些页直接从磁盘读入且未被修改丢弃后无需写盘代价最小。其次回收脏的 file-backed 页需要先写回磁盘。最后才回收匿名页必须写回 swap 分区或 swap 文件代价最高。这就是为什么系统内存紧张时cached会先下降——因为它回收成本最低。如果 cached 已经被回收得差不多了系统开始换出匿名页swap 的使用量就会上涨。此时你会看到vmstat里的so变大这通常意味着真的到了比较危险的阶段。4.3 swappiness一个被神化又容易被误解的参数/proc/sys/vm/swappiness控制内核倾向于回收匿名页换出到 swap还是回收文件页丢弃缓存。取值 0 到 100默认 60。很多人误以为把它设为 0 就能禁用 swap其实不对。swappiness0 只表示内核尽量避免换出匿名页但在内存极紧张时仍然会 swap。我自己的经验是对于数据库、缓存类服务因为要尽量避免 swap 造成的延迟抖动可以把 swappiness 调到 10 甚至 0对于普通 Web 服务默认值基本就够用不必过度调优。调完用sysctl -p生效但重启后如果想保持要写进/etc/sysctl.conf。提示swap 在很多运维眼里是不好的东西但合理保留少量 swap 其实是保险丝。极端情况下内存瞬间爆掉swap 能给你多争取几分钟反应时间而不是直接触发 OOM 杀进程。4.4 OOM Killer内核的最后裁决如果内存回收都来不及内核对进程的死刑判决就来了。OOM Killer 会根据oom_score选一个进程杀掉。这个分值跟进程占用的内存量、运行时间、优先级等因素有关oom_score越高越容易被杀。管理员可以调整/proc/pid/oom_score_adj来给重要进程免死或者给次要进程顶罪。我在生产中遇到过 MySQL 被 OOM Killer 杀掉的情况。当时排查dmesg | grep -i oom看到Out of memory: Killed process 12345 (mysqld) total-vm:12G, anon-rss:6G其实根源是同一台机器上有个跑数据分析的 Python 脚本内存占用几乎不受控把内存耗尽后 OOM Killer 按分值和权重选择了 MySQL。解决方式是给 Python 脚本加 cgroup 内存限制并调高 MySQL 的oom_score_adj这样即使内存紧张被杀死的也是数据任务的进程而不是核心数据库。5. 线上内存故障排查与 Linux 面试高频题最后这部分既是对前面内容的综合运用也是我在带团队和面试候选人时反复验证过的一套思路。5.1 内存故障排查的一条龙流程当怀疑某个进程内存异常时我建议按下面的顺序排查避免瞎猜看现象free -h看 availablevmstat 1看 si/so。定位进程用top按内存排序按 M 键找出 RES 异常高的进程。看明细进入进程内部cat /proc/pid/smaps可以看每一段映射的具体 RSSsmaps_rollup可以看汇总。判断泄漏还是峰值持续观察 RSS 是否只涨不降如果malloc申请的内存不释放RSS 会一路走高。查系统日志dmesg -T | grep -iE oom|killed确认是否触发过 OOM Killer。确认 swap 状况swapon -s查看 swap 分区使用量cat /proc/sys/vm/swappiness确认参数。这套流程我用了很多年出问题后基本能在五分钟内圈定方向比一头扎进代码里调试高效得多。5.2 几个容易踩的坑第一只看 free 不看 available。前面说过free 很小但 available 很大时系统完全健康。相反free 很大但 available 很小说明 cache 无法回收通常是内存碎片或不可回收内存如内核页表、不可回收 slab异常增长。第二以为 swap 用了就是问题。Linux 在有空闲内存时也可能换出一些很久不用的匿名页这属于正常行为不必每次都大惊小怪。关键在于看 si/so 是否持续非零以及是否有明显性能下降。第三忽略了 page cache 的 src/数据一致性。在数据库场景中cached大量存在并不代表内存浪费那可能是热数据的页缓存能显著加速查询。冒然 echo 1 /proc/sys/vm/drop_caches 清缓存短期看着内存 free 多了实际可能把热数据全部清出去之后查询全部变成磁盘 IO性能反而更差。5.3 面试常问的虚拟内存问题我给一份答案框架面试官问虚拟内存通常不是想听你背概念而是想看你能不能讲清楚为什么和怎么办。我整理几个高频题目的答题思路虚拟内存解决了什么问题从隔离性、按需加载、扩展物理内存三个角度回答顺带提一下多进程共享动态库。缺页中断有哪几种minor fault 和 major fault 的区别major fault 会导致性能下降因为涉及磁盘 IO。为什么进程 VIRT 很大但物理内存占用不高因为虚拟地址空间只有被访问到的部分才会真正分配物理页其余只是映射关系。什么是内存泄漏怎么排查程序申请内存后不再使用但未释放用 top 看 RSS 持续增长用 smaps 看具体哪段在涨。Swap 和虚拟内存是什么关系Swap 是虚拟内存系统中把匿名页换到磁盘的机制它让可用内存可以超过物理内存上限但也带来性能开销。面试时如果能顺手举一个自己在生产环境的真实案例比如我遇到过 OOM 杀了 MySQL原因是数据脚本内存不受控会比干巴巴背定义有说服力得多。5.4 我的几个调优习惯最后分享几个我平时最常用的配置习惯都是经过线上验证的保留少量 swap比如物理内存的 10% 或固定 2G作为保险丝。数据库服务器将 swappiness 调低到 10 左右避免不必要的换页抖动。对重要进程调整oom_score_adj核心服务设为 -500 到 -1000次要批处理任务设为正值让 OOM 裁决更符合业务预期。定期检查/proc/meminfo里的Dirty和Writeback如果长期高考虑调整vm.dirty_ratio和vm.dirty_background_ratio让脏页更积极地落盘。监控系统里不只报内存总量更要把available、si/so、OOM 事件数纳入重点告警项。我对虚拟内存最大的感受是它不是一个孤立的 Linux 概念而是把硬件MMU、内核页表、回收机制、操作系统设计进程模型和运维实践串在一起的一根主线。把这个主线搞透了很多玄学问题其实都是可以推理出来的。比如刚开头那次事故最后查出是支付服务在某个特定业务路径上疯狂创建大数组且未释放加上 JVM 堆外内存管理不当导致 RSS 持续攀升。即便如此系统没有立刻崩溃而是先吃 cache、再触发 swap最后才走到 OOM——Linux 虚拟内存这一整套缓冲机制已经帮我们争取了足够的观察和介入时间。搞懂它调试起来才能从容不迫。
返回列表