Linux 基础体系 · 第 16/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 虚拟内存:页表、缺页、Cache、Swap、OOM 与内存诊断

所属系列:Linux 基础体系
所属模块:四、存储与内存
标签:Linux、内存、OOM、性能优化
适用范围:现代主流 Linux 发行版,具体行为以运行内核、架构、配置以及是否使用 cgroups 为准。

Linux 进程看到的不是物理内存,而是一个由内核管理的虚拟地址空间。虚拟地址空间中的地址可以暂时没有对应的物理页,也可以映射到文件、共享内存、匿名内存或设备区域。只有当 CPU 真正访问某个地址时,系统才可能建立或修复对应映射。

理解 Linux 内存问题,通常需要同时回答五个问题:

  1. 进程访问的虚拟地址如何转换成物理地址?
  2. 地址没有当前映射时,缺页异常如何把数据准备好?
  3. 文件 Cache、匿名页、Swap 和页表各自占用了什么资源?
  4. 为什么“还有 free 内存”仍可能发生 OOM?
  5. 如何用观测数据区分泄漏、Cache 增长、Swap 抖动、页表膨胀和 cgroup 限制?

一、虚拟地址、物理页和页表

1. 虚拟地址空间解决了什么问题

每个普通用户进程都拥有独立的虚拟地址空间。例如,进程 A 和进程 B 都可以使用虚拟地址 0x7f0000000000,但该地址在两个进程中可以映射到不同的物理页。

这种抽象带来几个能力:

  • 进程之间默认隔离,不能直接读写彼此的用户内存;
  • 物理页可以不连续,虚拟地址仍然连续;
  • 多个虚拟地址可以映射到同一个物理页,实现共享内存和动态库共享;
  • 未实际访问的区域可以延迟分配;
  • 物理内存紧张时,部分页可以回收或换出;
  • 内核可以通过权限位阻止写入只读代码页等区域。

但虚拟地址不是“凭空存在”的。CPU 访问地址时,必须通过页表找到对应物理页,并检查访问权限。

1. 页表映射的基本形式

设:

  • 虚拟地址为 VV
  • 页面大小为 PP
  • 虚拟页号为 VPNVPN
  • 页内偏移为 offsetoffset

则:

VPN=VPVPN = \left\lfloor \frac{V}{P} \right\rfloor

offset=VmodPoffset = V \bmod P

如果页表将虚拟页 VPNVPN 映射到物理页框号 PFNPFN,则物理地址为:

PA=PFN×P+offsetPA = PFN \times P + offset

例如,页面大小为 4096 字节,虚拟地址为:

V = 0x12345

则:

VPN    = 0x12
offset = 0x345

假设页表把虚拟页 0x12 映射到物理页框 0xABC,则:

PA = 0xABC000 + 0x345 = 0xABC345

页表本身通常不保存每个字节的映射,而是按页保存。这样,4 KiB 页只需为每个 4 KiB 虚拟区间保存一个映射项。

2. 多级页表为什么存在

以 64 位架构为例,虚拟地址空间可能远大于一个进程实际使用的范围。如果为整个地址空间预先建立一个巨大的单级页表,浪费会很严重。

现代架构通常使用多级页表。以常见的 x86-64 配置为例,一个虚拟地址可能被拆成若干级索引:

| PGD | P4D | PUD | PMD | PTE | page offset |

不同内核配置、CPU 模式和是否启用 5-level paging,会影响具体级数和地址位数。核心思想不变:

  1. 用高位索引找到上一级页表项;
  2. 继续向下索引;
  3. 最后由 PTE 找到物理页框;
  4. 用低位页内偏移定位页内字节。

如果某一级页表没有建立,CPU 无法完成转换,便会触发页错误。

大页可以减少页表层级和 TLB 压力。例如 2 MiB 页可以覆盖 512 个 4 KiB 页,因此同样大小的内存需要更少的 PTE。代价是分配、回收和碎片管理更复杂;透明大页(THP)是否有利,取决于访问模式和分配行为,不能简单认为“越大越快”。

3. 页表项不仅保存物理地址

