Skip to content

虚拟内存 ​

#系统 · #虚拟内存 · #页表 · #MMU · #缺页 · #mmap

深入理解虚拟内存的地址翻译、页表、MMU、缺页处理、内存映射等核心机制。


1. 虚拟内存的概念 ​

1.1 为什么需要虚拟内存 ​

问题虚拟内存如何解决
物理内存不够用将磁盘当作内存的扩展(交换/分页)
程序间互相干扰每个进程拥有独立的虚拟地址空间
内存碎片物理上不连续的内存,虚拟地址上看起来连续
安全隔离页表权限位阻止非法访问
简化编程模型每个程序似乎拥有完整的地址空间

1.2 虚拟地址 vs 物理地址 ​

mermaid
graph LR
    CPU -->|"虚拟地址 (VA)"| MMU["MMU<br/>内存管理单元"]
    MMU -->|"物理地址 (PA)"| MEM["物理内存"]
    MMU -.->|"缺失时"| DISK["磁盘(交换空间)"]

2. 地址翻译 ​

2.1 页(Page)与页框(Page Frame) ​

  • 虚拟内存被划分为固定大小的页(通常 4KB)
  • 物理内存被划分为同样大小的页框
  • 页是虚拟内存管理的基本单位

2.2 页表(Page Table) ​

页表是虚拟页号(VPN)到物理页号(PPN)的映射表。每个进程拥有独立的页表。

虚拟地址: [ 虚拟页号 VPN | 页内偏移 VPO ]
                         │
                    ┌────┘
                    ▼
              [页表基址寄存器 (CR3)]
                         │
                    ┌────┘
                    ▼
物理地址: [ 物理页号 PPN | 页内偏移 PPO ]

2.3 页表条目(PTE) ​

PTE 结构 (x86-64, 64位):
┌────────────────────────────────────────────────────────────┐
│ PPN(物理页号)  │ ... │ D │ A │ PCD│PWT│U/S│R/W│ P │
└────────────────────────────────────────────────────────────┘
   位63-12         位11-6                        位5 位4 位3 位2 位1 位0

P (Present):  页面是否在物理内存中
R/W:          0=只读, 1=可读写
U/S:          0=内核, 1=用户
A (Accessed): 是否被访问过(由MMU自动设置)
D (Dirty):    是否被写过(由MMU自动设置)

2.4 多级页表 ​

为什么需要多级页表?

如果使用单级页表,48 位虚拟地址空间(4KB 页)需要 236 个 PTE,每个 PTE 8 字节 → 512GB 的页表!这不可接受。

四级页表方案(x86-64):

48位虚拟地址划分:
┌────────┬────────┬────────┬────────┬────────────┐
│ PML4   │ PDPT   │ PD     │ PT     │ Offset     │
│ 9位    │ 9位    │ 9位    │ 9位    │ 12位       │
└────────┴────────┴────────┴────────┴────────────┘

四级页表遍历:
CR3 → PML4[PML4_idx] → PDPT[PDPT_idx] → PD[PD_idx] → PT[PT_idx] → 物理页

其中 PD 条目可以映射 2MB 大页(跳过最后一级)
PDPT 条目可以映射 1GB 大页(跳过最后两级)

为什么恰好是 9+9+9+9+12 位?——完整数学推导:

Step 1: 确定页大小和 offset 位数
  页大小 = 4KB = 2¹² 字节 → offset 需要 12 位

Step 2: 确定需要几级
  48 位虚拟地址 - 12 位 offset = 36 位 VPN (Virtual Page Number)
  需要把 36 位 VPN 切分成若干级,每级查一个页表

Step 3: 确定每级多少位
  每个 PTE 的大小 = 8 字节 (64 位 / 8 字节)
  每张页表大小 = 4KB (一页),与物理页大小对齐 → 便于分配和换出

  每张页表能存多少个 PTE = 4KB / 8B = 512 个 → 需要 9 位索引 (2⁹ = 512)

Step 4: 计算需要的级数
  36 位 VPN ÷ 9 位/级 = 4 级
  → 恰好是 4 级页表!

  如果页大小是 2MB (大页),offset 需要 21 位:
  48 - 21 = 27 位 VPN → 需要 3 级 (9+9+9)

  如果页大小是 1GB (巨页),offset 需要 30 位:
  48 - 30 = 18 位 VPN → 需要 2 级 (9+9)

