什么是 ‘Memory Overcommit’?解析 Linux 内核如何在物理内存不足时‘画大饼’并在触发 OOM 时‘杀人’

各位同行,大家好!

今天,我们将深入探讨 Linux 内核中一个既高效又令人敬畏的机制——内存超额分配(Memory Overcommit)。这个机制允许系统“画大饼”,承诺给进程远超实际物理内存的虚拟内存,同时,当“大饼”无法兑现,物理内存耗尽时,又会毫不留情地“杀人”,以维护系统的稳定。作为一名编程专家,理解这一机制对于开发高性能、高稳定性的应用,以及有效管理服务器资源至关重要。

内存超额分配:虚拟世界的慷慨与现实的残酷

我们首先来理解什么是内存超额分配。

1.1 什么是内存超额分配 (Memory Overcommit)?

内存超额分配是指操作系统允许应用程序请求和拥有比系统实际可用物理内存总量更多的虚拟内存。这听起来似乎有些冒险,但它是现代操作系统提高内存利用率、优化性能的关键策略之一。

想象一下一个航空公司售票系统。航空公司可能会出售比飞机实际座位数更多的机票,这就是所谓的“超售”。为什么这样做?因为航空公司知道,总会有一些乘客因各种原因无法登机。通过超售,航空公司可以确保航班满座,最大限度地提高收益。

Linux 内核处理内存的方式与此类似。它允许进程申请大量的内存,即使这些内存的总和已经超过了物理内存加上交换区(swap space)的总和。内核知道,大多数程序在任何给定时间点都不会真正使用它们所申请的所有内存。例如,一个程序可能申请 1GB 的内存,但实际上只使用了其中的 100MB。如果内核严格按照申请量来分配物理内存,那么即使系统有足够的物理内存,也会因为虚拟内存地址空间被“占满”而拒绝新的内存申请,导致内存资源浪费。

通过超额分配,内核可以:

  • 提高内存利用率: 允许更多的进程同时运行,因为它们共享了物理内存。
  • 简化编程: 程序员无需精确计算内存需求,可以申请足够大的内存缓冲区,而不必担心立即耗尽物理内存。
  • 优化性能: 避免了不必要的内存分配失败,延迟了物理内存的实际分配,从而减少了系统开销。

然而,这种“画大饼”的策略也伴随着风险。一旦所有进程实际使用的内存总和超过了物理内存和交换区的总和,系统就会陷入真正的内存短缺,此时,内核就必须采取“杀人”的极端措施,即启动 OOM (Out Of Memory) Killer 来终止某些进程,以释放内存,避免系统崩溃。

1.2 虚拟内存的基石:一个抽象的舞台

要理解内存超额分配,我们首先需要理解虚拟内存系统。

在现代操作系统中,每个进程都运行在一个独立的、巨大的虚拟地址空间中。这个虚拟地址空间是一个抽象,它让每个进程都觉得自己拥有了全部的内存,通常是 4GB(32位系统)或 256TB(64位系统,Linux 通常限制到 128TB 或 256TB)。进程看到的内存地址是虚拟地址,而不是物理内存地址。

虚拟内存系统的核心组件是:

  • 虚拟地址空间 (Virtual Address Space): 进程可见的、连续的地址范围。
  • 物理内存 (Physical Memory): 计算机中实际安装的 RAM。
  • 内存管理单元 (MMU – Memory Management Unit): 位于 CPU 内部的硬件单元,负责将虚拟地址实时翻译成物理地址。
  • 页表 (Page Tables): 存储在物理内存中的数据结构,由操作系统维护,记录了虚拟页(通常是 4KB 大小)到物理页帧的映射关系。

当进程访问一个虚拟地址时,MMU 会查找页表,找到对应的物理地址。如果该虚拟地址尚未被映射到任何物理页,或者对应的物理页目前不在物理内存中(例如被交换到磁盘),就会触发一个“缺页中断”(Page Fault)。这是虚拟内存系统中最关键的机制之一,也是内存超额分配得以实现的基础。

Linux 内核的“画大饼”策略

Linux 内核通过巧妙地延迟物理内存的实际分配,实现了内存超额分配。

2.1 惰性分配 (Lazy Allocation):承诺与交付的分离

当一个程序通过 malloc()mmap() 等系统调用请求内存时,Linux 内核通常不会立即分配对应的物理内存。它只会为进程的虚拟地址空间添加一个新的虚拟内存区域(Virtual Memory Area, VMA),并更新页表,标记这些虚拟页为“未映射”或“未分配物理页”。这就是“惰性分配”或“延迟分配”策略。

我们来看一个简单的 C 语言程序,并观察其内存使用情况。

代码示例 1:malloc 一个大块内存,但不访问

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h> // For memset, if we decide to touch memory

// Helper to get process status info
void print_mem_info(const char* label) {
    char cmd[256];
    printf("n--- %s (PID: %d) ---n", label, getpid());
    // VmSize: 虚拟内存大小, VmRSS: 驻留集大小 (物理内存), VmSwap: 交换区使用量, VmPeak: 虚拟内存峰值
    sprintf(cmd, "grep -E 'VmSize|VmRSS|VmSwap|VmPeak' /proc/%d/status", getpid());
    system(cmd);
    printf("-----------------n");
}