页表项通常还包含:

  • Present:当前是否有有效物理映射;
  • Read/Write:是否允许写;
  • User/Supervisor:用户态或内核态访问权限;
  • Execute Disable:是否禁止执行;
  • Accessed:是否被访问过;
  • Dirty:是否发生过写入;
  • 缓存属性;
  • Copy-on-Write 等由内核结合权限和软件状态实现的语义。

因此,“地址存在”不等于“任何操作都允许”。向只读页写入可能导致保护异常;向未映射地址访问可能导致缺页;访问用户空间非法地址则可能最终收到 SIGSEGV

4. TLB:页表查询的缓存

如果每次内存访问都遍历多级页表,代价会很高。CPU 使用 TLB(Translation Lookaside Buffer)缓存近期的虚拟页到物理页映射。

一次访问可能经历:

  1. CPU 查询 TLB;
  2. TLB 命中,直接得到物理页框;
  3. TLB 未命中,硬件页表遍历或体系结构相关的页表查找;
  4. 若页表项有效,填充 TLB;
  5. 若页表项不存在或权限不符,进入缺页或保护异常处理。

进程切换时,地址空间不同,旧 TLB 项不能全部直接复用。现代 CPU 通常使用地址空间标识等机制减少刷新成本,但页表变化仍可能导致 TLB shootdown:一个 CPU 修改映射后,需要通知其他 CPU 使相关 TLB 项失效。大量线程频繁修改页表时,问题可能表现为 CPU 开销,而不是明显的内存不足。


二、缺页:从 CPU 异常到可用页面

1. 缺页不是必然错误

“缺页异常”是 CPU 把控制权交给内核的一种机制,不等同于程序错误。它至少有三类常见情况:

  • 首次访问按需分配的匿名页:例如 malloc 后第一次写入;
  • 首次访问文件映射:例如执行代码或读取 mmap 的文件区域;
  • 访问曾经被换出的页:内核需要从 Swap 读回;
  • 写入只读的 COW 页fork 后父子进程首次修改共享页;
  • 真正非法访问:内核无法建立合法映射,进程收到 SIGSEGV 或其他信号。

因此,缺页异常的结果可能是成功恢复执行,也可能是终止进程。

2. 一次匿名页首次写入的完整路径

考虑下面的代码:

char *p = malloc(4096);
p[0] = 'A';

假设 malloc 返回的地址对应一个尚未实际分配物理页的匿名虚拟区域,访问流程大致如下:

sequenceDiagram
    participant CPU
    participant MMU
    participant Kernel
    participant Allocator as Page allocator
    participant Process

    Process->>CPU: 执行 p[0] = 'A'
    CPU->>MMU: 查询虚拟地址映射
    MMU-->>CPU: 页表项不存在
    CPU->>Kernel: 触发 page fault
    Kernel->>Kernel: 检查 VMA、权限和访问类型
    Kernel->>Allocator: 分配一个物理页
    Allocator-->>Kernel: 返回物理页
    Kernel->>Kernel: 清零页面并建立页表映射
    Kernel-->>CPU: 返回用户态
    CPU->>Process: 重新执行写入

这里有一个重要区别:

  • malloc 可能只是向进程分配了一段虚拟地址范围;
  • 第一次写入才触发物理页分配;
  • 如果只调用 malloc 而不触碰页面,RSS 可能远小于虚拟地址空间大小。

不过,具体行为受分配器、内核过量提交策略、页面大小、透明大页等因素影响,不能把“malloc 不分配物理内存”当成绝对规范。

3. Minor fault 与 Major fault

Linux 统计中常见两类缺页:

  • Minor fault:不需要从磁盘读取数据。例如建立零页映射、COW、页已在内存但需要建立页表映射;
  • Major fault:需要进行磁盘 I/O,例如从文件或 Swap 读入页面。

Major fault 通常比 minor fault 慢得多,但名称不是性能等级的严格保证。存储设备、队列、预读和并发都会影响实际延迟。

可以用以下方式观察进程级缺页计数:

pidstat -r -p "$PID" 1

典型字段包括:

  • minflt/s:每秒 minor faults;
  • majflt/s:每秒 major faults;
  • VSZ:虚拟地址空间;
  • RSS:驻留集大小。

也可以查看:

awk '{print "minor_faults=" $10, "major_faults=" $12}' /proc/$PID/stat

/proc/$PID/stat 字段位置属于 procfs 接口,解析时应严格按对应内核文档处理;直接用 pidstat 通常更不容易错。