所以四级页表不是随意设计的,而是 "48位地址空间 + 4KB页 + 8B PTE + 每级页表占用1页"
这四个约束共同决定的必然结果。

一张页表只占 4KB 而不是 512GB 的原因:
  多级页表的核心思想是"按需创建"——进程实际使用的地址空间通常只占 48 位空间的一小部分
  不需要的中间级页表根本不会分配,大幅节省内存
mermaid
graph TD
    CR3["CR3 寄存器"] --> PML4["PML4 页表<br/>(每进程1个, 512条目)"]
    PML4 --> PDPT["PDPT 页表<br/>(512条目)"]
    PDPT --> PD["PD 页表<br/>(512条目)"]
    PD --> PT["PT 页表<br/>(512条目)"]
    PT --> PAGE["物理页 (4KB)"]

2.5 TLB ​

TLB(Translation Lookaside Buffer)是 MMU 内部的缓存,缓存最近使用的虚拟地址→物理地址翻译。详情请参阅 CPU 微架构与 TLB/Cache。


3. 缺页(Page Fault) ​

3.1 缺页处理流程 ​

mermaid
sequenceDiagram
    participant CPU
    participant MMU
    participant Kernel
    participant Disk

    CPU->>MMU: 请求虚拟地址 VA
    MMU->>MMU: 查 TLB
    alt TLB 命中
        MMU-->>CPU: 返回 PA
    else TLB 未命中
        MMU->>MMU: 遍历页表
        alt PTE P=1 (在内存中)
            MMU->>MMU: 填充 TLB
            MMU-->>CPU: 返回 PA
        else PTE P=0 (不在内存中)
            MMU->>CPU: 触发缺页异常
            CPU->>Kernel: 进入缺页处理程序
            Kernel->>Kernel: 选择牺牲页
            alt 牺牲页是脏页
                Kernel->>Disk: 写回牺牲页
            end
            Kernel->>Disk: 读入目标页
            Kernel->>Kernel: 更新页表
            Kernel-->>CPU: 返回,重新执行指令
        end
    end

3.2 缺页类型 ​

类型原因处理方式
Demand Paging首次访问未分配物理页的页面从磁盘读取或分配零页
Swap Fault页面被换出到交换分区从交换分区读回
COW Fault写入共享页面(fork 后的写时复制)复制页面
Protection Fault违反页权限(写入只读页)发送 SIGSEGV 信号
Invalid Fault访问非法地址发送 SIGSEGV 信号

4. 内存映射(mmap) ​

4.1 mmap 原理 ​

mmap() 将文件或匿名内存映射到进程的虚拟地址空间。

c
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

4.2 两种映射类型 ​

类型描述数据来源
文件映射文件内容映射到虚拟内存磁盘文件
匿名映射没有对应文件全零页面(demand-zero)

4.3 共享 vs 私有映射 ​

组合行为示例
私有文件映射写时复制,修改不写回文件加载 .so、可执行文件
共享文件映射修改会写回文件,其他进程可见进程间共享内存
私有匿名映射写时复制,不共享malloc(大块)、进程栈
共享匿名映射父子进程共享同一物理页fork 后共享内存

4.4 mmap 与 read 的对比 ​

维度mmapread/write
数据拷贝次数1次(磁盘→页缓存)2次(磁盘→页缓存→用户缓冲区)
适合场景随机访问、大文件顺序读写、小文件
缺页开销每次访问新页可能触发无
内存占用占用虚拟地址空间不占用

5. 交换(Swapping) ​

5.1 交换机制 ​

当物理内存不足时,内核将部分页面换出到磁盘(交换分区或交换文件)。

Linux 使用 swap cache 来协调并发访问被换出的页面。

5.2 Linux 页面回收 ​

LRU(最近最少使用)链表:

活跃链表 (Active LRU)  ← 经常被访问的页面
非活跃链表 (Inactive LRU) ← 不太被访问的页面(换出候选)