int main(int argc, char *argv[]) {
    printf("进程 PID: %dn", getpid());

    print_mem_info("初始状态");

    size_t size_mb = 512; // 申请 512 MB 内存
    size_t size_bytes = size_mb * 1024 * 1024;
    printf("尝试 malloc %zu MB 内存 (不写入)...n", size_mb);

    char *mem = (char*)malloc(size_bytes);
    if (mem == NULL) {
        perror("malloc 失败");
        return 1;
    }
    printf("malloc 成功。注意:此时可能尚未分配物理内存。n");

    print_mem_info("malloc 后 (未写入)");

    printf("等待 10 秒,观察内存使用情况...n");
    sleep(10); // 等待,让你可以用 top/htop/pmap 观察

    // 此时,如果取消注释下面的行并编译运行,会触发物理内存分配
    // printf("现在写入内存以触发物理页分配...n");
    // for (size_t i = 0; i < size_bytes; i += 4096) { // 每页写入一个字节
    //     mem[i] = (char)(i % 256);
    // }
    // printf("已写入 malloc 分配的内存。n");
    // print_mem_info("malloc 后 (已写入)");
    // sleep(10);

    free(mem);
    printf("内存已释放。n");
    print_mem_info("释放后");

    printf("程序退出。n");
    return 0;
}

编译并运行此程序:gcc -o mem_test mem_test.c && ./mem_test

你会观察到:

  • malloc 之后(未写入数据),VmSize 会立即增加 512MB,反映了虚拟内存的增长。
  • 然而,VmRSS(Resident Set Size,实际驻留在物理内存中的页数)可能只会略微增加,或者几乎不变。这表明内核只是“承诺”了这部分内存,但尚未实际“交付”物理页。

缺页中断 (Page Fault):真正触发物理内存分配的时刻

那么,物理内存何时才会被分配呢?答案是:当进程首次尝试访问这些虚拟地址时。

当进程第一次读写某个虚拟地址时,MMU 发现对应的页表条目无效或未映射,就会触发一个缺页中断。此时,操作系统内核介入:

  1. 分配物理页: 内核从空闲物理页池中分配一个物理页帧。
  2. 更新页表: 内核更新进程的页表,将该虚拟页映射到新分配的物理页帧。
  3. 重新执行指令: MMU 重新尝试转换地址,此时会成功,进程可以继续执行。

这就是惰性分配的核心:物理内存的分配被推迟到真正需要时。如果进程申请了内存但从未访问,那么这部分内存永远不会占用宝贵的物理 RAM。

写时复制 (Copy-on-Write, CoW):节省内存的利器

惰性分配的另一个强大应用是“写时复制”(Copy-on-Write, CoW)。当进程通过 fork() 系统调用创建子进程时,父子进程最初共享相同的物理内存页。这些页被标记为只读。只有当父进程或子进程尝试修改其中任何一个共享页时,内核才会为修改方复制一个新的物理页,从而实现“写时复制”。这极大地减少了 fork() 操作的内存开销,也是内存超额分配成功的关键之一。

2.2 超额分配的控制:vm.overcommit_memory

Linux 内核提供了 /proc/sys/vm/overcommit_memory 参数来控制内存超额分配的行为。这个参数有三个可能的值:

| 值 | 模式名称 | 描述 | 行为 表 1:vm.overcommit_memory 模式详解

| 值 | 模式名 | 描述 | 行为 |
| 0 | 启发式 (Heuristic) | 这是 Linux 内核的默认行为。当应用程序请求内存时,内核会根据当前系统内存使用情况、空闲内存、交换区大小以及 vm.overcommit_ratio 进行启发式判断。如果内核认为请求是合理的,并且不会立即导致 OOM,它会允许请求。如果请求的内存加上已提交的内存超过了物理内存和交换区的总和,但又未达到某个临界点,内核会尝试进行分配。这个模式下,内核会尽量避免 OOM,但仍允许超额分配。 |
| 1 | 总是超额分配 (Always Overcommit) | 在这个模式下,内核会无条件地满足进程的所有内存分配请求,无论系统有多少物理内存或交换区。只有当实际的物理内存分配(例如,通过写入)失败时,例如,在缺页中断处理过程中无法找到可用的物理页且无法将其他页交换出时,才会发生内存分配错误。这个模式下,CommitLimit 不会限制 Committed_AS。 |
| 2 | 严格限制 (Never Overcommit) | 在这个模式下,内核会严格限制总的虚拟内存提交量。它会计算一个理论上的“承诺极限”(CommitLimit),这个值通常是物理内存加上交换区大小的某个百分比。新的内存分配请求只有在 Committed_AS(已承诺的虚拟内存量)加上新的请求量不超过 CommitLimit 时才会被允许。如果超过,malloc 会失败并返回 NULL。这种模式下,系统永远不会超额分配内存。 |
| vm.overcommit_ratio | N/A | 在 vm.overcommit_memory = 2 模式下,这个参数定义了可以超额分配的内存占物理内存总量的百分比。例如,如果 MemTotal 是 16GB,overcommit_ratio 是 50,那么 CommitLimit 将是 16GB + (16GB * 0.50) = 24GB。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注