4. 文件映射的缺页

执行程序、共享库和 mmap 文件都可能通过文件页缓存提供数据:

void *p = mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, 0);

第一次读取 p 指向的页面时:

  1. 内核检查该虚拟地址属于合法 VMA;
  2. 根据文件偏移查找 page cache;
  3. 如果缓存中已有页面,建立用户页表映射,通常属于 minor fault;
  4. 如果没有,则提交 I/O,从文件系统和块设备读取,通常属于 major fault;
  5. 读取完成后建立映射并恢复执行。

如果是 MAP_PRIVATE 且进程写入映射页,内核通常会执行 COW:分配私有匿名页、复制原内容,再把该进程的映射改为可写。


三、Cache:CPU Cache、Page Cache 与内存统计

“Cache 占用内存”是诊断中最容易被混淆的表达,因为至少有三种不同对象。

1. CPU Cache 不是 free 中的 Page Cache

CPU 的 L1/L2/L3 Cache 位于 CPU 附近,用于缓存 CPU 最近访问的内存行。它由硬件维护,容量通常远小于主内存,不能通过 drop_caches 清除,也不会作为 Linux 进程 RSS 直接统计。

Linux 内存诊断中更常说的 Cache,通常是**页缓存(page cache)**和其他可回收内核缓存。

2. Page Cache 是文件内容的内存副本

读取普通文件时,内核通常把文件内容放入 page cache:

进程 read()
    -> 文件系统
    -> page cache 命中:直接复制到用户缓冲区
    -> page cache 未命中:提交磁盘 I/O,再返回数据

页缓存中的脏页在被修改后,需要根据文件系统规则回写到底层文件。干净的文件页在内存紧张时通常可以直接丢弃,因为之后还能从文件重新读取;脏页则需要先回写,或者在满足条件时由文件系统处理。

因此,page cache 不是“泄漏”,而是内核用空闲内存换取 I/O 性能的正常策略。

3. Anonymous memory 与 Page Cache 的差异

匿名内存没有直接对应的普通文件,例如:

  • malloc 得到的堆;
  • 线程栈;
  • MAP_ANONYMOUS 映射;
  • 许多运行时的对象和内部堆。

匿名页不能像干净文件页那样简单丢弃。如果需要回收,通常有两个方向:

  1. 页面内容可重建或已被释放;
  2. 把匿名页写入 Swap,释放物理页,之后需要时再读回。

文件页与匿名页的差异,是理解 Swap 和 OOM 的关键。

4. free 输出应该怎样读

执行:

free -h

现代 procps 版本通常输出类似:

               total        used        free      shared  buff/cache   available
Mem:             31Gi        18Gi       1.2Gi       420Mi        12Gi        11Gi
Swap:             8Gi       256Mi       7.8Gi

各列不能机械理解:

  • free:当前未分配使用的物理内存;
  • buff/cache:内核缓冲区、页缓存、可回收内核对象等,具体组成依版本而异;
  • available:内核估计在不发生严重 Swap 的情况下还能提供给新应用的内存;
  • used:工具计算出的派生值,不等价于“不可回收内存”。

available 是估计值,不是分配保证。它会受到 LRU 状态、脏页、内核保留、内存水位、cgroup 限制和回收成本影响。

5. drop_caches 为什么不是常规优化手段

下面的命令可在 root 权限下丢弃部分可回收缓存:

sync
echo 3 | sudo tee /proc/sys/vm/drop_caches

它不会释放匿名进程内存,也不会解决真正的泄漏。执行后可能导致后续请求重新从磁盘读数据,产生明显 I/O 和延迟抖动。生产环境不应把它当作“释放内存”的常规操作;若用于实验,应记录前后 I/O、延迟和缓存命中率。


四、RSS、PSS、VSZ:为什么进程内存不能简单相加

1. 三个常见指标

  • VSZ/VmSize:进程建立的虚拟地址空间总量,包括未驻留物理内存的区域、共享库映射、保留地址空间等;
  • RSS/VmRSS:当前驻留在物理内存中的页面总量,但共享页可能被多个进程分别计入;
  • PSS:按共享者数量分摊共享页后的驻留内存,更适合估算进程实际承担的物理内存成本。

例如:

进程 A 私有匿名页:100 MiB
进程 A 与 B 共享库页:40 MiB
进程 B 与 A 共享库页:40 MiB

若共享库页实际只有一份物理副本:

A RSS = 140 MiB
B RSS = 140 MiB
A PSS = 120 MiB
B PSS = 120 MiB

两者 RSS 相加为 280 MiB,但实际物理页可能只占 240 MiB。PSS 把 40 MiB 共享页各分摊 20 MiB。

2. 使用 smaps_rollup

cat /proc/$PID/smaps_rollup

常见字段包括:

Rss:                150000 kB
Pss:                120000 kB
Pss_Anon:            95000 kB
Pss_File:            23000 kB
Pss_Shmem:            2000 kB
Private_Clean:       ...
Private_Dirty:       ...
Swap:                ...

字段和可见性受内核版本、权限及 procfs 配置影响。重点观察:

  • Pss_Anon 高:匿名堆、栈、运行时对象可能是主要来源;
  • Pss_File 高:文件映射、共享库或页缓存映射较多;
  • Private_Dirty 高:进程私有且已修改的页面,通常不容易直接丢弃;
  • Swap 高:该进程有匿名页已经换出。

若需要定位到具体区域:

grep -E '^[0-9a-f]+-|^(Size|Rss|Pss|Private_Dirty|Anonymous|Swap|VmFlags):' \
    /proc/$PID/smaps

smaps 内容很长,应该结合映射起始地址、权限、文件路径和上述统计分析,而不是只看一行总量。


五、Swap:回收匿名页的后备存储

1. Swap 的本质

Swap 是一类用于保存匿名页面内容的后备存储,可以是 Swap 分区或 Swap 文件。物理内存紧张时,内核可能把不活跃的匿名页写入 Swap,释放对应物理页。

一次 Swap-out 大致经历:

匿名页在内存中
    -> 被判定为不活跃
    -> 写入 Swap
    -> 页表标记为不驻留,并保存 Swap 位置
    -> 释放物理页

之后再次访问:

访问虚拟地址
    -> page fault
    -> 发现页在 Swap
    -> 从 Swap 读取
    -> 分配物理页并恢复内容
    -> 更新页表
    -> 继续执行

这就是 Swap-in,通常会表现为 major fault 和存储 I/O。

2. 有 Swap 不等于正在严重换页

查看 Swap 使用量:

swapon --show
free -h

Swap 中有少量长期不活跃页并不一定是问题。内核可能把很少访问的页面换出,把物理内存留给文件缓存或活跃工作集。

真正需要关注的是换页活动和延迟:

vmstat 1

典型列:

  • si:每秒从 Swap 读入的内存量;
  • so:每秒写出到 Swap 的内存量;
  • r:可运行任务数;
  • b:不可中断睡眠任务数;
  • wa:等待 I/O 的 CPU 时间比例;
  • freebuffcache:内存概况。

持续较高的 si/so,同时 wa 升高、请求延迟变差,通常说明工作集无法稳定驻留,可能出现 thrashing(抖动)。

3. vm.swappiness 的含义和边界

查看:

sysctl vm.swappiness

vm.swappiness 是内核回收匿名页与文件页时的倾向参数,不是“内存用到百分之多少才开始 Swap”的阈值。不同内核版本和回收路径对它的解释存在差异,不能据此推导精确的 Swap 触发比例。

修改全局值:

sudo sysctl -w vm.swappiness=10

这是运行时全局调整,可能影响同机其他服务。降低它不等于禁止 Swap,也不一定降低 OOM 风险;如果匿名工作集很大,系统仍可能在压力下换页或触发 OOM。

4. 禁用 Swap 的风险

sudo swapoff -a

该命令需要把已换出的页面重新放回物理内存。如果当前没有足够可用内存,命令可能失败,甚至增加系统压力。禁用 Swap 后,匿名页无法通过换出释放物理页,内存压力可能更快到达 OOM。容器环境中是否允许、是否受宿主机和 cgroup 约束,也不能只看容器内命令。


六、fork、Copy-on-Write 与“瞬时内存翻倍”误解