kswapd(内核交换守护进程):在内存水位低于阈值时唤醒,将非活跃链表中的页面换出。

5.3 内存水位线 ​

高水位 (high) ───────── 空闲内存充足
低水位 (low)  ───────── kswapd 开始回收
最小水位 (min) ──────── 触发直接回收 (Direct Reclaim)
                        分配者阻塞,等待回收

5.4 OOM Killer ​

当内存彻底耗尽无法回收时,OOM Killer 根据 oom_score 选择并杀死进程。

5.5 页面置换算法 — 完整对比 ​

当物理内存不足需要将页面换出到磁盘时,选择哪个页面换出直接影响系统性能。越接近 OPT(理论最优),缺页次数越少。

五大经典算法 ​

text
OPT (Optimal, 理论最优) — 无法实现,仅作为理论下界:
  策略: 淘汰"将来最长时间不再被访问"的页面
  实现: 不可能(需要预知未来)
  用途: 衡量其他算法的效率差距

FIFO (First In First Out):
  策略: 淘汰最早进入内存的页面(队列)
  实现: 一个 FIFO 队列
  优点: 实现极简单
  缺点: 可能淘汰频繁访问的旧页面

  Belady 异常: FIFO 独有的反直觉现象
    内存帧数增加 → 缺页次数反而增多!
    原因: 增加帧数改变了淘汰顺序,反而淘汰了更多将要访问的页面
    LRU、Clock、OPT 不存在 Belady 异常(属于栈算法)

LRU (Least Recently Used):
  策略: 淘汰最久未被访问的页面
  实现: 链表 (每次访问移到头部,淘汰尾部) 或矩阵计数器
  优点: 接近 OPT,利用时间局部性
  缺点:
    - 每次访问都要更新链表 → 硬件开销大
    - 需要额外的访问位记录时间戳

Clock (近似 LRU, Second Chance):
  策略: 近似 LRU,给每个页面一个"访问位"
  实现:
    页面排成环形链表,指针循环扫描:
    1. 访问位 = 0 → 淘汰该页面
    2. 访问位 = 1 → 清零,给第二次机会,指针前进
  优点: 硬件只需 1 bit 访问位,MMU 自动设置
  现实: Linux 和大多数 OS 的实际选择

Enhanced Clock (改进型 Clock):
  策略: 同时考虑"访问位"和"修改位"
  优先级 (越小越先淘汰):
    (0,0) 未访问未修改 → (0,1) 未访问已修改 → (1,0) 已访问未修改 → (1,1) 已访问已修改
  优点: 优先淘汰干净页(无需写回磁盘),性能更好

算法对比 ​

算法缺页率实现复杂度硬件支持Belady异常实际使用
OPT最低——❌理论基准
FIFO中-高极简单不需要✅ 存在极少
LRU低高需要时间戳❌早期系统
Clock接近LRU低1 bit 访问位❌Linux 内核
Enhanced Clock接近LRU中访问+修改位❌Linux 变体

Linux 实际实现 ​

text
Linux 不使用页级 Clock,而是基于 LRU 链表的两级回收:

活跃链表 (Active LRU):
  - 频繁被访问的页面
  - 匿名页 (anon): 进程堆/栈
  - 文件页 (file): 文件映射页

非活跃链表 (Inactive LRU):
  - 不太被访问的页面(换出候选)
  - kswapd 优先回收此链表

页面迁移:
  首次访问 → Inactive (不确定是否热)
  再次访问 → Active (确认是热的)
  长时间未访问 → 降级回 Inactive
  内存压力 → kswapd 从 Inactive 尾部回收

为什么是两链表而非严格 LRU?
  - 严格 LRU 每次访问都需更新链表位置 → 锁争用严重
  - 两链表"懒惰"升级/降级 → 锁成本低,适合多核
  - 实际效果接近 LRU,实现却简单得多

7. NUMA 架构与内存分配 ​

7.1 为什么需要 NUMA ​

在 SMP(对称多处理)架构中,所有 CPU 共享一条内存总线。当 CPU 数量增加时,总线成为瓶颈:

SMP(对称多处理):                NUMA(非一致性内存访问):
┌───┬───┬───┬───┐              ┌───────┐   ┌───────┐
│CPU│CPU│CPU│CPU│              │CPU CPU│   │CPU CPU│
└─┬─┴─┬─┴─┬─┴─┬─┘              │ Node0 │   │ Node1 │
  │   │   │   │                │本地内存│   │本地内存│
  └───┴───┴───┘                └───┬───┘   └───┬───┘
   ═══ 共享总线 ═══               └─── 互联总线 ───┘
    (瓶颈!)

7.2 NUMA 的内存访问延迟 ​

同一 NUMA Node(本地访问):~100ns
跨 NUMA Node(远端访问):  ~150-200ns(1.5-2x 延迟)

分配策略:
--localalloc: 默认,在发起访问的 CPU 所在 Node 上分配
--membind:    强制绑定到指定 Node
--interleave: 轮询分配到所有 Node(避免单 Node 内存耗尽)

7.3 诊断与调优 ​

bash
# 查看 NUMA 拓扑
numactl --hardware
lscpu | grep NUMA

# 查看进程的 NUMA 内存分布
numastat -p <pid>

# 绑定进程到指定 NUMA Node
numactl --cpunodebind=0 --membind=0 ./my_program

# 交错分配(适用于内存分配 > 单 Node 容量的场景)
numactl --interleave=all ./my_program

7.4 Go 中的 NUMA 意识 ​

Go runtime 默认不完全 NUMA-aware,但有一些优化:

  • Go 1.10+:GOMAXPROCS 按 Node 分配 P(Processor),尽量不跨 Node
  • 可以通过 taskset / numactl 从外部绑定
  • 数据库等场景建议用 numactl --interleave=all 避免单 Node 内存瓶颈
bash
# 把 Go 程序绑定到 Node 0
taskset -c 0-15 numactl --membind=0 ./my_go_server

# 数据库(如 TiKV/RocksDB)推荐交错
numactl --interleave=all ./tikv-server

8. 大页(Huge Pages) ​

6.1 大页的优势 ​

指标4KB 页2MB 大页
映射 1GB 需要的 PTE 数262,144512
TLB 覆盖(400条 L2 TLB)1.6MB800MB
缺页处理开销(单页)相同相同
内部碎片风险低高

6.2 透明大页(THP) ​

Linux THP 自动将连续的 4KB 页合并为 2MB 大页,对应用透明。

THP 为什么会引发延迟毛刺? ​

虽然 THP 减少了 TLB miss,但它的后台维护操作会引入不可预测的延迟抖动——这对 Redis、JVM、Go 运行时等延迟敏感的应用是致命的。

THP 的后台维护操作有两个阶段:

1. khugepaged(内核线程)扫描进程内存,寻找可以合并的 4KB 连续页:
   - 扫描频率: 每 10 秒一次(可配置)
   - 合并条件: 2MB 对齐 + 512 个连续 4KB 页全部存在
   - 合并操作: 分配 2MB 新页 → 拷贝 512 个 4KB 页 → 更新页表

2. Compaction Stall(压缩停顿)—— 真正的延迟杀手:
   当应用申请大页但找不到连续的 2MB 空间时:
   - 内核暂停应用的分配请求(stall!)
   - 启动内存压缩(compaction):移动 4KB 页来腾出连续的 2MB 空间
   - 这个过程可能耗时数毫秒到数十毫秒
   - 应用线程在此期间完全被阻塞
mermaid
sequenceDiagram
    participant App as 应用线程
    participant Kernel as 内核
    participant Memory as 物理内存

    Note over Memory: 4KB 页碎片化<br/>无法找到连续 2MB

    App->>Kernel: malloc() / mmap() 请求内存
    Kernel->>Memory: 尝试分配 2MB 连续页
    Memory-->>Kernel: ❌ 没有连续 2MB

    rect rgb(255, 200, 200)
        Note over App: ⚠️ COMPACTION STALL
        Kernel->>Memory: 启动内存压缩<br/>移动 4KB 页 → 腾出连续空间

        Note over Memory: 耗时: 1ms ~ 100ms+
        Note over App: 应用线程被暂停!
    end

    Memory-->>Kernel: ✅ 连续 2MB 已就绪
    Kernel->>Memory: 分配 2MB 大页
    Kernel-->>App: 返回内存
    Note over App: 应用继续执行<br/>但 P99 延迟已经飙高

