malloc() 不是系统调用。它是用户态分配器接口:先从 glibc 已管理的空闲 chunk、tcache、bin 或 arena 中找空间,只有现有空间不够时,才可能通过 brk 或 mmap 向内核申请更多虚拟地址空间。

讨论“内存用了多少”时,至少要区分四个量:

  • 申请大小:程序传给 malloc 的字节数。
  • 虚拟地址空间:VmSize、VMA 和页表描述的地址范围。
  • 常驻内存:已经进入 RAM 的页面,通常从 VmRSS 或 smaps 观察。
  • 分配器持有内存:已经 free,但仍留在 glibc 中等待复用的 chunk。

把这四个量混成一个数字,就会得到“free 没用”“大于 128KB 一定走 mmap”“申请大块内存必须有连续物理页”等错误结论。

1. 从 malloc 到物理页的真实路径

一个常见路径是:

malloc(size)
  |
  +-- glibc tcache / bins / arena 中有可复用 chunk
  |      `-- 直接返回,不发生系统调用
  |
  `-- 现有空间不足
         +-- 扩展 arena:brk 或 mmap
         `-- 大块请求:可能直接 mmap
                |
                `-- 内核建立或扩展虚拟内存区域
                       |
                       `-- 程序首次写入页面时触发缺页
                              `-- 内核分配并映射物理页

这里没有“一次 malloc 对应一次系统调用”的关系。一次 brk 或 mmap 可以供后续很多次分配使用;一次 free 也可能完全停留在用户态。

brk、mmap 主要改变虚拟地址空间。匿名页通常在首次访问时才建立实际映射。用户态看到连续的虚拟地址,不要求底层物理页连续。

2. 用一个程序观察分配、触页和释放

下面的程序接收字节数,调用 malloc,每页写入一个字节,然后等待两次:第一次观察分配后的映射,第二次观察 free 与 malloc_trim 后的状态。

保存为 malloc_probe.c:

#define _GNU_SOURCE
#include <errno.h>
#include <malloc.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static size_t parse_size(const char *text)
{
    char *end = NULL;
    errno = 0;
    unsigned long long value = strtoull(text, &end, 10);

    if (errno != 0 || end == text || *end != '\0' ||
        value == 0 || value > SIZE_MAX) {
        fprintf(stderr, "invalid byte count: %s\n", text);
        exit(EXIT_FAILURE);
    }

    return (size_t)value;
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s BYTES\n", argv[0]);
        return EXIT_FAILURE;
    }

    const size_t bytes = parse_size(argv[1]);
    const long page_size_value = sysconf(_SC_PAGESIZE);
    if (page_size_value <= 0) {
        perror("sysconf");
        return EXIT_FAILURE;
    }
    const size_t page_size = (size_t)page_size_value;

    unsigned char *buffer = malloc(bytes);
    if (buffer == NULL) {
        perror("malloc");
        return EXIT_FAILURE;
    }

    for (size_t offset = 0; offset < bytes; ) {
        buffer[offset] = 0x5a;
        if (bytes - offset <= page_size) {
            break;
        }
        offset += page_size;
    }
    buffer[bytes - 1] = 0x5a;

    printf("pid=%ld ptr=%p bytes=%zu\n",
           (long)getpid(), (void *)buffer, bytes);
    puts("inspect allocation, then press Enter");
    fflush(stdout);
    getchar();

    free(buffer);
    const int trimmed = malloc_trim(0);
    printf("free complete; malloc_trim=%d\n", trimmed);
    puts("inspect again, then press Enter");
    fflush(stdout);
    getchar();

    return EXIT_SUCCESS;
}

编译并跟踪内存相关系统调用:

gcc -std=c11 -O2 -g -Wall -Wextra malloc_probe.c -o malloc_probe

strace -f -e trace=brk,mmap,munmap,madvise \
  ./malloc_probe $((64 * 1024 * 1024))

程序第一次暂停时,另开终端,把输出中的 PID 填入:

PID=<pid-from-program>

grep -E '\[heap\]|rw-p' /proc/$PID/maps
grep -E 'Vm(Size|RSS|Data)' /proc/$PID/status
cat /proc/$PID/smaps_rollup

按 Enter 后,程序执行 free 和 malloc_trim(0),再次比较相同文件。重点看现象,不要预设结果:

  • 指针可能落在 [heap],也可能落在独立匿名映射。
  • 首次写入每一页后,RSS 通常明显增长。
  • free 后可能出现 munmap 或 madvise,也可能没有相关系统调用。
  • malloc_trim 返回 1 只表示释放了部分内存,不承诺 RSS 回到分配前数值。

再分别测试较小和较大的请求:

./malloc_probe $((64 * 1024))
./malloc_probe $((64 * 1024 * 1024))

