作者:Artificer老王 | 更新时间:2026-09-01 | 阅读时长:约 22 分钟

<p align="center"><strong><u>从一句常见误解说起</u></strong></p>

不少开发者直觉地认为,调用 malloc 之后系统就拿到了一块物理内存。这种认知在多数功能开发里不会立刻暴露问题,却会在排查内存占用、RSS 异常或 OOM 时造成误判。本文试图把虚拟内存与物理内存的区别、内核如何管理内存、malloc 与 free 各自触发了哪些系统行为讲清楚,并且每一处结论都给出可核查的官方资料与真机实验结果。

<p align="center"><strong><u>虚拟内存与物理内存的概念</u></strong></p>

物理内存即装机的内存条(RAM),内核以架构无关的抽象来管理它。Linux 把物理地址空间划分为节点(node,由 struct pglist_data 表示)与区域(zone,由 struct zone 表示),物理内存的最小管理单位是一个个物理页框(page frame),每个页框由 struct page 描述[6]。这种分层是为了兼顾 NUMA 拓扑、寻址约束与回收策略。

虚拟内存则是另一种视角:每个进程拥有各自独立的虚拟地址空间,CPU 发出的地址是虚拟地址,需要经过页表翻译为物理地址后才能访问内存。内核文档对这一过程有明确定义,即 page tables map virtual addresses to physical ones through a series of page tables。Linux 把页表组织为 PGD、P4D、PUD、PMD、PTE 五级抽象模型,叶子级的 PTE 指向真正的物理数据页;当架构支持的级数不足五级时,内核会把多余的层级折叠(fold)处理,在概念上仍按五级对待[7]。x86-64 未启用 LA57 五级分页时按四级工作,本文实验机即属此类:内核虽编译了 CONFIG_X86_5LEVEL,但 CPU 不含 la57 特性位,实际运行的仍是四级页表,对应 48 位虚拟地址空间[12]。

图 1:虚拟地址经 MMU 与页表翻译为物理地址;两个进程可以使用相同的虚拟地址,经各自页表映射到不同的物理页框。

process A                            process B
+------------------+                 +------------------+
| virtual address  |                 | virtual address  |
| space            |                 | space            |
+--------+---------+                 +--------+---------+
         |                                   |
         | page table A                      | page table B
         v                                   v
      +--+------------------------------+--+
      |  MMU: translate virtual address    |
      |  to physical address (TLB + walk)  |
      +-----------------+------------------+
                        v
+------------------------------------------------------+
| physical RAM: page frames                            |
| [frame 0] [frame 1] [frame 2] [frame 3]...[frame N]  |
+------------------------------------------------------+

图 2:x86-64 四级页表的一次地址翻译过程(P4D 折叠于 PGD),48 位虚拟地址按 9+9+9+9+12 划分。

x86-64, 4-level paging, 4 KiB pages (P4D folded into PGD)

virtual address = [ PGD | PUD | PMD | PTE | page offset ]
                    9b    9b    9b    9b       12b

CR3 --> PGD[idx] --> PUD[idx] --> PMD[idx] --> PTE[idx]
                                                    |
                                                    v
                                          page frame + offset
                                          = physical address

引入虚拟内存的动因并不复杂:它使进程彼此隔离,使进程可用的地址空间可以远超物理内存容量(配合换页与按需调页),也便于共享库、写时复制等机制的实现。虚拟地址空间本身只是预留的编号,并不等价于已经占用的物理页框。

<p align="center"><strong><u>Linux 内存管理原理</u></strong></p>

进程的用户态虚拟地址空间在逻辑上通常包含代码段、数据段、堆、内存映射区与栈等区域。内核用 struct mm_struct 描述整个虚拟地址空间,用一串 struct vm_area_struct(VMA)描述其中一段具有相同访问权限与保护属性的连续虚拟内存区域[7]。用户态申请内存时,内核通常只新增或扩展一块 VMA,页表项的建立留待缺页时完成。

图 3:进程用户态虚拟地址空间的典型布局,以及 mm_struct 与 VMA 的对应关系。