为什么 Redis 官方强烈建议关闭 THP? ​

text
Redis 的特征:
  - 内存分配频繁(写操作不断产生新 key / 修改 value)
  - fork() 产生子进程写 RDB / AOF rewrite
  - 延迟极度敏感(P99 通常要求 < 1ms)

THP 在 Redis 场景下的问题:
  1. fork() 时 COW 触发大量 4KB 页写入 → 内存碎片化
  2. Redis 后续 malloc() → 内核尝试分配 2MB 大页
  3. compaction stall → Redis 主线程暂停
  4. P99 延迟从 <1ms → 数十甚至数百毫秒

这就是 Redis 官方日志中那句经典的警告:
  "WARNING you have Transparent Huge Pages (THP) support enabled in your kernel.
   This will create latency and memory usage issues with Redis."
bash
# Redis 官方建议:关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 查看当前 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never  ← 当前配置

不同场景的 THP 建议 ​

场景建议原因
Redis❌ 关闭 (never)延迟敏感,fork + COW 触发频繁碎片
Memcached❌ 关闭slab 分配器,与 THP 不兼容
MongoDB❌ 关闭 (官方建议)内部使用 mmap,THP 干扰内存管理
JVM (大堆)⚠️ madvise应用自己控制大页分配时机
Go 服务 (大堆 10GB+)⚠️ madviseGo runtime 用 madvise 建议内核使用大页
数据库 buffer pool⚠️ madvise稳定运行后手动触发,而非自动
批处理 / 离线计算✅ always延迟不敏感,TLB 命中率收益大

THP 延迟毛刺的监控 ​

bash
# 1. 检查 compaction stall 事件
perf stat -e compaction:migration:mm_compaction_migratepages \
           -e compaction:begin:mm_compaction_begin         \
           -e compaction:end:mm_compaction_end             \
           -p $PID sleep 30

# 2. 查看 khugepaged 活动
cat /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs

# 3. 查看 THP 使用情况
grep -E "AnonHugePages|ShmemHugePages" /proc/meminfo
cat /proc/vmstat | grep thp
# thp_fault_alloc: 通过缺页分配的大页数
# thp_collapse_alloc: 通过压缩合并的大页数(这个值高 = 频繁 compaction)
# thp_split_page: 大页被分割的次数

madvise 模式:应用自主控制 ​

madvise 模式下,只有应用通过 madvise() 系统调用明确建议的地址范围,内核才会尝试分配大页。这给了应用精细控制的能力:

go
// Go runtime 在初始化 heap arena 时使用 madvise 建议大页
import "golang.org/x/sys/unix"

func allocWithHugePage(size int) ([]byte, error) {
    // 分配内存
    data, err := unix.Mmap(-1, 0, size,
        unix.PROT_READ|unix.PROT_WRITE,
        unix.MAP_ANONYMOUS|unix.MAP_PRIVATE)
    if err != nil { return nil, err }

    // 建议内核:这个区域适合使用大页
    unix.Madvise(data, unix.MADV_HUGEPAGE)
    return data, nil
}

一句话总结:THP 是"空间换时间"的策略——用内存碎片和 compaction stall 换取更少的 TLB miss。对延迟敏感的应用(Redis/JVM/Go 服务),这个代价通常不划算,建议使用 madvise 模式或关闭。


7. 虚拟内存实战 ​

7.1 TLB — 地址翻译的"CPU 缓存" ​

每次内存访问都要查页表(多级查找,4 次内存访问),CPU 无法承受。TLB (Translation Lookaside Buffer) 是 MMU 内部的专门缓存,缓存最近用过的"虚拟页→物理页"映射:

mermaid
flowchart TB
    VA["虚拟地址"] --> TLB{"TLB 命中?"}
    TLB -->|"✅ 命中 (99%+)"| PA1["直接得到物理地址<br/>延迟: ~0.5ns (L1 TLB)"]
    TLB -->|"❌ 未命中"| PM["页表遍历 (Page Walk)"]
    PM -->|"4 级页表 (4 次内存访问)"| PA2["得到物理地址<br/>延迟: ~100ns<br/>并缓存到 TLB"]