不要根据这两次结果推导永久阈值。glibc 会根据历史分配、环境变量、tunables、架构和版本改变策略。

3. brk 和 sbrk 的边界

program break 是传统进程堆的末端。Linux 的 brk 系统调用尝试改变这个边界;sbrk 是 libc 提供的历史接口,通过相对增量访问 program break,并不是一个独立的 Linux 系统调用。

main arena 在合适条件下可以扩展 program break。扩展只增加堆顶,因此中间被释放的 chunk 不能靠降低 break 单独交还内核。分配器通常把它留在 bin 中复用,或对完全空闲的页面使用其他回收手段。

不要在一个仍使用 malloc、printf、线程库或其他 libc 组件的普通程序里手工移动 brk。program break 是进程级共享状态,绕过分配器修改它会破坏 glibc 对堆边界和 chunk 的记录。需要观察时,使用 strace、/proc/<pid>/maps 和分配器统计,而不是自己实现一半 malloc。

4. mmap 提供独立虚拟映射

匿名 mmap 可以创建与传统 [heap] 分离的虚拟地址区域。glibc 常在两类场景使用它:

  • 大块分配直接获得独立映射。
  • 为额外 arena 准备 heap 区域,降低多线程争用。

一个独立映射通常可以在释放时通过 munmap 整体撤销,所以 RSS 和虚拟地址空间更容易下降。但代价包括更多 VMA、页表操作、TLB 影响和系统调用。分配器必须在复用收益与及时归还之间权衡。

mmap 返回的虚拟地址可以连续,底层物理页仍可以分散。用户进程申请 64MB 匿名内存,不要求伙伴系统找到连续 64MB 物理块。把用户态 malloc 与内核 kmalloc 的物理连续性约束混在一起,是常见误解。

5. 现代 glibc 不只有 brk 与固定阈值

把 glibc 简化为“小于 128KB 走 brk,大于 128KB 走 mmap”已经不够准确。实际决策还受这些状态影响:

  • 当前线程的 tcache 是否已有合适 chunk。
  • arena 的 fastbin、small bin、large bin、unsorted bin 和 top chunk 是否可用。
  • 当前线程绑定哪个 arena,以及 arena 是否需要扩展。
  • mmap_threshold、trim_threshold、top_pad、arena_max 等 tunable。
  • glibc 版本、架构、历史分配和释放模式。

glibc 文档仍把 128KiB 列为 mmap_threshold 的初始默认值,但默认会动态调整。设置 glibc.malloc.mmap_threshold 或对应 mallopt 参数后,动态行为也会改变。因此下面的判断都不可靠:

  • “129KB 必然出现一次 mmap。”
  • “64KB 永远来自 [heap]。”
  • “同一个程序在另一台机器上一定使用相同路径。”

可以用 GLIBC_TUNABLES 做受控实验,例如限制 arena 数量:

GLIBC_TUNABLES=glibc.malloc.arena_max=2 \
  ./malloc_probe $((64 * 1024 * 1024))

这不是通用生产配置。降低 arena 数可能减少虚拟内存和碎片,也可能增加多线程锁竞争。先用真实并发、RSS、延迟和分配热点做基线,再改参数。

6. 为什么 free 后 RSS 不一定下降

free(pointer) 的直接含义是把 chunk 归还给分配器。后续动作取决于 chunk 来源和周围布局:

  • tcache 或 bin 接收 chunk:没有系统调用,RSS 通常不变。
  • 直接 mmap 的大块被释放:通常执行 munmap,映射与 RSS 更容易下降。
  • arena 顶部形成足够大的空闲区:分配器可能收缩 program break。
  • arena 内出现整页空闲:分配器可能用 madvise 让内核回收页面,同时保留虚拟地址范围。
  • 空闲块被仍在使用的 chunk 隔开:产生碎片,暂时无法整段归还。

malloc_trim(0) 只是一次尽力回收请求。返回值和 RSS 都不能单独证明是否存在泄漏。常见判断顺序是:

  1. 查看对象数量或堆剖析结果是否持续增长。
  2. 对比 smaps_rollup 中匿名私有页,而不是只看总 RSS。
  3. 检查进程是否存在多个 arena、线程栈、JIT、文件映射或共享内存。
  4. 观察空闲后内存是否被后续请求复用。

“RSS 没降”可能是分配器保留,也可能是真泄漏。两者需要不同证据,不能只凭一条 top 输出下结论。

7. 缺页、overcommit 与容器 OOM

malloc 返回非空指针,不等于已经为全部字节准备了物理 RAM。常见匿名内存路径是:

  1. 分配器获得虚拟地址。
  2. 程序第一次写入某页。
  3. CPU 触发缺页异常。
  4. 内核分配物理页、清零并建立页表映射。