fork() 创建子进程时,内核通常不会立即复制父进程的全部匿名页,而是:

  1. 父子进程暂时共享同一批物理页;
  2. 将相关映射设置为只读或使用 COW 标记;
  3. 父进程或子进程首次写入某页;
  4. 触发保护异常或缺页处理;
  5. 内核分配新页并复制原内容;
  6. 写入方获得私有可写页。

因此,fork 的复制成本通常由“之后被修改的页面”决定,而不是简单由父进程 RSS 决定。

fork 仍可能带来高峰风险:

  • 页表本身需要复制或建立;
  • 子进程可能随后修改大量页面;
  • 多线程进程 fork 的语义和锁状态增加复杂性;
  • fork 后执行前的内存峰值可能影响 overcommit 和 cgroup 限制。

反例是:

父进程有 10 GiB 匿名内存
fork 后子进程只读这些页面

这不必然立即消耗额外 10 GiB 物理内存。

另一个反例是:

父进程有 10 GiB 匿名内存
fork 后父子进程都写遍全部页面

此时大部分页面都会发生 COW,物理内存需求可能接近 20 GiB,此外还要加上页表、运行时和其他内存。


七、过量提交:申请成功不等于最终可写入

Linux 允许一定程度的 overcommit:进程可以申请一个看起来大于当前可用物理内存和 Swap 的虚拟地址范围。

查看相关配置:

sysctl vm.overcommit_memory vm.overcommit_ratio

常见模式:

  • 0:启发式 overcommit;
  • 1:允许过量提交;
  • 2:限制提交,提交量与物理内存、Swap 和比例相关。

在严格模式下,内核使用一个近似的提交上限:

CommitLimitSwapTotal+RAM×overcommit_ratio100CommitLimit \approx SwapTotal + \frac{RAM \times overcommit\_ratio}{100}

实际可用计算还会受到内核保留、HugeTLB、架构和实现细节影响。查看:

grep -E 'CommitLimit|Committed_AS|MemTotal|SwapTotal' /proc/meminfo
  • Committed_AS:内核承诺给进程的虚拟内存量;
  • CommitLimit:估算的提交上限。

malloc 返回成功后,后续写入仍可能因为实际物理资源不足而失败、触发回收,或最终进入 OOM。尤其在 fork、大量匿名映射和严格 overcommit 策略下,不能只用 malloc 返回值判断系统最终一定能承受工作集。


八、OOM:物理内存耗尽、cgroup 限制与进程选择

1. OOM 的含义

OOM(Out Of Memory)表示某个内存管理范围内,内核无法通过正常回收满足分配请求,因而需要终止一个或多个任务以释放资源。

OOM 不只发生在“整台机器完全没有空闲字节”时。以下情况都可能导致 OOM:

  • 物理内存和可用 Swap 不足;
  • 进程需要不可回收或难以回收的内存;
  • cgroup 的 memory.max 达到上限;
  • 内核内存、页表、slab 或预留内存导致可用资源不足;
  • 申请在当前内存约束下无法满足;
  • 大页或连续物理内存分配失败,触发相关回收路径。

2. 全局 OOM 与 memcg OOM

在没有 cgroup 限制时,通常是全局内存管理器在系统范围内处理 OOM。

在容器或服务使用 cgroup 时,可能先发生 memcg OOM:

容器内存达到 memory.max
    -> cgroup 内尝试回收
    -> 回收不足
    -> memcg OOM
    -> 选择并杀死该 cgroup 中的任务

这时宿主机可能仍有大量 MemAvailable,但容器依然会被杀。诊断必须同时查看:

cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events

在 cgroup v2 中,memory.events 可能包含:

low 0
high 12
max 3
oom 1
oom_kill 1

含义大致是:

  • high:超过 memory.high,进入受压制状态;
  • max:触及 memory.max
  • oom:发生 OOM 条件;
  • oom_kill:内核杀死了任务。

具体文件和计数器取决于 cgroup 版本及挂载方式。容器内可能没有权限读取完整层级,应在宿主机或编排平台上确认。

3. OOM killer 如何选择进程

内核需要选择终止对象,以释放尽可能多的内存并降低系统损害。oom_score_adj 可用于调整某个进程被选中的倾向:

cat /proc/$PID/oom_score
cat /proc/$PID/oom_score_adj
  • oom_score_adj 范围通常为 -10001000
  • 数值越高,越倾向于被杀;
  • -1000 表示强烈保护,但需要权限,且不是绝对保证;
  • OOM 发生在 memcg 时,选择范围通常受该 cgroup 约束。