user virtual address space, x86-64 (high address at top)

+---------------------------------------+  ~0x7fffffffffff
| stack                    grows down   |
+---------------------------------------+
| mmap region: shared libraries,        |
| anonymous mappings (large mallocs)    |
+---------------------------------------+
| heap                     grows up     |
+---------------------------------------+
| data / bss                            |
+---------------------------------------+
| text (program code)                   |
+---------------------------------------+  low address

mm_struct describes the whole space;
each vm_area_struct (VMA) covers one contiguous
range with uniform access permissions

物理页框的分配由内核负责。空闲页框通常以伙伴系统(buddy system)组织:相同阶(order,即 2 的幂次大小)的空闲页挂在对应的空闲链表上,分配时按阶取用、释放时尝试合并[9]。伙伴系统按 zone(如 ZONE_NORMAL)供给页框,具体入口是 alloc_pages 一族函数,实现位于内核 mm/page_alloc.c[6][9]。

图 4:伙伴系统按阶组织空闲页框;分配时按阶取用、必要时分裂,释放时与伙伴合并。

buddy allocator: free page frames grouped by order

order 0  (4 KiB)    [1 page]    -> [1 page] -> ...
order 1  (8 KiB)    [2 pages]   -> ...
order 2  (16 KiB)   [4 pages]   -> ...
...
order 10 (4 MiB)    [1024 pages]-> ...

allocate: take the smallest order that fits the request;
          split a larger free block into two buddies if needed
free:     put the block back; merge with its buddy
          when that buddy is free as well

把虚拟页和物理页关联起来的是缺页中断(page fault)。当进程访问一个尚未建立物理映射的虚拟页时,CPU 触发缺页异常,内核据此分配一个空闲物理页框、按需清零(对匿名映射而言),并把 PTE 写回,使虚拟地址指向该物理页[3][7][9]。这也是按需调页(demand paging)的含义:物理页并非预先铺满,只在被访问时才落地。

图 5:缺页中断的处理流程;合法访问经伙伴系统取页、清零、写回 PTE 后重新执行指令,非法访问则投递 SIGSEGV。

page fault handling (demand paging)

access a virtual page that has no valid mapping
        |
        v
CPU raises a page fault, enters the kernel
        |
        v
is the access covered by a VMA and permitted?
        |
   no   |        yes
   +----+--------+
   v             v
SIGSEGV      buddy allocator: take a free page frame
(segfault)   zero it (anonymous page)
             write back the PTE (VA -> frame)
             return to user mode, re-run the instruction

<p align="center"><strong><u>内存分配原则</u></strong></p>

可以把分配原则分为用户态与内核态两层来看。

用户态层面,malloc 是 C 运行库(glibc 的 ptmalloc)提供的内存池管理器,它并不直接操作物理页。对于较小的请求,它从堆(heap)中切分空闲块,必要时通过 brk 设置程序断点或通过 sbrk 增量调整数据段来扩充堆[1][4];对于达到阈值的较大请求,则绕过堆,直接向内核申请一块私有的匿名映射(mmap,MAP_PRIVATE 配 MAP_ANONYMOUS)[1][3]。该阈值的初始值为 128 KiB(M_MMAP_THRESHOLD),并可通过 mallopt 调整。glibc 默认启用动态 mmap 阈值:释放大于当前阈值且不超过 DEFAULT_MMAP_THRESHOLD_MAX 的大块时,阈值上调至该块的大小,堆裁剪阈值同时变为动态 mmap 阈值的两倍;一旦用户显式设置 M_TRIM_THRESHOLD、M_TOP_PAD、M_MMAP_THRESHOLD 或 M_MMAP_MAX 中的任一参数,动态调整即被禁用[2]。

关键在于,无论走堆还是走 mmap,malloc 这一步通常只是预留虚拟地址,物理页要等到真正读写时才由缺页机制分配[1][8]。这与下一节的实证结果一致。

