资讯动态

Linux虚拟内存与内存故障排查:从原理到实战

发布时间:2026/10/23 3:28:56 来源:尧图企业网站定制
在服务器上跑过几个 Java 或数据库实例的人多半都见过“内存不够”的瞬间。Linux 虚拟内存不是一个让你能假装内存很大的魔法而是一套让每个进程都拥有独立、隔离、看起来连续完整地址空间的核心机制。我最早被“虚拟地址转物理地址”这套图绕得有点晕后来在线上动手排查内存泄漏、swap 抖动和 OOM 时才慢慢把整条链路串起来。这篇文章适合刚入门 Linux 的同学也适合那些已经会用 free 但遇到内存异常仍然抓瞎的开发者。读完你能理解虚拟内存为什么存在、系统如何回收内存以及排查内存故障时怎么少走弯路。1. 虚拟内存到底管的是什么1.1 “虚拟”不是让你多一块内存很多人第一次见到 free 命令的输出时第一反应是“我物理内存明明只有 16G为什么每个进程加起来都 100 多 G 了”这里的关键就在于虚拟内存概念进程看到的是虚拟地址空间不是物理地址空间。Linux 给每个进程画了一张很大的“地图”64 位系统下通常可以达到 128 TiB 左右但这张地图本身不占物理内存。你可以把虚拟内存理解成酒店的房间编号每个房间都有独立门牌号但酒店实际能同时住的人数取决于楼层和房间数量而不是门牌号的数量。进程通过门牌号虚拟地址去找自己的房间真正住进去分配物理内存时操作系统才根据当前资源情况安排。门牌号可以很多物理房间是有限的但门牌号之间绝对不允许串门否则进程 A 就能看到进程 B 的隐私数据。所以“虚拟”二字的实际含义是逻辑上隔离、物理上共享、按需映射。进程 malloc 一段内存时操作系统往往不会立刻给它分配物理页而是先在地图上记一笔“这块区域可以用”等进程真的写入数据时才触发缺页异常把对应的物理页面分配出来。这种懒加载机制让内存分配变得非常廉价也让很多程序跑起来比想象中轻快。1.2 地址空间、页表与 MMU 的角色虚拟地址不会自己变成物理地址中间的核心角色是 MMU内存管理单元和页表。MMU 是 CPU 里的硬件单元负责把虚拟地址翻译成物理地址页表是操作系统维护的数据结构记录虚拟页面到物理页框的映射关系。以 x86-64 为例一个虚拟地址会被拆成多段偏移分别作为多级页表的索引。通常有四层PML4、PDPT、PD、PT最后再加上页内偏移。这就意味着每次访问内存都可能要经过三四次查表性能开销很大。硬件上的 TLB块表就是专门缓存最近用过的地址转换结果的避免每次都从内存读页表。页表里每一行页表项也不只是存物理地址还带了一堆标志位。比较常见的有 present页面是否在内存里、RW是否可写、User是否允许用户态访问、Accessed是否被访问过、Dirty是否被写过。这些标志位是内核做回收、换出、写时复制等操作的依据。提示理解这些标志位的价值在于排障。当你去看 /proc/PID/smaps 时里面很多字段比如 Referenced、Dirty 都是从这些硬件标志位汇总出来的。不要把它们当摆设。为什么不用一个大数组当页表因为虚拟地址空间太大光是覆盖全部地址的单一页表就会吃掉海量内存。多级页表的好处是进程真正用到的地址范围往往很小对应的中间层级为空时可以直接让上层页表项指向空节省大量内存。这就是“按需构建”的典型案例。1.3 为什么说虚拟内存是操作系统稳定的地基虚拟内存带来的不只是“看起来内存很大”的错觉它更核心的价值是隔离性和保护性。每个进程运行在自己的地址空间里无法直接访问其他进程的地址除非通过内核提供的共享内存、mmap 等机制明确共享。这样任何程序都能保证自己的指针不会莫名其妙访问到别人的数据结构。另一个重要的设计是写时复制Copy-on-Write。fork() 创建子进程时父进程的地址空间并不需要真的复制一份。Linux 让父子进程共享同一批物理页但把这些页表项都标记为只读。当任何一个进程尝试写入时CPU 会触发缺页异常内核这时才真正复制物理页。这个机制让 fork 的成本大大降低也是很多服务端程序频繁 fork 还能撑住的根本原因。虚拟内存还统一了“内存”和“文件”的视角。mmap 可以把一个文件直接映射到进程地址空间读取文件就像访问内存一样同时page cache 也是建立在文件页基础之上的。这让文件 IO、内核缓存、共享库加载都走同一套内存管理逻辑整个系统的代码路径变得非常统一和干净。我一直觉得能把虚拟内存这条路想明白再看 Linux 的文件系统、网络缓存、容器隔离体系会突然有一种“原来都是同一套底子”的通透感。2. 内存管理的关键机制页、缺页、换入换出2.1 物理页、虚拟页和页表的关系进程看到的是虚拟页物理内存按物理页框管理两者通过页表对应起来。虚拟页和物理页的默认大小都是 4KB这是几十年的老传统。现在服务器上常见 HugeTLB 或 THP透明大页页大小可能是 2MB 甚至 1GB目的就是减少页表项数量、降低 TLB miss 概率。页表项维护的关键在于“什么时候建立映射”进程访问一个还没有映射的虚拟地址时CPU 会产生 page fault缺页异常随后进入内核的缺页异常处理程序由内核分配物理页并建立映射再让程序继续执行。这个动作通常非常快但如果你碰上频繁的 major fault性能就会明显下降因为内核可能要从磁盘读数据进来。这里要区分两个指标minor fault 和 major fault。minor fault 是物理页面已经在内存中只是当前进程的页表里还没有建立映射补一个映射就能继续major fault 则是目标数据不在物理内存里必须从磁盘读入比如换出页被重新读回、mmap 文件映射首次加载。用 time 命令可以很方便地看到程序运行期间这两个数字time -v your_command输出里的 Minor/Major page faults 就是。2.2 缺页异常的几种路径缺页异常不完全是“内存不够”的信号它其实是虚拟内存机制正常工作的入口。常见的缺页路径至少可以分三类首次访问缺页进程 malloc 了一整块区域但物理页还没分配。第一次写这块内存时触发缺页内核立刻分配物理页并清零。这也是为什么申请大内存不一定慢但触碰大内存一定慢建议分配大块内存时考虑 mmap加上 MADV_HUGEPAGE 之类提示能优化大页分配行为。写时复制缺页fork 之后父子进程共享的是只读页任何一方要写MMU 就抛一个 protection fault内核复制原物理页再更新页表为可写。这类缺页频率高、单次耗时短很多系统性能分析里的 fault 计数就是它。换出/文件缺页物理页被回收并写到了 swap 或文件缓存需要重新读入时页表项标记为不存在访问时从 swap 分区或磁盘加载数据。这类缺页最慢一旦频繁出现系统基本已经处于内存压力很大的状态。排障中如果看到每秒大量 major fault不要只盯着“是不是代码有 bug”。先确认可用内存还剩多少、swap 是否被大量使用再看是不是某些进程反复读文件映射。我见过很多挂着“大量缺页”表象的问题最后排查到其实是 page cache 被反复淘汰应用读文件每次都落盘。2.3 swap 与回收机制只是备胎但不是摆设Linux 的内存回收机制根本目标是“尽量让物理页处于可用状态”。当系统内存不足时内核会从几个方向收回页面文件页已同步到磁盘或可重新读取的直接丢弃脏文件页先写回再丢弃匿名页进程堆栈等没有磁盘备份的则写到 swap 换出。swap 分区或 swap 文件存在的意义就在这里它是匿名页的“救生圈”。只要物理内存紧张内核会先回收文件页因为文件页丢了大不了重新从磁盘读如果还不够才会换出匿名页。很多人以为 swap 是内存不够时才用的这个说法不算错但更准确的表述是swap 是内核回收匿名页的必要出口。就算内存还很充裕某些场景下内核也可能因为需要缓存更多文件数据而换出少量匿名页。内存水位也很关键。内核把空闲内存分成 min、low、high 三个水位。当可用内存跌到 low 以下kswapd 内核线程会被唤醒开始回收如果直接跌到 min 以下说明回收速度跟不上内存分配就会进入直接回收路径调用方会阻塞在回收过程中。高水位与低水位的差留给突发分配和后台回收足够的时间所以有时候你看到 MemFree 还剩几百兆但系统已经开始做内存回收这并不一定是故障可能是水位线设置导致的正常提前量。实操心得千万别一看到 swap 有使用量就急着加大内存。swap 使用量持续增长时先看是被谁换出、换出的是文件页还是匿名页、si/so 高不高。如果只是偶尔换出一部分冷匿名页系统性能几乎没有影响如果 si/so 持续高位通常说明物理内存已经不足要么增加内存要么调整应用的缓存策略。3. 日常排查思路从现象反推内存问题3.1 先看 /proc/meminfo 而不是只看 freefree -h 是大家最常用的命令但只看它容易误判。free 里的 used 包含 page cache 吗其实 free 把 kernel 认为的可用内存拆成了 used、buff/cache、available不同发行版对 used 展示口径还不完全一致。更可靠的起点是直接看 /proc/meminfo它就是 free 等命令的上游数据源。关键字段值得背下来MemTotal / MemFree物理内存总量和完全空闲页总量。MemFree 很低不意味着有问题因为可作为缓存回收的页很多。MemAvailable内核估算的“在不触发换出、不牺牲太多缓存的前提下还能分给新进程的内存”。日常判断内存够不够优先看这个。Buffers / Cachedbuffers 主要指块设备、文件系统元数据占用的页Cached 大部分是文件页缓存包含 tmpfs 等共享内存页。它们都是可回收的。AnonPages匿名页进程堆栈、私有数据映射占用的内存不含文件映射。换出主要针对这部分。Mapped进程实际映射到地址空间的文件页数量。Cached 减掉 Mapped 之外的才是纯碎缓存。Shmemtmpfs、共享内存等。它既算 Cached 也算 AnonPages排查共享内存占用时很容易被漏掉。Slab / SReclaimable / SUnreclaim内核自身数据结构占用的内存。SReclaimable 可以回收SUnreclaim 不可回收。如果你发现 Slab 特别高先看是不是某个文件系统、网络模块或者驱动把内核对象堆满了。我自己的习惯是先看 MemAvailable 占总内存的比例低于 5% 就得警惕再看 Cached 和 AnonPages 的占比关系判断是文件缓存过多还是进程内存过高最后看 Slab 是否异常增长很多“内存泄漏”最后发现是内核态对象堆积。3.2 vmstat、sar、top 怎么看才不抓瞎vmstat 1 每秒刷新一组数据其中最有价值的是 si、so、r、b、cs 几个字段。si 和 so 是 swap 的换入换出页数r 是正在运行的进程数不含等待 IO 的b 是阻塞在 IO 等资源的进程数cs 是上下文切换次数。如果 si/so 持续非零且数值很大几乎可以确定内存压力导致的换页已经拖慢了系统这时配合看 r 和 b能判断是 CPU 排队还是 IO 等待。top 里几个内存相关指标容易被误读。VIRT 是进程虚拟内存大小并不等于实际占用Java 进程 VIRT 动辄几十 GB 是常态因为 JVM 会保留大块地址空间RES 是常驻物理内存部分也就是进程实际占用的物理页数SHR 是共享内存部分包括共享库、mmap 文件页等。三个进程的 RES 不能简单相加再乘以进程数来估算总内存占用因为共享部分重复计算了。sar 在排查长期内存趋势时很管用。sar -r看物理内存使用率、缓冲区和缓存大小sar -B看 page fault 和 swap 活动其中 pgmajfault/s 就是一个很有力的压力指标。如果你能提前部署好 sar 采集往往能在故障复盘时一眼看出内存是什么时候开始恶化的这个时间点对定位事件非常关键。注意top 里的 %MEM 是按 RES 算的不代表进程的“真实内存成本”。有些共享库被八百个进程用到每个进程 %MEM 都显示 0.1%可你如果误以为它们各自占用了同样数量的物理内存算出来的总量会高出实际很多。真要看进程级内存成本可以看 PSS比例份额smem 这类工具专门补这个视角。3.3 用匿名页和文件页判断内存去向很多时候你只想知道“内存到底被谁吃了”。方法不是直接看某个进程而是先判断是进程私有内存多还是文件缓存多还是内核态 Slab 多。下面三个方向可以帮助缩小范围。先分大类再看具体进程如果 AnonPages 持续高涨说明主要是进程堆内存占大头结合 top 里的 RES 找进程再用 smem 看 PSS。如果 Cached 高、AnonPages 不高说明 page cache 占用大可能是频繁读写大文件。这时看系统里有没有大量 IO 活动必要时可以调整 vm.vfs_cache_pressure 或脏页比例来优化。如果 Shmem 很高别漏了排查 /dev/shm、tmpfs 挂载点和 docker 容器内共享内存。定位进程细节时可以看 /proc/PID/status 里的 VmPeak、VmSize、VmRSS、VmSwap或者 /proc/PID/smaps_rollup 一次拿到汇总。smaps 文件可以看每个映射段的具体占用情况对分析匿名映射大小、文件映射大小、私有脏页非常有效。实操心得别把 vmRSS 当作全部。进程的匿名映射只要是私有的退出后内存会一次性还给系统但文件映射占用的是 page cache进程退出后这些页不会被立刻释放而是作为缓存继续留在系统里。所以你会遇到“进程都死了free 显示也没变多”的情况。这不是泄漏是缓存在起作用只要 MemAvailable 在合理范围就不用过度担心。4. 调优、限制与避坑经验4.1 vm.swappiness 该怎么设置vm.swappiness 是内核控制“回收文件页和匿名页相对倾向”的参数默认值在多数发行版是 60范围 0–200。很多人以为设置成 0 就彻底禁用 swap这是误解。swappiness0 只表示内核尽量少换出匿名页但物理内存严重紧张时一样会换出。这个参数本质是一个权重值越大越倾向于回收匿名页并把它换出到 swap值越小越倾向于回收文件页和缓存。文件页的回收成本通常低于匿名页所以就算你想为应用保留更多缓存也不需要把 swappiness 调到特别高。我的经验建议普通 Web/Java 服务内存还算充裕可以设为 10 左右让系统尽量少做无谓的匿名页换出。数据库主机尤其是对延迟敏感的场景很多人喜欢直接设为 0但要注意这只是降低换出概率不是隔离内存压力真到 OOM 边缘该换还是会换。一些内存很紧、文件缓存又很重要的场景可以保留默认值甚至调到 80 以上让系统大胆用缓存和文件页换性能适当换出冷匿名页。在 cgroup v2 环境下每个容器的 memory.swappiness 也可单独控制这比全局调优更精准。我建议容器场景优先用 cgroup 层级的配置而不是对所有进程一刀切。注意新版内核引入了一些类似PSI的指标能让系统更精准判断内存压力但 swappiness 依然是日常调优里能直接感知的工具。调完后一定要看实际回收行为而不是只盯着数字。4.2 overcommit 到底怎么选Linux 在分配内存时内核允许进程申请比实际物理内存更多的虚拟内存这就是 overcommit。如果不允许那么 malloc 一个几百 MB 的大缓冲区可能直接失败这对于现代应用来说无法接受。内核通过 /proc/sys/vm/overcommit_memory 控制策略值策略适用场景0启发式根据内存和虚拟空间大小估算是否允许默认值绝大多数场景没问题1无条件允许 overcommit测试环境图省事时可用但 OOM 风险高2禁止明显过大的 overcommit按比例限制稳定性优先、数据库类服务可考虑overcommit_memory2 时系统允许的虚拟地址空间总量由CommitLimit决定通常等于 swap 大小 overcommit_ratio * 物理内存大小。你可以在 /proc/meminfo 里看到 Committed_AS 和 CommitLimit 两个字段。Committed_AS 是所有进程已经承诺的虚拟内存总量如果超过 CommitLimit新的大分配就会被拒绝。很多数据库的调优指南会建议把 overcommit_memory 设为 2理由是防止某些应用一次性申请巨量内存把系统直接拖垮。但我见过不少反例设成 2 后JVM 或某些框架启动时申请大块虚拟内存失败应用直接起不来排查半天才发现是它。所以这个参数一定要根据应用动态判断如果应用里明显有“一次性故意申请超大缓冲区”的代码建议保留默认 0如果整套环境非常稳定、服务类型单一设成 2 确实能提升整体安全性。实际建议不要看单个参数就做决定。先看 Committed_AS 和 CommitLimit 的差距再评估应用对“分配失败”的容忍程度。稳妥做法是保持默认 0靠 MemAvailable 监控和监控告警来预警而不是靠杀掉所有大分配来保平安。4.3 cgroup 内存限制使用中踩过的坑容器时代里内存限制大多交给 cgroup 来做。cgroup v2 相关文件路径是 /sys/fs/cgroup/。核心文件有两个memory.max硬上限超过可能触发 OOM kill和 memory.high软上限超过后会加强回收、压缩内存但不会立刻杀进程。坑点一只设 memory.max 不设 memory.high会让容器在接近上限时突然进入直接回收产生明显的延迟尖刺。memory.high 相当于提前预警让内核在压力较小时就开始回收避免攒到最后一刻集中干活。但 memory.high 设置太低也会造成频繁回收反而浪费 CPU。坑点二page cache 也计入 cgroup 内存统计。容器里读了大量文件后page cache 会增长即使进程没占多少内存memory.max 也可能被 cache 顶满。这时内核第一反应是回收 cache但如果回收来不及就会引发 OOM。很多容器“内存不够”的告警其实根本不是进程内存多而是文件缓存太多。坑点三memory.swap.max 默认是单向的。需要明确设置的还有匿名页换出的比例。cgroup v2 里设置了 memory.swap.max 后容器内进程可以换出到 swap如果没设置则按全局 swap 策略执行。建议容器场景先把 memory.high 和 memory.max 配好再考虑是否给单独容器开放 swap 能力。避坑思路用 cgroup 限制内存时先观察容器当前的 cache/anon 占比。如果是文件密集型应用memory.high 可以稍微收紧memory.max 可以适当保留缓冲再加一个只读根文件系统避免大量脏页反复写回。4.4 OOM killer 的一些细节OOM killer 可能是大家对虚拟内存最熟悉又最恐慌的部分。它并不是“内存满了一瞬间就杀”而是当系统无法通过回收、换出或拒绝分配来满足内存请求时选择牺牲一个进程来释放空间。OOM 的评分依据是 oom_score分数越高越容易被杀。内核会综合考虑进程占用的内存量、CPU 时间、运行时长、优先级等因素。程序员可以调整 /proc/PID/oom_score_adj范围是 -1000 到 1000。设置为 -1000 表示 OOM 时完全免疫该设计通常只给内核自身使用设置为正数则更可能被杀。许多服务主进程会把自己的 oom_score_adj 调到较低值同时把需要保护的数据线程显示隔离开防止监控进程被误杀。另一个容易踩的坑是OOM killer 是“挑一个进程杀”所以即使某个容器只是短暂分配了一大批内存被杀的也可能是这个容器内的守护进程而不是触发内存暴涨的那个线程。cgroup v2 下可以设置 memory.oom.group让 cgroup 内所有进程一起被处理能减少这种误杀。OOM 日志可以从 dmesg 或内核日志里看到里面通常包含大量进程内存快照。排障时别只盯着“谁被杀了”要看那一时刻的整体内存分布是匿名页暴涨还是 page cache 占太多还是 slab 不可回收。只有回答了这个根因调参和重启才有意义。个人经验生产环境里我从不把 oom_score_adj-1000 用在业务进程上。业务进程如果完全免疫 OOM内核就只能杀别的进程那才是真正的灾难。合理做法是让内核优先杀“不重要但吃内存”的进程同时靠监控快速感知并人工介入。OOM 是最后一道防线不是第一道退路。我在实际运维中最大的体会是Linux 虚拟内存这套体系只有把“地址转换、缺页异常、回收换出、cgroup 限制”这四个层面串起来才能真正做出正确的调优决策。单独调一个 swappiness 或 overcommit 参数往往只是头痛医头。建议你现在就把 /proc/meminfo 翻出来对照本文把每项字段都过一遍结合自己服务的实际负载做一次内存画像——这个动作比任何优化命令都值钱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