不能只凭 RSS 排名猜测最终受害者。内核还会考虑进程的内存使用、调整值、共享关系和当前 OOM 上下文。

4. OOM 日志

检查内核日志:

dmesg -T | grep -i -E 'out of memory|oom-killer|killed process'

使用 systemd 的系统可查看:

journalctl -k -g 'oom|out of memory|killed process' --since -1h

典型日志会包含:

  • OOM 发生的节点或 cgroup;
  • 当时的内存和 Swap 状态;
  • 被杀进程的 PID、名称和内存信息;
  • oom_score_adj
  • 触发分配的进程。

日志中“被杀的进程”不一定是“造成泄漏的进程”。例如,进程 A 可能长期占用大量内存,进程 B 发起一次无法满足的分配,内核最终选择 B 或其他更合适的任务。必须结合时间线和 cgroup 统计判断因果关系。


九、从症状到证据:一条可复现的诊断路径

1. 先确认是哪个资源范围耗尽

先看系统总体:

free -h
cat /proc/meminfo
vmstat 1

再看内核事件:

journalctl -k --since -30min | grep -i -E 'oom|out of memory|reclaim|swap'

如果服务运行在容器或 systemd slice 中,查看 cgroup:

systemctl status your-service.service
systemctl show your-service.service \
    -p MemoryCurrent -p MemoryMax -p MemoryHigh -p OOMPolicy

cgroup v2 路径因发行版和服务管理器不同,不能假设固定目录。应从进程的 cgroup 信息开始:

cat /proc/$PID/cgroup

2. 判断是回收压力还是单进程异常增长

观察 PSI(Pressure Stall Information):

cat /proc/pressure/memory

可能看到:

some avg10=2.30 avg60=1.10 avg300=0.40 total=...
full avg10=0.00 avg60=0.00 avg300=0.00 total=...

some 表示至少有部分任务因内存资源而停顿;full 表示某些时间窗口内所有可运行任务都受到这类停顿。它描述的是压力造成的停顿时间,不是“已使用百分比”。

持续的 si/so、major fault、I/O 等待和 PSI 同时升高,偏向 Swap 抖动或工作集过大。没有 Swap 活动但匿名 PSS 持续增长,则更需要检查应用对象、分配器缓存、线程栈和内存映射。

3. 找到进程及其内存类型

ps -eo pid,ppid,comm,%mem,rss,vsz,stat --sort=-rss | head -20

对重点进程:

grep -E 'VmPeak|VmSize|VmRSS|RssAnon|RssFile|RssShmem|VmSwap|Threads' \
    /proc/$PID/status

cat /proc/$PID/smaps_rollup

解释时要注意:

  • VmSize 增长不一定意味着物理内存增长;
  • RssFile 增长可能是文件映射或共享库;
  • RssAnon 增长更接近匿名工作集;
  • VmSwap 只表示该进程有页面被换出,不表示所有内存都在 Swap;
  • RSS 总和可能重复计算共享页。

如果需要精确区分共享成本,优先看 PSS,而不是把多个 RSS 直接相加。

4. 检查内核内存和页表

系统内存异常但用户进程 RSS 总和解释不了时,查看:

grep -E 'Slab|SReclaimable|SUnreclaim|PageTables|KernelStack|AnonPages|AnonHugePages|Cached|Shmem' \
    /proc/meminfo

含义包括:

  • PageTables:进程页表占用;
  • KernelStack:内核为线程使用的栈;
  • SReclaimable:理论上可回收的一部分 slab;
  • SUnreclaim:不容易回收的 slab;
  • AnonHugePages:匿名透明大页等相关统计;
  • Shmem:tmpfs、共享内存等。

大量线程会增加线程栈和调度相关开销;大量稀疏映射或极大的地址空间可能增加页表成本。页表不一定按 RSS 直观归入某个进程,需要结合 /proc/$PID/status 中的 VmPTE 等字段以及系统总量判断。

5. 判断是否是文件 Cache

如果系统 buff/cache 很高但应用 RSS 不高,可以查看文件访问和回收活动:

sar -B 1
sar -W 1
iostat -xz 1

也可以使用:

sudo slabtop
sudo filetop   # 需要 bpftrace/bcc 等工具,系统不一定安装

filetopbiolatencymemleak 等 eBPF 工具来自不同工具集,参数和可用性随发行版变化;使用前应确认内核 BTF、权限、工具版本和生产风险。eBPF 采样或挂载探针本身也可能增加开销,不能在无验证的情况下直接长期启用。

6. 区分内存泄漏和缓存增长

一个更可靠的判断方法是记录时间序列,而不是只看一次快照:

while sleep 10; do
    date
    awk '/VmRSS|RssAnon|RssFile|VmSwap/ {print}' /proc/$PID/status
    awk '/Rss:|Pss:|Pss_Anon:|Pss_File:|Swap:/ {print}' /proc/$PID/smaps_rollup
done

观察模式:

  • RssAnonPss_Anon 随业务量增长且不回落:可能是对象泄漏、分配器保留、缓存无界增长;
  • RssFile 增长,系统 available 仍较高,压力时能够回收:更像 page cache 或文件映射;
  • VmSize 增长但 RSS 基本不变:可能只是地址空间预留;
  • VmSwapsi/so 周期性升高:工作集超出驻留能力;
  • 进程内存稳定但 cgroup memory.current 上升:应检查 cgroup 内其他进程、内核记账、tmpfs、socket、page cache 等。

应用级泄漏最终仍需要语言运行时或分配器工具确认。例如 C/C++ 可用 ASan、LeakSanitizer 或堆分析器;这些工具通常会显著改变内存布局和性能,应在测试或受控环境使用,而不是直接推断生产基线。


十、一个可运行的实验:观察虚拟分配、缺页和 Swap

下面的程序申请一段匿名内存,并按页面写入。每次写入都会使对应页面更可能获得实际物理页。

// touch.c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv) {
    size_t mib = argc > 1 ? strtoull(argv[1], NULL, 10) : 256;
    size_t bytes = mib * 1024ULL * 1024ULL;
    long page = sysconf(_SC_PAGESIZE);

    char *p = malloc(bytes);
    if (!p) {
        perror("malloc");
        return 1;
    }

    printf("pid=%d bytes=%zu page_size=%ld\n", getpid(), bytes, page);
    fflush(stdout);

    for (size_t i = 0; i < bytes; i += (size_t)page) {
        p[i] = 1;
        if ((i / (size_t)page) % 256 == 0) {
            printf("touched=%zu MiB\n", i / 1024 / 1024);
            fflush(stdout);
            usleep(10000);
        }
    }

    printf("done; press Ctrl-C to exit\n");
    fflush(stdout);
    for (;;) pause();
}

编译和运行:

cc -O0 -Wall -Wextra touch.c -o touch
./touch 1024

另开终端检查:

PID=$(pgrep -n touch)
grep -E 'VmSize|VmRSS|RssAnon|VmSwap|VmPTE' /proc/$PID/status
pidstat -r -p "$PID" 1

预期现象:

  1. malloc 后,VmSize 可能先增长;
  2. 随着页面被逐页写入,VmRSSRssAnon 增长;
  3. 第一次触碰页面会产生大量 minor fault;
  4. 如果物理内存压力足够大,可能出现 Swap、major fault 和 si/so
  5. 退出进程后,这些匿名页可以被释放,但内核不保证所有进程统计会瞬间以同样速度更新。

为了避免实验影响生产主机,不应在重要节点随意运行大参数版本。容器中还必须考虑 memory.max,即使宿主机内存充足,程序也可能在触及 cgroup 上限后被杀。


十一、常见误解与反例

误解一:VSZ 大,所以程序占用了同样多的物理内存

反例:

void *p = mmap(NULL, 1ULL << 40,
               PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

这类映射可能只建立虚拟地址范围,未访问的页面没有对应 RSS。VSZ 可以非常大,而 RSS 仍较小。另一方面,VMA 数量和页表等元数据仍然可能产生真实成本。

误解二:Cache 高就是内存泄漏

干净 page cache 通常可回收。判断泄漏应看:

  • 进程匿名 PSS 是否持续增长;
  • 对象数量是否持续增长;
  • 回收压力下 Cache 是否下降;
  • cgroup 的 memory.current 中具体类型是什么。

仅凭 freebuff/cache 不能诊断泄漏。

误解三:Swap 使用为零,所以没有内存压力

系统可能在没有 Swap 的情况下直接回收文件页、阻塞分配或触发 OOM。Swap 是一种后备手段,不是压力传感器。应同时观察 MemAvailable、PSI、回收活动、major fault 和分配延迟。

误解四:RSS 总和就是机器实际使用量

共享库、共享内存、COW 页面会被多个 RSS 重复计数。进程成本核算应优先使用 PSS,容器级分析则还要结合 cgroup 的内存记账。

误解五:OOM 杀死谁,谁就是泄漏源

OOM victim 是内核在当前约束下的选择结果。触发分配的进程、长期占用最多的进程和最终被杀的进程可能不是同一个。必须把 OOM 日志、进程 PSS 时间序列和 cgroup 事件放在同一时间线上分析。

误解六:把 echo 3 > drop_caches 当成修复

它只能丢弃部分可回收缓存,不能释放匿名泄漏、不能降低页表占用,也不能修复 cgroup 上限。执行后还可能让磁盘读取重新升高,造成缓存冷启动。


十二、生产环境中的取舍

1. Swap 的取舍

保留适量 Swap 可以让冷页面退出物理内存,降低瞬时 OOM 风险;但当业务工作集持续大于物理内存时,Swap 只能延迟失败并放大延迟。是否启用、使用何种介质和配置多少,需要结合延迟目标、故障恢复策略和平台约束验证。

2. cgroup 内存限制的取舍

memory.max 能隔离服务,防止单个服务耗尽宿主机内存,但也会让服务在宿主机仍有空闲时发生 memcg OOM。设置限制时应考虑:

  • 主进程和子进程;
  • page cache;
  • tmpfs 和共享内存;
  • 内核记账项目;
  • 启动和滚动升级期间的峰值;
  • JVM、Go、Node.js 等运行时的堆外内存;
  • 线程栈、动态库和页表。

memory.high 可以先施加回收和节流压力,memory.max 则是硬上限;两者的行为和效果不同,不能混用。

3. 回收不一定免费

释放 Cache、扫描 LRU、压缩或迁移页面、处理 THP、写回脏页,都需要 CPU 和 I/O。即使最终没有 OOM,过度回收也可能表现为:

  • 分配延迟升高;
  • kswapd 或直接回收占用 CPU;
  • I/O 等待增加;
  • major fault 增加;
  • PSI memory 上升;
  • 请求尾延迟变差。

因此,内存优化的目标不是让 free 永远很大,而是让活跃工作集稳定驻留,并使回收、缺页和 I/O 处于可接受范围。


十三、把内存问题串成证据链

一个完整的诊断结论应至少包含以下因果链:

业务行为
  -> 虚拟地址增长或页面被触碰
  -> 匿名页 / 文件页 / 共享内存 / 页表增长
  -> 回收、缺页或 Swap 活动
  -> PSI、I/O、CPU 和延迟变化
  -> 全局 OOM 或 cgroup OOM
  -> 内核日志中的选择结果

例如:

请求缓存无界增长
  -> Pss_Anon 持续增长
  -> memory.current 接近 memory.max
  -> reclaim 和 PSI memory 升高
  -> cgroup memory.events 的 max、oom、oom_kill 增加
  -> 容器内进程被杀

另一个完全不同的链条可能是:

批量读取大量文件
  -> page cache 增长
  -> buff/cache 增长但匿名 PSS 稳定
  -> 内存压力时文件页被回收
  -> 没有持续 Swap,也没有 oom_kill

两者都可能被笼统描述为“内存变高”,但修复方法分别是限制应用缓存和调整 I/O/访问模式,不能用同一个参数解决。

Linux 虚拟内存的核心不是某个单独命令,而是一个状态系统:虚拟地址通过页表映射到物理页;缺页使按需分配、文件读取、COW 和 Swap-in 成为可能;Cache 利用空闲内存降低 I/O;回收在资源压力下重新平衡匿名页与文件页;当某个内存管理范围无法继续满足分配时,OOM 负责终止任务。诊断时只有把页类型、共享关系、回收活动、Swap、cgroup 限制和内核日志结合起来,才能从“内存高”进一步得到可验证的原因。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。