图 6:malloc 的三条分配路径;命中空闲链表时只在用户态完成,内核路径只预留虚拟地址,物理页留待首次访问。

malloc(size)
   |
   v
[1] free chunk available in tcache / bins?
      |-- yes --> return the cached chunk; user space only
      |
      no
      v
[2] size >= M_MMAP_THRESHOLD (initial 128 KiB)?
      |-- yes --> mmap: create an anonymous VMA
      |                (independent mapping, page aligned)
      |
      no
      v
[3] brk sets program break, sbrk increments it; extend [heap],
    then carve the chunk from the heap

paths [2] and [3] only reserve virtual address space;
physical pages are populated later, on first access

内核态层面,物理内存以页为单位分配,经由伙伴系统按 zone 取用空闲页框,并在缺页时建立映射[6][9]。系统默认开启启发式过量提交(overcommit_memory=0),允许虚拟地址空间的提交量适度超过物理内存与交换区之和,但明显越界的提交会被拒绝[8]。

<p align="center"><strong><u>调用 malloc 之后,系统做了什么</u></strong></p>

分两个层次说明。

用户态(glibc ptmalloc):malloc 先在已有的堆或 arena 中查找可满足请求的空闲 chunk,命中后更新空闲链表(fastbins、smallbins、unsorted bin、large bin,以及线程本地缓存 tcache),把指针返回给调用方。这些空闲链表是 ptmalloc 的内部实现,在 glibc 源码 malloc.c 的注释中有完整说明[10]。这一快速路径完全发生在用户态,不涉及系统调用,也不分配物理页;只有空闲块无法满足请求时,才会走到下一小节所述的内核路径[1]。

内核态:当堆空间不足需要扩张、或大块走 mmap 时,才会陷入内核。前者表现为 brk 系统调用调整程序断点(program break),后者表现为 mmap 创建一段新的虚拟映射。此时内核主要做的是扩展 VMA、预留虚拟地址,并不立即分配物理页[1][3][4]。

物理页在访问时才落地:第一次读写该虚拟页会触发缺页中断,内核经伙伴系统分配物理页框、清零、写回 PTE[3][7][9]。下面用真机实验验证这一点。

<p align="center"><strong><u>实证一:malloc 只预留虚拟地址,物理页按需落地</u></strong></p>

演示程序分配 200 MiB 虚拟空间,但先不写入;随后逐页触碰,并实时读取 /proc/self/status 的 VmSize 与 VmRSS。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// 读取 /proc/self/status,抽取 VmSize 与 VmRSS 两个字段,
// 用于观察 malloc 之后虚拟内存与物理内存是否同步增长。
static void show_mem(const char *tag)
{
    printf("%-26s", tag);
    fflush(stdout);

    FILE *fp = fopen("/proc/self/status", "r");
    if (!fp) {
        printf("(无法打开 status)\n");
        return;
    }

    char line[256];
    while (fgets(line, sizeof(line), fp)) {
        // 仅打印与本文论证相关的两个内存字段
        if (strncmp(line, "VmSize:", 7) == 0 ||
            strncmp(line, "VmRSS:", 6) == 0) {
            printf("%s", line);
        }
    }
    fclose(fp);
    fflush(stdout);
}

// 对第 idx 页做一次真实的写后读。
// 以 volatile 限定访问,确保编译器在任意优化级别(含 -O2)下
// 都会实际发出 load/store,从而真正触发缺页、分配物理页。
static void touch_page(volatile unsigned char *base, long page, int idx)
{
    volatile unsigned char *addr = base + (long)idx * page;
    *addr = 0xAB;
    unsigned char v = *addr;
    (void)v;
}