TLB 层级条目数延迟覆盖范围 (4KB页)
L1 iTLB (指令)64-128~0.5 ns256-512 KB
L1 dTLB (数据)64-128~0.5 ns256-512 KB
L2 STLB (共享)1536~5 ns6 MB

TLB miss 的代价:一次 L1 TLB miss + L2 STLB 命中约 5ns;一次全 miss + 4 级页表遍历约 100ns——是 L1 命中的 200 倍。这也是为什么大页 (2MB) 能把 TLB 覆盖率提升 512 倍(一个 TLB 条目覆盖 2MB 而非 4KB)。

7.2 大页(HugePages)在 Go/Java 中的应用 ​

大页类型页大小特点使用方式
HugeTLB2MB / 1GB需显式预留,不会换出echo 512 > /proc/sys/vm/nr_hugepages
THP (透明大页)2MB内核自动合并,对应用透明echo always > /sys/kernel/mm/transparent_hugepage/enabled

Go 中的大页:Go runtime 在初始化时通过 madvise(MADV_HUGEPAGE) 建议内核使用大页,但实际是否分配大页取决于 THP 配置。对于 1GB+ 堆的 Go 服务,启用 THP 可减少 10-20% 的 TLB miss,但会增加内存碎片(内部碎片 2MB-实际使用)。

Java 中的大页:JVM 通过 -XX:+UseLargePages 启用,G1GC/ZGC 等使用大堆时提升明显。

7.3 mmap — 文件到内存的映射 ​

mmap 将文件直接映射到进程虚拟地址空间,访问文件像访问内存一样:

go
import "golang.org/x/sys/unix"

func mmapRead(path string) ([]byte, error) {
    f, _ := os.Open(path)
    defer f.Close()
    fi, _ := f.Stat()
    size := int(fi.Size())

    data, err := unix.Mmap(int(f.Fd()), 0, size,
        unix.PROT_READ, unix.MAP_SHARED)
    return data, err
}
维度mmapread/write
数据拷贝0 次(直接映射 Page Cache)2 次(内核→用户)
系统调用0(映射后直接访问内存)每次 read/write 都调用
页面调入按需(缺页时加载,惰性)立即读入
内存占用占用虚拟地址空间仅读入时占用物理内存
适合大文件随机读、持久化队列小文件、流式顺序访问
不适合网络 IO、管道、频繁写入—

Kafka/RocketMQ 为何用 mmap:消息队列的日志段文件需要频繁随机读取(consumer 可能在任意 offset),mmap 避免了 read() 的 syscall 和数据拷贝,同时利用操作系统 Page Cache 的 LRU 淘汰策略。写入时结合 write() + flush(mmap 不适合直接写入,因为缺页处理会导致写停滞)。

bash
# /proc/PID/maps: 进程的虚拟地址空间布局
cat /proc/self/maps

# pmap: 更友好的展示
pmap -x <PID>

7.2 常见问题诊断 ​

现象可能原因
RSS 远小于 VSZ大量内存被分配但未实际使用
频繁 swap in/out(用 vmstat 查看)内存不足,需扩容或优化
SIGSEGV访问非法地址或违反保护
OOM内存泄漏或配置不足

9. 生产系统中的虚拟内存故障 ​

9.1 Minor Fault 和 Major Fault 差别为什么这么大 ​

缺页不等于一定要读磁盘。

类型含义代价
Minor Fault页不在当前进程页表里,但物理页已在内存中相对可控
Major Fault需要真正从磁盘或 swap 读入页面代价极高,可能拖垮 RT

所以线上看到 page fault 不能直接恐慌,关键是分清:到底只是页表层面的“补登记”,还是已经进入磁盘 I/O 级别的慢路径。

9.2 为什么 swap 抖动会把应用拖成“看起来 CPU 不高但特别慢” ​

mermaid
flowchart LR
    A["工作集超过物理内存"] --> B["kswapd / direct reclaim"]
    B --> C["匿名页或页缓存被回收"]
    C --> D["再次访问时发生缺页"]
    D --> E["需要从磁盘或 swap 读回"]
    E --> F["请求线程阻塞"]
    F --> G["RT 飙高,CPU 不一定高"]