因此只申请不触碰时,VmSize 可以很大而 RSS 很小。不要通过读取未初始化的 malloc 内存验证“共享零页”;在 C 语言层面,这种读取本身没有可靠语义。应像示例一样写入每页,或直接实验匿名 mmap。

Linux overcommit 策略由 vm.overcommit_memory 等参数控制:

  • 模式 0:启发式判断。
  • 模式 1:总是允许 overcommit。
  • 模式 2:按严格提交限制检查。

即使主机允许 overcommit,容器仍可能先碰到 cgroup 内存上限。在 cgroup v2 环境中,至少检查:

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

memory.events 中 oom 或 oom_kill 增长,说明问题来自 cgroup 内存压力。此时“主机还有空闲内存”并不能排除容器 OOM。

8. 一套够用的观测方法

不要只盯一个指标。下面几组工具回答的问题不同。

系统调用:分配器是否向内核扩展

strace -f -e trace=brk,mmap,munmap,madvise ./malloc_probe 67108864

它能看到地址空间扩展与归还,但看不到 tcache、bin 内部复用。

地址空间:指针属于哪个映射

cat /proc/$PID/maps
pmap -x $PID

[heap] 是 program break 管理的传统堆。没有标签的匿名映射可能来自额外 arena、直接 mmap、线程栈或其他运行时组件,不能只凭一行 maps 判定来源。

常驻页:哪些页面真正进入 RSS

cat /proc/$PID/smaps_rollup
grep -E 'Vm(Size|RSS|Data|Swap)' /proc/$PID/status

关注 Rss、Pss、Private_Dirty、Anonymous 和 Swap。共享库页面、文件缓存和匿名堆的回收方式不同。

缺页:首次触页成本

perf stat -e page-faults,minor-faults,major-faults \
  ./malloc_probe 67108864

匿名内存首次写入通常产生 minor fault;major fault 往往涉及需要 I/O 的文件页或 swap。计数仍需结合映射类型解释。

9. 不要把 malloc 与内核分配器混为一谈

用户程序不能直接调用 kmalloc、slab 或 vmalloc。这些是内核内部接口:

  • kmalloc 适合内核小对象,并满足特定物理连续性要求。
  • slab/SLUB 为重复分配的内核对象提供缓存。
  • vmalloc 提供连续内核虚拟地址,底层物理页可以不连续。
  • 页分配器负责实际物理页,页表把它们映射到用户虚拟地址。

用户态 malloc 通过 brk/mmap 管理虚拟地址,内核随后按页处理缺页。文章或监控里看到 malloc(1GB),不能据此推断内核正在申请一个连续 1GB 物理块。

内核内部结构会随版本变化。排查用户态 RSS 时,稳定接口是系统调用、/proc、cgroup 文件和分配器文档,不应依赖从某个旧内核复制出的 struct mm_struct 字段布局。

10. 五个常见问题

Q1:free 后 RSS 仍然很高,是不是泄漏?

不一定。先确认对象是否仍可达、堆 profile 是否持续增长,再判断是 tcache/bin 保留、arena 碎片、线程栈还是其他匿名映射。

Q2:怎么知道一次分配走了 brk 还是 mmap?

用 strace 看系统调用,用 /proc/<pid>/maps 确认指针所在映射。一次分配若直接复用已有 chunk,可能两者都看不到。

Q3:大于 128KB 是否一定使用 mmap?

不是。128KiB 是 glibc 文档中的初始默认阈值,不是固定边界。动态调整、tunables、arena 状态和版本都会改变选择。

Q4:为什么 malloc 成功,写内存时进程却被杀?

常见原因是 overcommit 允许先获得地址空间,首次触页后才产生真实内存压力;容器还可能触发 memory.max 和 cgroup OOM。

Q5:为什么 maps 里没有或几乎不增长 [heap]?

程序可能主要使用直接 mmap、额外 arena、其他 allocator,或尚未扩展 program break。[heap] 不是进程全部动态内存的总览。

11. 排查顺序

  1. 明确观察的是申请量、虚拟地址、RSS、PSS 还是 cgroup usage。
  2. 用 strace 判断是否发生 brk、mmap、munmap 或 madvise。
  3. 用 maps 定位地址所属区域,用 smaps_rollup 拆分常驻页。
  4. 在容器内同时检查 memory.current、memory.max、memory.events。
  5. 用堆 profiler 或业务对象计数证明泄漏,不把 RSS 保留直接等同于泄漏。
  6. 只有基线证明 arena、阈值或 trim 策略是瓶颈后,再调整 glibc tunables。
  7. 固定 glibc、内核和容器运行时版本复测;分配策略不是跨版本常量。

判断 Linux 内存问题,关键在于把分配器状态、虚拟映射、缺页和 cgroup 限制放在同一条证据链里。

参考链接