int main(void)
{
    // 取系统页大小,后面按页粒度触碰内存
    long page = sysconf(_SC_PAGESIZE);
    printf("page size = %ld bytes\n", page);

    show_mem("after start");

    // 预留 200 MiB 虚拟地址,但暂不写入任何字节
    size_t size = 200UL * 1024UL * 1024UL;
    void *p = malloc(size);
    // 打印指针使分配结果对外可见,避免被 -O2 整段优化掉
    printf("allocated %zu bytes at %p\n", size, p);
    volatile unsigned char *vp = (volatile unsigned char *)p;
    show_mem("after malloc(200MiB)");

    // 触碰远离 chunk 头部的第 100 页,观察 VmRSS 是否只增长一页
    // (跳过首部若干页,避开 glibc 为 chunk 元数据已建立的映射)
    touch_page(vp, page, 100);
    show_mem("after touch page #100");

    // 再触碰第 200 页
    touch_page(vp, page, 200);
    show_mem("after touch page #200");

    free(p);
    return 0;
}
page size = 4096 bytes
after start               VmSize:    2680 kB
VmRSS:    1612 kB
allocated 209715200 bytes at 0x76ad80fff010
after malloc(200MiB)      VmSize:   207484 kB
VmRSS:    1616 kB
after touch page #100     VmSize:   207484 kB
VmRSS:    1620 kB
after touch page #200     VmSize:   207484 kB
VmRSS:    1624 kB

VmSize 在 malloc 之后立刻由 2680 kB 涨到 207484 kB,说明虚拟地址已被预留;而 VmRSS 仅从 1612 kB 微增到 1616 kB,说明物理页几乎未分配。直到逐页触碰(跳过首部若干页,避开 glibc 为 chunk 元数据已建立的映射),VmRSS 才以每页 4 KiB 的节奏增长(1620、1624)。这组数据与前文的分析一致:malloc 预留的是虚拟地址,物理页按访问需求经缺页逐步分配[5]。

<p align="center"><strong><u>调用 free 之后,系统做了什么</u></strong></p>

同样分两层。

用户态(glibc ptmalloc):free 将对应 chunk 标记为空闲,并尝试与相邻的空闲块合并,放回相应的空闲链表。默认情况下,堆里释放出来的小块并不会立即归还给内核,而是留在用户态池里等待复用[2]。当堆顶的连续空闲内存增长到 M_TRIM_THRESHOLD(默认 128 KiB)这一最小规模时,free 会通过 sbrk 收缩堆顶、把内存归还系统,收缩后在堆顶保留 M_TOP_PAD 指定的余量[2]。堆只能在堆顶一端收缩,堆中部释放的空闲块无法经此路径归还;mmap 块没有这一限制,可独立释放回系统[2]。

内核态:并非所有 free 都会回到内核。对于走 mmap 的大块,free 会调用 munmap 解除那段匿名映射,把地址空间还给内核;而对于堆里的小块,free 通常只在用户态完成,不触发任何系统调用[2][3]。

图 7:free 的两条归还路径;堆块留在用户态空闲链表等待复用,mmap 块经 munmap 归还内核。

free(p)
   |
   v
how was the block obtained?
   |
   |-- heap block (small)
   |     mark the chunk free, coalesce with neighbours,
   |     return it to tcache / bins; no syscall
   |       |
   |       +-- if the free run at the heap top reaches
   |           M_TRIM_THRESHOLD (default 128 KiB):
   |           sbrk shrinks the heap top,
   |           memory returns to the kernel
   |
   |-- mmap block (large)
         munmap: tear down the anonymous VMA;
         the address range and its pages
         go back to the kernel

