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 都不能单独证明是否存在泄漏。常见判断顺序是:
- 查看对象数量或堆剖析结果是否持续增长。
- 对比
smaps_rollup中匿名私有页,而不是只看总 RSS。 - 检查进程是否存在多个 arena、线程栈、JIT、文件映射或共享内存。
- 观察空闲后内存是否被后续请求复用。
“RSS 没降”可能是分配器保留,也可能是真泄漏。两者需要不同证据,不能只凭一条 top 输出下结论。
7. 缺页、overcommit 与容器 OOM
malloc 返回非空指针,不等于已经为全部字节准备了物理 RAM。常见匿名内存路径是:
- 分配器获得虚拟地址。
- 程序第一次写入某页。
- CPU 触发缺页异常。
- 内核分配物理页、清零并建立页表映射。
因此只申请不触碰时,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. 排查顺序
- 明确观察的是申请量、虚拟地址、RSS、PSS 还是 cgroup usage。
- 用
strace判断是否发生brk、mmap、munmap或madvise。 - 用
maps定位地址所属区域,用smaps_rollup拆分常驻页。 - 在容器内同时检查
memory.current、memory.max、memory.events。 - 用堆 profiler 或业务对象计数证明泄漏,不把 RSS 保留直接等同于泄漏。
- 只有基线证明 arena、阈值或 trim 策略是瓶颈后,再调整 glibc tunables。
- 固定 glibc、内核和容器运行时版本复测;分配策略不是跨版本常量。
判断 Linux 内存问题,关键在于把分配器状态、虚拟映射、缺页和 cgroup 限制放在同一条证据链里。