这类问题的典型误判是:

  • 应用 CPU 不高,以为不是本机问题
  • 网络看起来也正常
  • 但请求就是越来越慢

实际根因可能是:

  • 内存打满,进入 direct reclaim
  • 匿名页被 swap out
  • 热数据工作集已经放不进内存

9.3 页缓存、匿名内存、mmap 为什么要放在一起看 ​

在 Linux 里,内存不是“只有应用堆”这么简单。

类型常见来源关注点
匿名内存Go heap、线程栈、malloc、大对象GC、栈膨胀、对象保活
页缓存(Page Cache)文件读写、数据库、日志、mmap 文件是否挤压匿名内存
文件映射mmap、共享库、数据库数据文件缺页行为、回收策略

很多线上问题的本质是三者互相争抢:

  • 应用堆太大,挤压 page cache
  • page cache 太激进,导致匿名页回收压力上升
  • mmap 文件访问稀疏,导致 major fault 多

9.4 一个常见链路:应用变慢不一定是 GC,也可能是虚拟内存 ​

以 Go 服务为例,用户看到的可能只是 P99 抖动,但背后的链路可能是:

text
流量上涨
  → goroutine / heap / page cache 一起涨
  → 内存接近上限
  → reclaim / swap / major fault 增多
  → handler 卡在缺页和磁盘等待
  → P99 升高,超时增多

这类场景特别容易和 GC、数据库慢查询、网络抖动混淆,所以必须联合看:

  • heap / RSS
  • pgfault / pgmajfault
  • swap in/out
  • 磁盘延迟

9.5 排障 Runbook ​

先看全局是否在抖 ​

bash
vmstat 1
sar -B 1
sar -W 1
free -h
cat /proc/meminfo

重点观察:

  • si / so:swap in/out
  • pgmajfault:major fault
  • wa:I/O wait
  • free / available:可用内存是否过低

再看进程维度 ​

bash
pidstat -r -p <pid> 1
pmap -x <pid>
cat /proc/<pid>/smaps_rollup
numastat -p <pid>

如果怀疑缺页影响性能 ​

bash
perf stat -e page-faults,minor-faults,major-faults -p <pid> sleep 10

9.6 调优原则 ​

问题更合理的方向
major fault 多降低工作集、减少稀疏访问、给足内存
swap 抖动控制内存峰值,必要时调低 swappiness
page cache 挤压匿名内存分析文件 I/O 模式、限流、冷热分层
NUMA 远端访问多调整绑核绑内存策略

一个重要原则是:不要一看到内存问题就先调内核参数。更高优先级通常是先搞清楚:到底是匿名内存过大、page cache 过大、还是 mmap/访问模式导致工作集根本装不下。


10. COW(Copy-on-Write)的工程影响 ​

10.1 COW 的基本机制 ​

mermaid
flowchart LR
    subgraph BEFORE["fork() 之前"]
        P["父进程页表"] --> PHY["物理页 (共享)"]
    end
    subgraph AFTER["fork() 之后"]
        PA["父进程页表"] --> PHY2["物理页 (共享, 只读标记)"]
        CH["子进程页表"] --> PHY2
    end
    subgraph WRITE["任一方写入时"]
        PA2["父进程页表"] --> PHY3["原物理页"]
        CH2["子进程页表"] --> PHY4["新复制的物理页"]
    end

fork() 不会立即复制所有内存,而是让父子进程共享同一组物理页,并把这些页标记为只读。当任一方尝试写入时,触发 COW 缺页,内核才真正复制那一页。

10.2 Redis BGSAVE 时 COW 导致内存翻倍 ​

这是线上最常见的 COW 问题之一。

mermaid
sequenceDiagram
    participant R as Redis 主进程
    participant C as 子进程 (BGSAVE)
    participant K as 内核

    R->>K: fork()
    K->>K: 父子共享所有物理页 (COW 标记)
    K-->>C: 子进程开始遍历数据写 RDB

    rect rgb(255, 240, 240)
        Note over R: 主进程继续处理写请求
        R->>K: 写入某个 key
        K->>K: COW 触发: 复制该页
        Note over K: 每写一页就多占一页物理内存
    end

    Note over K: 如果写入量大 → 几乎所有页都被复制<br/>→ 内存接近 2×