下面用 strace 追踪一个对照实验:先 malloc 一个 64 KiB 小块(预期走 brk 堆),再 malloc 一个 4 MiB 大块(预期走 mmap),然后分别 free。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// 演示 malloc 对小块与大块分别走 brk 扩展堆 / mmap 匿名映射,
// 以及 free 时是否触发 munmap。请用 strace 观察系统调用序列。
int main(void)
{
    printf("pid=%d\n", (int)getpid());

    // 64 KiB 小于 M_MMAP_THRESHOLD 初始值(128 KiB),预期走 brk 扩展堆
    void *small = malloc(64 * 1024);
    printf("malloc(64KiB) -> %p\n", small);
    memset(small, 0xAB, 64 * 1024);

    // 4 MiB 大于 M_MMAP_THRESHOLD 初始值,预期走 mmap 匿名映射
    void *big = malloc(4UL * 1024UL * 1024UL);
    printf("malloc(4MiB)  -> %p\n", big);
    memset(big, 0xCD, 4UL * 1024UL * 1024UL);

    // 读取一个字节并打印,使写入对外可见,确保分配不被优化掉
    printf("big[0]=0x%02X\n", ((unsigned char *)big)[0]);

    printf("free(64KiB)  # 堆块,预期不触发 munmap\n");
    free(small);

    printf("free(4MiB)   # mmap 块,预期触发 munmap\n");
    free(big);

    return 0;
}
pid=1675296
malloc(64KiB) -> 0x5e3b408c42b0
malloc(4MiB)  -> 0x71a9ac3ff010
big[0]=0xCD
free(64KiB)  # 堆块,预期不触发 munmap
free(4MiB)   # mmap 块,预期触发 munmap

