ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux 内存管理深度解析:从虚拟内存到线上故障排查

Linux 内存管理深度解析:从虚拟内存到线上故障排查 很多做运维和后端的朋友应该都有过这种经历登上一台服务器free -h一看内存用了 95%top里翻来翻去也没找到哪个进程吃了这么多。然后你开始怀疑是不是被挖矿了是不是有进程退出后没释放折腾半天才发现大部分都被 page cache 占用了应用一有需要系统会立刻吐出来。这篇文章我想把 Linux 内存管理这块彻底讲透——从最底层的虚拟内存与分页机制到进程地址空间怎么排布再到物理内存的分配与回收最后落到性能观测和线上问题排查。无论你是在准备面试还是在运维一线被内存问题折磨过这篇都值得存一份慢慢对照着看。内容偏底层但我会尽量说人话保证每个概念都有例子、有原理、有实战意义。1. 虚拟内存先搞清楚 Linux 为什么非要搞这么一层很多刚接触 Linux 内存管理的人最难理解的就是“虚拟内存”这个概念。明明物理内存就那么大为什么进程看到的地址空间和物理内存对不上要回答这个问题得先从“如果没有虚拟内存会怎样”说起。1.1 进程隔离与地址连续性虚拟内存解决的两个核心问题如果没有虚拟内存进程 A 和进程 B 直接操作物理地址会出现两个问题。第一是隔离。进程 A 不小心写了一个非法地址可能直接把进程 B 的内存改掉甚至把内核数据改掉整个系统直接崩溃。服务器上几十个进程谁都不能保证自己不越界。第二是连续性。进程加载一个程序代码、数据、堆、栈需要摆在一块连续的物理内存里。物理内存被多个进程分来分去后连续的大块内存很快就没有了新进程根本加载不进来。虚拟内存的引入相当于给每个进程发了一张独立的、看似连续的地址地图。进程以为自己拥有一整块从头到尾连续的地址空间实际上这些地址会被内核一层层翻译成真正的物理地址。翻译错了或者访问未被映射的地址就会触发段错误。1.2 内核态与用户态的 3:1 分割Linux 在 32 位时代经典的地址空间划分是 0~3GB 给用户态进程用3GB~4GB 留给内核态。用户态代码不能直接访问内核态区域只有通过系统调用时 CPU 切到内核态才能操作那 1GB 空间。到了 64 位时代地址空间大得多。x86-64 下用户空间默认有 128TB内核空间还有另外一大块。用户态进程平时能看到的也就是那 128TB但实际上它只会用到其中很小一部分。这里有个很反直觉的点虚拟内存大不代表物理内存需求就大。进程可以声明一块很大的虚拟地址空间但只要不实际读写就不会占用物理内存。这也是为什么你在top里看到一个进程 VIRT 有几十 GBRES 可能才几百 MB——VIRT 是它申请的虚拟地址空间总量RES 才是真真切切占着物理内存的页。1.3 按需加载与共享虚拟内存带来的额外红利虚拟内存除了隔离和连续性还带来两个非常实用的能力。一个是按需加载。程序启动时内核并不需要把整个可执行文件全部读进内存。它会建立好地址映射等进程真的访问到某一段代码时再触发缺页异常从磁盘把对应页面加载进来。启动一个大程序因此变得非常快。另一个是内存共享。动态库.so在物理内存里通常只保留一份不同进程的虚拟地址通过页表映射到同一块物理页上。比如 libc 这个库系统上几百个进程都在用但物理内存里只有一份代码段副本。这也是free命令里 shared 那一列存在的原因。2. 分页机制与内存管理单元虚拟地址是怎么翻译成物理地址的虚拟地址翻译成物理地址靠的是 CPU 里的内存管理单元MMU和内核维护的页表。操作系统里的“内存管理单元包含页号页框号”这句话说的就是页表项的核心内容虚拟页号映射到哪个物理页框号。2.1 页、页框、页表项的基本概念Linux 默认页大小是 4KB。进程的虚拟地址空间被切成一个个等长的块这些块叫“页”Page物理内存也被切成同样大小的块叫“页框”或“页帧”Page Frame。虚拟页和物理页框之间的对应关系记录在页表项PTE里。一个 PTE 里除了物理页框号还包含很多标志位。比如可读、可写、可执行、是否被访问过Accessed、是否被写过Dirty、是否用户态可访问等。这些标志位不仅是权限检查用的也是内核内存回收和 swap 换出的重要依据。缺页异常发生时内核先看 PTE 里的 present 位——如果页不在内存里就得分情况处理。2.2 多级页表为什么不用一张巨大的线性表如果只用一个线性页表32 位下虚拟地址空间 4GB、每页 4KB需要 100 万个页表项每个 PTE 占 4 字节就需要 4MB。每个进程备一份 4MB 的表100 个进程就是 400MB这还不算 64 位的 128TB 地址空间——线性表根本不可能实现。所以 x86-64 架构用了四级页表PML4 → PDPT → PD → PT → Offset。虚拟地址被拆成这几段每一段作为对应层级页表的索引。中间任意一级页表为空就表示这一大块地址空间完全没有映射不需要为它分配下级页表。这样进程即使声明了巨大的虚拟地址空间只要没用到的部分页表开销也极小。2.3 TLB 与缺页中断性能的关键路径每次访问内存都走一遍四级页表性能会非常差。所以 CPU 里有一个叫 TLBTranslation Lookaside Buffer的缓存专门缓存最近用过的虚拟页到物理页框的映射。TLB 命中就跳过页表遍历直接拿到物理地址。但 TLB 的条目数有限而且要管所有进程的映射。所以内核每次切换进程时都要把 TLB 里的旧进程映射清掉这也是进程切换开销的一部分。后来 MMU 引入了地址空间标识符ASID允许不同进程的 TLB 条目并存减少了切换时的刷 TLB 开销。缺页中断则是整个按需分页机制的核心触发点。进程访问一个虚拟地址如果页表项 present 位为 0CPU 会触发缺页异常进入内核的缺页处理流程。内核先判断这次访问是不是合法的——如果地址压根不在进程的虚拟地址空间范围里直接送 SIGSEGV如果是合法但物理页尚未分配就分配一页并建立映射如果页被 swap 换出了就从交换分区读回来。整条路径跑完进程恢复执行完全感知不到刚才发生了什么。3. 进程地址空间布局堆、栈、mmap 区域的选址逻辑理解了虚拟地址怎么映射到物理内存再来看一个进程的虚拟地址空间内部是怎么组织的。这一块对排查线上问题非常关键比如为什么malloc之后 RSS 没涨为什么栈溢出一般不会立刻崩。3.1 从低地址到高地址text、data、bss、堆、mmap、栈一个典型的 Linux 进程态地址空间从低到高大致是这么排的text 段代码段只读可执行。data 段已初始化且有非零初始值的全局变量。bss 段未初始化或初始值为 0 的全局变量不占磁盘空间加载时清零。heap堆向上增长malloc分配的动态内存主要在这里。mmap 区域共享库、mmap映射的文件、线程栈、匿名映射等。stack栈向下增长局部变量、函数调用信息在这里。你可以对自己的进程跑一下cat /proc/self/maps看到的就是一张完整的地址分布图。每一行格式是“地址范围 权限 偏移 设备 inode 路径”。分析内存泄漏时这个文件比top好用得多因为你能精确看到每一块匿名映射从哪里到哪里。3.2 为什么堆向上、栈向下中间留那么大空隙堆从低地址向上增长栈从高地址向下增长中间留着一大块未映射区域。这样设计的目的是让堆和栈可以相对独立地增长不至于一个进程稍微多用点内存两个区域就撞上。栈向下增长意味着一个进程的调用栈深度是有限的。默认栈大小通常是 8MBulimit -s可查。一旦递归过深或者栈上声明了超大数组栈指针就会进入它下方的不可访问区域触发段错误这就是栈溢出。注意内核不会提前检查栈空间够不够而是等真正越界的瞬间才通过缺页异常发现非法访问。堆和 mmap 区域的配合也很有讲究。glibc 的malloc分配小块内存时走brk系统调用直接在堆顶扩展分配大块内存时走mmap在 mmap 区域单独映射一块匿名内存。实测下来这个分水岭大约是 128KB。brk分配的内存放在堆里释放后不一定还给操作系统而mmap分配的内存在munmap后会立刻归还RSS 马上降下来。这就是为什么有些进程反复 malloc/free 后 RSS 居高不下因为大量 small block 走的是 brk内存碎片留在了堆里。3.3 ASLR 与随机化地址布局不只是为了内存效率现代内核默认开启地址空间布局随机化ASLR每次启动进程时栈、mmap 区域、动态库加载地址都会随机偏移。这样做的主要目的是安全——防止攻击者利用固定地址进行 ROP 攻击。但 ASLR 也给性能分析带来一点小麻烦。比如你每次cat /proc/pid/maps看到的动态库地址都不同如果写脚本对比进程内存布局就要注意这种随机性。排查问题时我一般看的是相对变化趋势而不是绝对地址。4. 物理内存的分配与回收伙伴系统、slab、page cache 与 swap 的协作虚拟地址空间只是地图真正要运行程序必须把物理内存分配给这些虚拟页。物理内存的分配、回收、缓存与交换是 Linux 内存管理中最“兵荒马乱”的部分也是线上故障的重灾区。4.1 伙伴系统为什么物理页按 2 的幂拆分内核管理物理内存的核心算法是伙伴系统Buddy System。它把空闲物理页按连续的 2 的幂次分组order 0 是一页4KBorder 1 是两页8KBorder 2 是四页16KB依此类推。分配内存时从对应的 order 链表里取一个块如果没有就向上找更大的块把它不断拆半直到满足需求。释放时做的是逆操作——把相邻的空闲块合并回更大的块。这种“分裂合并”的机制能很好避免外部碎片。但是伙伴系统分配的内存至少是一页内核里大量的小对象比如 task_struct、inode如果每次都要一页页分配浪费会非常可观。4.2 slab/slub 分配器内核小对象的缓存池为了解决内核小对象分配浪费的问题Linux 引入了 slab 分配器现在主流内核用 slub 实现。它针对每一种内核对象建立一个缓存池比如task_struct_cachep专门管理进程描述符。对象释放后不归还给伙伴系统而是留在缓存里复用下一次分配直接拿走省去了频繁的页分配和页表操作。从运维角度看slab 占用的内存会在free的 used 里体现但top按进程看不到因为它是内核自己的缓存。如果发现系统内存被大量占用但查不到是哪个进程记得看一眼/proc/meminfo里的Slab字段以及cat /proc/slabinfo找具体是哪些对象。4.3 page cache文件读写的隐形加速器page cache 可能是最容易让运维误判内存问题的元凶。Linux 读写文件时并不直接读写磁盘设备而是先经过 page cache。读文件时如果 page cache 命中直接从内存返回没命中才从磁盘读入。写文件时也是先写到 page cache标记为脏页再由内核的 flusher 线程异步刷回磁盘。这种设计让重复读文件非常快对文件系统性能至关重要。代价是 page cache 会在内存充足时不断膨胀吃掉大量空闲内存。很多新手看到free的 buff/cache 占了十几个 GB就觉得内存不够了其实完全不是这么回事。内核在应用申请内存时第一选择就是回收 page cache 来满足需求正常情况下根本不需要手动清理。4.4 LRU 链表与 swap内存压力下谁先被扫地出门当内存紧张时内核要释放一些物理页。它根据 LRU最近最少使用算法维护了几条链表活跃文件页、不活跃文件页、活跃匿名页、不活跃匿名页。回收顺序有讲究。优先回收不活跃的文件页因为干净文件页直接丢弃之后要用了重新读磁盘即可几乎没有额外代价。匿名页进程堆栈、匿名 mmap不能直接丢弃要回收就必须先写入 swap 分区代价更高。所以只有在内存压力持续增大、文件页已经回收得差不多时内核才会去换出匿名页。vm.swappiness参数控制的是回收匿名页的激进程度默认 60。它不是一个百分比阈值而是一个权重——值越大越倾向回收匿名页值越小越倾向回收文件页。线上数据库或 Java 服务一般建议调低到 10 左右因为 swap 抖动带来的延迟对这类应用影响很大。但完全不设 swap 也危险内存瞬时飙高时没有兜底机制容易直接被 OOM Killer 干掉。4.5 OOM Killer内存耗尽时的最终裁决当系统内存严重不足回收也救不回来时内核会触发 OOM Killer选择一个进程杀掉释放内存。选谁杀不是随机的内核根据每个进程的 oom_score 打分来决定分数越高越容易被杀。打分主要看进程的 RSS 大小、运行时间、nice 值、硬件架构等。如果你希望一个关键进程永远不被杀可以把/proc/pid/oom_score_adj设为 -1000反过来某些计算任务想让它优先被杀可以设一个正数。在容器环境里cgroup 的 OOM 逻辑也遵循类似机制但是限制在 cgroup 内部不会影响其他容器。5. 从 free 到 cgroup内存观测与线上排查的实用套路原理讲了一堆真正上了生产环境你手里能用的还是那么几个命令。但很多人不会用。比如free输出里 buff/cache 和 available 的区别比如top里 VIRT 和 RES 的差异比如容器里看到的内存为什么和宿主机对不上。这一节把观测和排查串一遍。5.1 free 命令的正确解读先看一个典型的free -h输出total used free shared buff/cache available Mem: 15Gi 2.1Gi 10Gi 12Mi 3.0Gi 12Gi Swap: 4.0Gi 0B 4.0Gi四个关键字段used是系统实际使用的内存包含进程内存和内核 slabfree是完全没有用过的物理页buff/cache是 page cache 和块设备缓冲available是估算的“还能分给新进程的内存”。注意available不是free buff/cache因为 buff/cache 里有一部分正在被占用无法回收。内核在计算时会考虑当前回收压力下能安全回收多少缓存。判断一台机器内存够不够第一眼看available而不是看free。我见过很多同事一看到free只有几百 MB 就紧张实际上 available 还有十几个 GB内存完全没问题。5.2 vmstat 与 sar判断内存压力的动态指标静态看一眼不够要判断内存是否持续紧张用vmstat 1 5看连续输出。重点关注 siswap in和 soswap out两列。如果这两列持续非零说明系统已经在一页页地和磁盘交换内存这是一个非常糟糕的信号说明物理内存确实不足了。sar -r可以看历史内存使用趋势特别适合复盘“昨晚服务为什么挂”。看历史记录时重点观察kbcommit和%commit——这是内核承诺给所有进程的虚拟内存总量占物理内存加 swap 的比例。如果这个比例长期超过 100%说明 overcommit 压力很大随时可能触发 OOM。5.3 top/ps 里的 VIRT、RES、SHR 到底看哪个进程视角看内存top里最常用的三个字段是 VIRT、RES、SHR。VIRT进程申请的虚拟地址空间总大小包括没有实际映射的部分参考意义不大。RES实际驻留在物理内存中的部分数值得看。SHR共享内存大小比如动态库代码、共享内存段。真正接近“独占物理内存”的指标是 RES 减去 SHR但这只是近似。排查单个进程的内存占用直接用pmap -x PID看每一段映射的 RSS。再精确一点进/proc/PID/smaps看每一段映射的 PSS按共享比例分摊比如多个进程共享同一个动态库时PSS 会把物理页按进程数均摊。分析 Java 或 Python 这类多进程/多线程服务时PSS 能帮你搞清楚一个进程到底“独吞”了多少内存。5.4 内存泄漏排查链路从现象到根因内存泄漏最常见的现象是进程 RES 持续增长重启后回落过几天又涨上来。排查经验可以总结成一条链路。第一步先用top或ps aux --sort-rss定位哪个进程在涨。第二步用pmap -x PID看是堆、mmap 还是栈在涨。第三步对 Java 服务用jmap -heap、jstat -gc看堆内还是堆外对 C/C 服务考虑用 valgrind 或 AddressSanitizer 找具体的泄漏点。第四步如果pmap显示 mmap 区域增加很多用strace跟踪是否有mmap/munmap调用对没匹配上。这里有个很容易踩的坑RSS 高不等于内存泄漏。glibc 的 malloc 分配的大部分内存并不会在 free 时归还操作系统而是留在堆里复用C 里 vector 扩容之后缩容内存也不会立刻返还。所以“RSS 只升不降”不一定是 bug可能是内存池策略导致。要结合业务特征判断如果 RSS 在高峰期很高但低谷期能降回来通常不是泄漏。5.5 page cache 到底该不该手动清很多人会问page cache 占太多了要不要定期echo 3 /proc/sys/vm/drop_caches清一下。我的答案是一般情况下不要。drop_caches 会清除页缓存、目录项和 inode 缓存确实能立刻让free的数值变好看。但它把本来可以加速文件读写的缓存清空了后续访问同一批文件还得重新读磁盘实际上是拿性能换一个“内存充足”的幻觉。如果系统 available 充足、业务没有感受到内存压力就不用管 page cache。只有在你准备分配超大块连续内存、又等不及内核自动回收时才值得手动清理一次。还有一个小技巧有时候你 drop_caches 之后发现释放效果不明显因为 cache 里混着脏页回写还没完成。可以先sync强制刷盘再一次 drop_caches。但生产服务器不建议频繁这么操作。5.6 cgroup 与容器别忘了容器内看到的内存不是全部现在大多数服务跑在容器里容器内执行free看到的往往是宿主机的内存总量而不是容器限额。这是一个非常坑的认知偏差。判断容器内存是否吃紧应该看 cgroup 的统计文件。cgroup v1 里看/sys/fs/cgroup/memory/memory.usage_in_bytes和memory.limit_in_bytescgroup v2 里看memory.current和memory.max。容器触发 OOM 时内核会在 cgroup 视角下杀掉超限容器里的进程。排查时先看dmesg里有没有Killed process再结合 cgroup 文件确认是否触发了限额。6. 面试高频问题与真实踩坑这些坑我都在生产环境遇到过最后一块聊几个和 Linux 内存管理相关的面试经典题再分享几个我实际碰到的案例。把这些串起来你会发现前面讲的所有原理最终都能在某个故障里对上号。6.1 为什么 malloc 很大的内存不一定失败malloc(1GB)在 Linux 上很可能成功即使物理内存根本不够。因为 glibc 走的是虚拟机机制——内核在 overcommit 策略允许的范围内先承诺给你这块地址空间但不会立即分配物理页。真正把内存坐实的是你逐页写入的时候此时才触发缺页分配物理内存。系统的 overcommit 策略由/proc/sys/vm/overcommit_memory控制。默认是 0内核会做启发式检查拒绝明显不合理的超大申请设为 1 表示永远允许设为 2 表示严格按照“物理内存 swap × 比例”来限制。生产环境很多人喜欢设成 2防止某个进程一次性申请过多虚拟内存把系统拖垮但设成 2 后一些依赖大虚拟地址空间的程序会启动失败需要权衡。6.2 Java 进程 RSS 虚高Metaspace、线程栈和 glibc arena之前排查过一个 Java 应用堆设置就 4GB但 RSS 冲到 12GB 还不停。当时第一反应是堆外内存泄漏后来用pmap -x和各种工具逐层看发现问题其实是几部分组成的。一部分是 Metaspace类元数据没有上限或者上线调太高一部分是大量线程各占 8MB 栈的虚拟空间实际物理占用虽然不多但页表也要开销最大的一部分来自 glibc 的 malloc arena 扩展。现代 glibc 为了多线程性能会为每个线程维护独立的分配区每个 arena 默认最大 64MB线程一多堆外内存就失控了。这类问题不是“内存泄漏”而是内存分配策略和 JVM 配置叠加的结果。6.3 容器内频繁 OOM不是宿主机内存不够是限额太低另一个案例更常见。容器跑着跑着进程被杀dmesg里能看到Killed process但宿主机free显示 memory 还有很多。原因就是容器 cgroup 的 memory.limit 设得太低进程内存一涨就触顶。排查这类问题要养成看 cgroup 文件的习惯而不是只盯宿主机free。同时要理解容器 OOM 和宿主机 OOM 的“兜底手段”不一样——宿主机 OOM 可以考虑加 swap、加内存容器 OOM 先要看限额是否合理再看进程本身有没有内存增长异常。6.4 服务器 swap 狂飙一次糟糕的内核参数调优还有一次一台数据库服务器 Swap usage 持续很高业务响应变慢。一开始大家建议直接关 swap。但关掉之后内存瞬时尖峰直接导致 OOM数据库主进程被杀。后来改成保留 swap 但把vm.swappiness从默认 60 调低到 1同时分析出是某个批量查询在短时间内申请了大量内存导致内核频繁换页。调低 swappiness 后内核优先回收文件缓存来满足内存需求匿名页不再频繁换出Swap 用量慢慢降下来数据库也稳定了。这个案例给我的教训是swap 不是洪水猛兽它是一个兜底机制。核心问题是要让内核明白“优先回收文件缓存尽量减少对匿名页的交换”而不是一刀切关闭。6.5 排查内存问题的小习惯踩了几次坑之后我现在排查内存问题基本固定一套流程。第一步看free -h的 available第二步看vmstat 1 5确认有没有持续 swap第三步top按 RSS 排序定位进程第四步pmap -x或/proc/PID/smaps看具体映射第五步结合 cgroup 文件和dmesg判断是容量不足还是进程异常。这套流程跑下来大部分内存问题都能定位到 80% 的程度。剩下那 20% 就需要对具体应用做深入分析了比如 Java 的 GC 日志、C/C 的 Valgrind 报告这些就得按项目来学。Linux 内存管理的内容远不止这些但把虚拟内存、分页、地址空间、伙伴系统、page cache、swap、OOM Killer 和观测命令串在一起理解你面对大多数内存问题和面试题都会从容很多。希望这篇总结能帮你把碎片化的知识焊成一个整体以后不管是排查故障还是做容量评估都能有自己的判断。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进