为什么 Redis 的 COW 特别严重?

因素说明
Redis 数据全在内存堆很大,COW 的潜在复制量 = 整个数据集
写入频繁每秒大量 SET/INCR/LPUSH → 大量页被修改
jemalloc 的页布局一个 key 的修改可能触发整个 4KB 页的复制
子进程存活时间长RDB 写盘慢 → COW 窗口长 → 累积复制量大

实际数值:

text
Redis 使用 10GB 内存
BGSAVE 期间写入 QPS = 50000
每次写入平均触发 1 个新页的 COW

50000 × 4KB = 200MB/s 的额外内存增长
如果 BGSAVE 持续 30s → 额外 6GB
极端情况: 内存从 10GB → 接近 20GB → OOM

应对策略:

策略做法
控制写入量BGSAVE 期间降低写入 QPS
预留内存容器 memory limit 至少留 1.5× 数据量
用 AOF 替代 RDBappendonly yes + aof-use-rdb-preamble yes
调整 BGSAVE 频率减少 fork 次数
监控 COW 页数info persistence 中的 latest_fork_usec 和 rdb_last_cow_size

10.3 Go runtime 与 fork/COW ​

Go 程序很少直接 fork()(因为 goroutine 和多线程不兼容 fork),但以下场景仍然涉及 COW:

场景说明
os/exec.Command内部 fork+exec,fork 瞬间 COW 标记整个堆
容器内 Go 服务容器 memory limit 需要考虑 fork 瞬间的 COW 峰值
cgo 调用 fork如果 cgo 库内部 fork,可能触发 COW

Go 1.20+ 在 Linux 上使用 CLONE_VM + CLONE_VFORK 来避免完整 fork 的 COW 开销(os/exec 路径)。

10.4 容器 memory limit 与 COW 的关系 ​

text
容器 memory limit = 4GiB
Redis 数据 = 3GiB
BGSAVE 触发 fork

fork 瞬间:
  父子共享 3GiB 物理页
  RSS 报告 ≈ 3GiB(共享页只计一次)

BGSAVE 期间写入:
  每写一页 → COW → 物理内存 +4KB
  如果写了 1GiB 的页 → 物理内存 = 3GiB + 1GiB = 4GiB
  → 触发 OOM → 容器被杀

所以容器里跑 Redis/MySQL 等会 fork 的服务,memory limit 必须留足 COW 余量。

经验公式:

text
memory_limit >= 数据量 × (1 + 预期COW比例) + 其他开销

Redis: memory_limit >= used_memory × 1.5 ~ 2.0
MySQL (InnoDB): fork 较少,但 buffer pool 大时也要注意

10.5 COW 与 MADV_FREE / MADV_DONTNEED ​

Go runtime 归还内存给 OS 时,有两种方式:

方式行为COW 影响
MADV_DONTNEED立即释放物理页,下次访问触发缺页无 COW 问题
MADV_FREE标记为"可回收",但不立即释放fork 时这些页仍然参与 COW

Go 1.12-1.15 默认使用 MADV_FREE(RSS 不降),Go 1.16+ 改回 MADV_DONTNEED(RSS 能降)。

这个选择直接影响:

  • 容器内 RSS 监控的准确性
  • fork 时的 COW 内存峰值
  • OOM 判断的时机

10.6 排障:怎么判断是不是 COW 导致的内存问题 ​

bash
# 1. 看 fork 时的 COW 页数(Redis)
redis-cli info persistence | grep cow

# 2. 看进程的共享/私有内存
cat /proc/<pid>/smaps_rollup
# Shared_Clean / Shared_Dirty / Private_Clean / Private_Dirty

# 3. 看 fork 前后的内存变化
watch -n 1 'cat /proc/<pid>/status | grep -E "VmRSS|VmSize"'

# 4. 看容器内存事件
dmesg | grep -i oom
cat /sys/fs/cgroup/memory/memory.failcnt

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记