# 与上述过程对应的 strace 关键调用(已滤除 glibc 内部 arena 的无关 mmap):
brk(NULL)                       = 0x5e3b408c3000
brk(NULL)                       = 0x5e3b408c3000
brk(0x5e3b408e4000)            = 0x5e3b408e4000
mmap(NULL, 4198400, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x71a9ac3ff000
munmap(0x71a9ac3ff000, 4198400) = 0

strace 显示,64 KiB 小块触发了一次 brk 调整程序断点;4 MiB 大块触发了 mmap(NULL, 4198400, ... MAP_PRIVATE|MAP_ANONYMOUS);释放 4 MiB 时出现了对应的 munmap(..., 4198400),而释放 64 KiB 小块时,跟踪记录里没有出现针对该块的 munmap,它留在了用户态空闲链表[1][2]。

再用 /proc/pid/maps 从地址空间层面印证:malloc(4MiB) 之后,地址空间里多出一段约 4 MiB 的匿名区;free 之后,这段匿名区消失,而 [heap] 堆区保持不变。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 打印当前进程地址空间中与堆、匿名映射相关的区域,
// 用于直观对照 malloc 之后 [heap] 与 mmap 匿名区的位置变化。
static void dump_ranges(const char *tag)
{
    printf("\n=== %s ===\n", tag);
    fflush(stdout);

    FILE *fp = fopen("/proc/self/maps", "r");
    if (!fp) {
        printf("(无法打开 maps)\n");
        return;
    }

    char line[512];
    while (fgets(line, sizeof(line), fp)) {
        // 堆区始终显示;匿名私有映射在 maps 中无文件路径
        // (既不含 '/' 也不含 '['),一并显示
        if (strstr(line, "[heap]") ||
            (strchr(line, '/') == NULL && strchr(line, '[') == NULL)) {
            printf("%s", line);
        }
    }
    fclose(fp);
}

int main(void)
{
    dump_ranges("before malloc");

    void *small = malloc(64 * 1024);
    void *big = malloc(4UL * 1024UL * 1024UL);
    // 打印指针使两次分配的结果对外可见,确保 mmap/brk 真正发生
    printf("small=%p big=%p\n", small, big);

    dump_ranges("after malloc (64KiB + 4MiB)");

    free(big);
    free(small);
    dump_ranges("after free");

    return 0;
}
=== before malloc ===
603ff7336000-603ff7357000 rw-p 00000000 00:00 0                          [heap]
779ba7e05000-779ba7e12000 rw-p 00000000 00:00 0
779ba7e35000-779ba7e38000 rw-p 00000000 00:00 0
779ba7e49000-779ba7e4b000 rw-p 00000000 00:00 0
small=0x603ff73378a0 big=0x779ba77ff010

=== after malloc (64KiB + 4MiB) ===
603ff7336000-603ff7357000 rw-p 00000000 00:00 0                          [heap]
779ba77ff000-779ba7c00000 rw-p 00000000 00:00 0
779ba7e05000-779ba7e12000 rw-p 00000000 00:00 0
779ba7e35000-779ba7e38000 rw-p 00000000 00:00 0
779ba7e49000-779ba7e4b000 rw-p 00000000 00:00 0

=== after free ===
603ff7336000-603ff7357000 rw-p 00000000 00:00 0                          [heap]
779ba7e05000-779ba7e12000 rw-p 00000000 00:00 0
779ba7e35000-779ba7e38000 rw-p 00000000 00:00 0
779ba7e49000-779ba7e4b000 rw-p 00000000 00:00 0

对比可见,malloc(4MiB) 之后新增了一段约 4 MiB 的匿名区(779ba77ff000-779ba7c00000),而 [heap] 大小未变;free 之后该匿名区消失,[heap] 仍保留。这与 strace 中 munmap 的出现、以及小块 free 不触及内核的结论相互吻合。

<p align="center"><strong><u>验证环境与方法说明</u></strong></p>

本文实验在 Linux 6.8.0(Ubuntu 24.04)、glibc 2.39、x86-64 环境下完成。

验证过程中还有一个值得记取的教训:若演示程序里 malloc 的返回值既未被读取、也未参与任何可观察的输出(例如仅以 (void) 强转丢弃),GCC 在 -O2 下可能将整个 malloc、memset、free 序列判定为无副作用而整体优化掉,表现为既无 brk 也无 mmap、VmSize 纹丝不动。因此本文给出的代码通过打印指针、并以 volatile 限定访问执行真实的逐页读写,使分配与缺页行为在任意优化级别下都对外可见。这一插曲也说明,涉及内存行为的结论必须经过真机验证,不能仅凭直觉或未经编译运行的代码片段下判断。

<p align="center"><strong><u>附:相关高频面试题与参考解析</u></strong></p>

以下题目提炼自各主流技术社区(CSDN、掘金、GeeksforGeeks 等)中操作系统与 Linux 内存管理方向被反复问到的问题,选取与本文内容直接相关的八题。参考答案依据与前文一致,标注了对应的官方资料与本文实验,便于对照核查。

问题一:什么是虚拟内存?为什么需要虚拟内存?

答题思路:先给出定义,再从隔离、扩展、简化编程三个维度说明动机,最后落到地址翻译机制(MMU 与页表)。面试官常追问虚拟地址空间与物理内存的关系,回答时避免陷入"虚拟内存就是交换分区"这类常见误解。

参考答案:虚拟内存是操作系统为每个进程提供的独立地址空间抽象,CPU 发出的虚拟地址经 MMU 查页表翻译为物理地址后才能访问内存[7]。其价值主要有三点:进程彼此隔离,一个进程无法直接踩到另一个进程的内存;地址空间可大于物理内存,配合按需调页与换页机制运行超出物理容量的程序;进程看到的是连续地址,不必关心物理页框在物理上是否连续[7][9]。交换分区只是这套机制的一个配套部件,二者不是同一概念。

问题二:malloc 分配的是虚拟内存还是物理内存?

答题思路:这是本文实证一直接回答的问题。先给结论,再给验证方法(读取 /proc/self/status 的 VmSize 与 VmRSS),最后点出背后的按需调页机制。

参考答案:通常只分配虚拟地址。glibc 的 malloc 通过 brk 或 mmap 向内核预留一段虚拟地址区间,物理页要到首次读写触发缺页中断时才分配[1][7][9]。本文实证一显示,malloc(200MiB) 之后 VmSize 立即增长约 200 MiB,而 VmRSS 几乎不动;逐页触碰之后 VmRSS 才以每页 4 KiB 的节奏增长。能给出 VmSize 与 VmRSS 这对指标的区分(前者度量虚拟地址空间大小,后者度量常驻物理内存[5]),比只背结论更有说服力。

问题三:malloc 的底层实现原理是什么?brk 和 mmap 如何分工?

答题思路:按"用户态内存池加两条内核路径"的框架回答:先讲 ptmalloc 在堆或 arena 中切块的快速路径,再讲空闲块不足时小块走 brk、大块走 mmap,最后补充分界阈值及其动态调整规则。

参考答案:malloc 是 glibc ptmalloc 提供的用户态内存池管理器。命中空闲块时只在用户态更新空闲链表(tcache、fastbins、smallbins、large bin 等),不进入内核[10]。空闲块不足时按请求规模分流:较小请求经 brk 调整程序断点(或由 sbrk 增量调整数据段)来扩充堆[1][4];达到 M_MMAP_THRESHOLD(初始 128 KiB)的请求改用 mmap 建立独立的匿名映射[1][3]。该阈值默认动态调整:释放大于当前阈值的大块时阈值上浮,用户显式设置相关参数后动态调整即被禁用[2]。本文实证二用 strace 抓到了 64 KiB 走 brk、4 MiB 走 mmap 的完整调用序列,可作为这一分工的直接证据。

问题四:free 之后内存会立即归还给操作系统吗?

答题思路:分小块(堆)与大块(mmap)两种情况回答,并解释 free 之后 RSS 常常不降的原因,这通常是面试官真正关心的落点。

参考答案:视分配路径而定。走 mmap 的大块,free 会触发 munmap,地址空间立即归还内核,本文实证二的 strace 记录与实证三的 /proc/pid/maps 对照都印证了这一点[2][3]。走 brk 堆的小块,free 通常只把 chunk 放回用户态空闲链表等待复用,不触发系统调用,因此 RSS 不会立刻下降[2];当堆顶连续空闲内存增长到 M_TRIM_THRESHOLD(默认 128 KiB)这一最小规模时,free 才经 sbrk 收缩堆顶、把超出保留余量的部分归还系统[2]。堆只能在堆顶一端收缩,堆中部释放的空闲块不会经此路径归还,这是与 mmap 块可独立释放的关键差异[2]。因此 free 之后 RSS 不降,多数情况下是分配器的缓存行为,不等于内存泄漏;需要主动归还时可调用 malloc_trim,它经 sbrk 或 madvise 释放堆中完整的空闲页,glibc 2.8 起作用于所有 arena[11]。

问题五:什么是缺页中断?处理流程是怎样的?

答题思路:按触发、检查、分配、恢复四个阶段叙述,指出它是在指令执行期间触发的异常而非普通外设中断,并区分合法缺页与非法访问。

参考答案:进程访问一个尚未建立物理映射的虚拟页时,CPU 在指令执行期间触发缺页异常并陷入内核。内核的处理路径大致是:先依据 VMA 判定这次访问是否合法,非法访问则向进程投递 SIGSEGV;合法时经伙伴系统分配一个空闲物理页框,匿名页按需清零,随后写回 PTE 建立映射,最后返回用户态重新执行被中断的那条指令[7][9]。按需调页正是建立在这一机制之上:物理页并非预先铺满,只在被访问时才落地,本文实证一中 VmRSS 随逐页触碰逐页增长即是直接证据。

问题六:为什么需要多级页表?

答题思路:先算一笔账,说明单级页表覆盖整个地址空间的代价;再说明多级页表按需建立下级页表的节省逻辑;最后落到 Linux 的层级抽象与折叠。

参考答案:以 32 位地址、4 KiB 页为例,单级页表需要约一百万个页表项才能覆盖整个 4 GiB 虚拟地址空间,每个进程一份,开销随进程数线性放大,而进程实际用到的虚拟区间通常远小于整个地址空间。多级页表只在某段虚拟区间被使用时才为其建立下级页表,未使用的一级表项不再占用下级空间,页表体积因此显著压缩[7][9]。Linux 把页表组织为 PGD、P4D、PUD、PMD、PTE 五级抽象,架构支持的级数不足时折叠处理;x86-64 未启用 LA57 时按四级工作[7]。

问题七:malloc 申请 1 GiB 一定成功吗?什么是内存过量提交?

答题思路:先区分预留虚拟地址与占用物理内存两个步骤,再引出 overcommit 策略与 OOM 的因果链,说明 malloc 返回非空不代表物理内存足够。

参考答案:不一定,结果取决于内核的过量提交策略与当时的可用资源。Linux 默认采用启发式过量提交(overcommit_memory=0),允许虚拟地址空间的提交量适度超过物理内存与交换区之和,因此申请远大于空闲物理内存的虚拟空间可能成功;明显越界的提交则会被拒绝,此时 malloc 返回 NULL[8]。若进程随后真的写满这些页面而物理内存耗尽,内核会启动 OOM Killer 选择并终止进程以回收内存[8]。回答此题的关键是把 malloc 成功与物理内存够用解耦:前者只代表虚拟地址预留成功。

问题八:如何验证一次 malloc 走的是 brk 还是 mmap?

答题思路:这是一道动手题,考察能否把理论落到工具上。给出 strace 与 /proc/pid/maps 两条验证路径,并能结合阈值说出预期结果,即为完整回答。

参考答案:常用手段有两种。其一,用 strace -e trace=mmap,munmap,brk 跟踪目标进程:走 brk 会看到程序断点被调整到一个更高的地址,走 mmap 会看到一条带 MAP_PRIVATE 与 MAP_ANONYMOUS 的匿名映射,其长度与请求量对应[3][4]。其二,查看 /proc/pid/maps:brk 分配的内存落在 [heap] 区间内,mmap 分配的内存表现为独立的匿名映射(无文件路径)[5]。预判依据是 M_MMAP_THRESHOLD(初始 128 KiB):小于它的请求通常走堆,达到它的请求走 mmap[1][2]。本文实证二与实证三正是用这两种方法交叉验证的。

<p align="center"><strong><u>参考来源</u></strong></p>

[1] Linux man-pages, malloc(3). https://man7.org/linux/man-pages/man3/malloc.3.html

[2] Linux man-pages, mallopt(3). https://man7.org/linux/man-pages/man3/mallopt.3.html

[3] Linux man-pages, mmap(2). https://man7.org/linux/man-pages/man2/mmap.2.html

[4] Linux man-pages, brk(2). https://man7.org/linux/man-pages/man2/brk.2.html

[5] Linux man-pages, proc_pid_status(5). https://man7.org/linux/man-pages/man5/proc_pid_status.5.html

[6] The Linux Kernel documentation, Physical Memory (Nodes 与 Zones 章节). https://docs.kernel.org/mm/physical_memory.html。该文档的 Pages、Folios、Initialization 等章节当前标记为 stub;文中关于节点、区域与伙伴系统的论述同时以内核源码 mm/page_alloc.c 作为直接依据。

[7] The Linux Kernel documentation, Process Addresses. https://docs.kernel.org/mm/process_addrs.html

[8] The Linux Kernel documentation, Overcommit Accounting. https://docs.kernel.org/mm/overcommit-accounting.html

[9] Mel Gorman, Understanding the Linux Virtual Memory Manager. 该著作基于 Linux 2.6,被 Linux 内核社区视为权威的 VM 文档(参见 LWN:https://lwn.net/Articles/27449)。文中关于伙伴系统、缺页处理与进程地址空间的基本原理在后续内核版本中仍保持一致的框架;PDF:http://www.csn.ul.ie/~mel/projects/vm/guide/pdf/understand.pdf(该 URL 当前返回 502,内容可经由 LWN 综述或内核文档交叉印证)。

[10] glibc 源码 malloc/malloc.c 头部注释(bins、tcache 等 ptmalloc 内部结构说明),官方镜像 bminor/glibc. https://github.com/bminor/glibc/blob/master/malloc/malloc.c(若因网络原因不可达,可改用各发行版附带的 glibc 源码包或 https://sourceware.org/git/?p=glibc.git 浏览)。

[11] Linux man-pages, malloc_trim(3). https://man7.org/linux/man-pages/man3/malloc_trim.3.html

[12] The Linux Kernel documentation, x86_64 Memory Management(4 级页表与 48 位虚拟地址布局;57 位对应 LA57 五级分页). https://docs.kernel.org/arch/x86/x86_64/mm.html