☰
malloc 与 sbrk 底层关系:手写最小内存分配器实战
2026/10/27 15:24:01 网站建设 项目流程

1. malloc 和 sbrk 是什么关系,先把层级摆正

很多人第一次看/proc/PID/smaps的时候都会愣一下:代码里malloc了 200MB,free掉之后RSS一点没降,堆段还是那么大。有人第一反应是内存泄漏,跑去用工具一通查,结果发现指针确实释放了。问题不在业务代码,而在分配器压根没打算把这块内存还回去。要把这件事讲透,绕不开一个常年躺在man手册角落里、被标注为 legacy 的东西:sbrk。

这篇文章我想做的事情很具体:把malloc和sbrk之间的上下游关系摆清楚,然后真的动手用sbrk写一个能跑起来的最小分配器,把中间那些平时被malloc挡住的细节——断点怎么移动、对齐怎么取整、头部信息怎么藏、为什么释放了还不还给系统——一个个拆开看。适合两类人:一是写过 C 但没直接碰过sbrk的,想补齐内存这一层认知;二是正在琢磨自己写内存池、想搞明白 glibc 那套东西怎么来的。如果你只想知道sbrk的语法,man 2 brk五分钟搞定;但如果你想知道它为什么这么设计、什么时候该用、什么时候碰一下就把程序搞崩,那往下看。

1.1 从一次 RSS 不下降的排查说起

先把现象描述清楚。一个常驻服务,启动后处理请求,堆占用爬到 300MB,请求量下来之后malloc_trim也调了,RSS还是 300MB 上下浮动。这时候打开/proc/PID/maps,会看到一个[heap]段,起始地址和结束地址之间的差值基本就是 300MB 出头。

这个[heap]段就是本文的主角。它的右边界有一个专门的名字,叫program break,中文一般叫“程序断点”。所有brk/sbrk干的事情,本质上就是移动这个右边界:往高地址推,堆变大;往低地址收,堆变小。malloc在底层向内核要内存,小块的走这条路径,大块的走mmap另开一个匿名映射。

理解了这个,就能解释那个现象了。堆是一整块连续区域,右边界只能整体移动。你free掉的是一块位于堆中间的内存,它上面的地址还有别的对象在用,右边界就下不去。于是这块内存就变成了堆内部的一个空洞,分配器把它记在自己的空闲链表里,等着下次有人申请同样大小的对象时复用。从进程外部看,RSS当然不会变。这不是 bug,是连续堆这个数据结构天生的限制。

1.2 调用链:应用代码 → malloc → brk/mmap → 内核

把整条链路拉直,大概是这样:

你的代码 └─ malloc(size) ├─ size < mmap_threshold ──> 主分配区(main_arena) │ └─ 堆不够了 ──> brk() / sbrk() 推进断点 └─ size >= mmap_threshold ──> mmap(MAP_PRIVATE|MAP_ANONYMOUS) └─ free 时 munmap 直接归还

几个关键点值得单独拿出来说。

第一,malloc不是系统调用。它是 libc 里的一个普通函数,运行在用户态,绝大多数时候压根不进入内核。你连续malloc一百次小对象,strace里可能一次brk都看不到,因为分配器手里还有存货。这个“存货”就是它维护的空闲链表和各类缓存。

第二,真正进内核的是brk和mmap。sbrk是brk的一层薄封装,严格来说sbrk也是 libc 函数,不是系统调用,只是它每次调用基本都会触发一次brk系统调用(glibc 内部有断点缓存优化,但语义上等价)。

第三,那条mmap_threshold的分界线很关键。glibc 默认是 128KB,而且这个值是动态调整的:你free掉一块mmap来的内存,阈值可能会往上抬,最高到 32MB,避免频繁mmap/munmap带来的系统调用开销。这条规则解释了另一个常见现象——同样大小的对象,有时候从堆里出,有时候单独映射,smaps里看起来完全不一样。

提示:判断一块内存是来自堆还是来自mmap,在/proc/PID/smaps里看映射名。带[heap]的是堆,空白名字或者/dev/zero (deleted)的匿名映射基本就是mmap来的。

1.3 什么场景下才真的该直接用 sbrk

说实话,日常业务代码里直接调sbrk的机会非常少。但有几种情况,它是有价值的。

自己写分配器或者内存池的时候。比如你有一个生命周期很明确的模块,启动时一次性申请一大块,运行期只做切分和回收,退出时统一释放。这种场景用sbrk拿一块连续内存,比反复malloc更可控,也更容易做内存水位监控。

做内存相关的教学 Demo 或者调试工具。想知道malloc到底要了多少内存,最直接的办法是自己用sbrk观察断点变化。

嵌入式或者资源极度受限的环境。有些精简 libc 的malloc实现就是几十行代码,底下一层直接对接sbrk。看懂sbrk你就看懂了整个分配器。

反过来说,业务代码里不要用。原因有三个:它不可移植(Windows 没有),它不是线程安全的(断点是进程级全局状态),以及最要命的一点——它会和malloc打架。最后这条后面单独讲,是踩坑重灾区。

2. 动手前必须搞清楚的底层概念

直接抄代码容易,但抄完不知道怎么改、出了问题不知道从哪儿查,那就白搭。这一节把几个必须先建立的概念过一遍。

2.1 program break 与堆的边界

每个进程的虚拟地址空间里,数据段(.data、.bss)之后有一段区域留给堆。内核为每个进程维护一个mm_struct,里面有个字段start_brk和brk,后者就是当前的断点位置。堆的有效范围是[start_brk, brk)。

sbrk(increment)的语义是:把断点加上increment字节,返回移动之前的断点地址。所以下面这段代码拿到的p就是新区域的起始地址:

void *p = sbrk(1024); /* 返回旧断点,也就是新拿到的这 1024 字节的起点 */

brk(addr)的语义是:把断点直接设成addr,成功返回 0,失败返回 -1。两者是同一件事的两种表达,sbrk是相对量,brk是绝对量。

有个细节很多人不知道:你sbrk(1024)之后,用sbrk(0)再查一次断点,会发现它不止涨了 1024。原因是内核会把断点按页对齐,页大小通常是 4096。所以你实际“占住”的地址空间是向上取整到页边界的量。这个行为在不同架构、不同内核版本上表现一致,可以放心依赖。

2.2 页对齐、16 字节对齐与“看起来多要了”

这里有两层对齐,别搞混。

第一层是页对齐,发生在内核侧。上面说了,断点会被对齐到页边界。这影响的是“地址空间占用量”,不影响你能用的字节数——你申请的 1024 字节一个不少,多出来的是对齐填充,你用不到但也不能用。

第二层是分配器对齐,发生在用户侧。C 标准要求malloc返回的指针必须满足max_align_t的对齐要求,在 x86-64 上通常是 16 字节。这个要求不是随便定的:long double、SSE 指令里的__m128、某些原子操作,都要求 16 字节对齐,不对齐轻则性能暴跌,重则直接SIGSEGV(比如movaps指令)。

所以我们自己写的分配器,返回的每个指针都必须是 16 的倍数。这就带来一个绕不开的开销:每个分配块都需要一个头部来记录大小,而头部本身也占用空间,还要参与对齐计算。

算一笔账。假设头部是 16 字节(一个size_t存大小,一个指针做空闲链表),请求n字节,实际需要:

need = ALIGN_UP(n + 16)

ALIGN_UP(x)就是把x向上取整到 16 的倍数,写法是(x + 15) & ~15UL。举几个例子看看利用率:

请求字节数加头部后对齐后实际占用内存利用率
8243225%
16323250%
24404850%
32484866.7%
64808080%
12814414488.9%
10241040104098.5%

这张表是理解“为什么小对象分配这么贵”的最好材料。你malloc(8)一百万个,实际吃掉 32MB,而不是 8MB,四分之三都是头部和对齐填充。glibc 为了解决这个问题,搞出了tcache、fastbin、按 16 字节分档的size class等等一整套机制,本质都是在头部开销和查找效率之间做权衡。

2.3 brk 与 sbrk 的接口差异

把两个接口放一起对比一下,方便选型:

特性brk(void *addr)sbrk(intptr_t incr)
参数含义目标断点的绝对地址相对当前断点的增量,可为负
返回值成功 0,失败 -1成功返回旧断点,失败返回(void *)-1
获取当前断点需要自己保存上一个值sbrk(0)直接返回,不改状态
错误码errno = ENOMEMerrno = ENOMEM
使用便利性要自己算绝对地址直接说“我要多少”

brk的返回值设计其实挺反人类的——它不告诉你新的断点在哪,只告诉你成没成功。如果想知道当前断点,还得自己维护一个变量。sbrk就顺手多了,返回旧断点等于把“新分配区域的起始地址”直接给你了,所以绝大多数示例代码都用sbrk。

标准层面的态度也值得提一句:brk和sbrk在 POSIX 里被标记为LEGACY(历史遗留),新代码推荐用mmap。但在 Linux 上它们会一直存在,glibc 内部也还在用,不用担心哪天消失。

3. 从零写一个能跑的 sbrk 分配器

理论说完了,上代码。这一节的目标是:写出一个逻辑完整、能编译、能跑、能释放、能用strace和/proc验证的最小分配器。它肯定打不过 glibc,但能让你把每个环节都亲手摸一遍。

3.1 最小验证:观察断点的移动

先不上分配器,就看看sbrk到底怎么动断点的。这段代码建议手敲一遍:

#include <unistd.h> #include <stdio.h> int main(void) { void *b0 = sbrk(0); printf("break before : %p\n", b0); void *p1 = sbrk(1024); printf("returned : %p\n", p1); printf("break after : %p\n", sbrk(0)); void *p2 = sbrk(4096); printf("returned : %p\n", p2); printf("break after : %p\n", sbrk(0)); return 0; }

编译运行,你会看到类似这样的输出(地址每次不同):

break before : 0x55a1c3e2b000 returned : 0x55a1c3e2b000 break after : 0x55a1c3e2c000 returned : 0x55a1c3e2c000 break after : 0x55a1c3e2d000

注意两点。第一,sbrk返回的值和调用前的断点完全一样,印证了“返回旧断点”的语义。第二,第一次要 1024 字节,断点涨了 0x1000(4096 字节),这就是页对齐的结果。

再看一个有意思的现象,验证“惰性分配”:

#include <unistd.h> #include <stdio.h> #include <string.h> #include <stdlib.h> int main(void) { size_t big = 256UL * 1024 * 1024; /* 256MB */ void *p = sbrk(big); if (p == (void *)-1) { perror("sbrk"); return 1; } printf("reserved %zu MB at %p\n", big >> 20, p); printf("press enter to touch memory...\n"); getchar(); memset(p, 0x5a, big); printf("touched, press enter to exit...\n"); getchar(); return 0; }

在两处暂停的时候分别看一眼VmRSS:

grep VmRSS /proc/<pid>/status

第一次看,RSS几乎没变;memset之后再查,RSS涨了 256MB 左右。原因很简单:sbrk只是改了虚拟地址空间的边界,物理页要等真正写入、触发缺页中断时才分配。这个机制叫按需分页(demand paging),对应的内核策略里还有个overcommit参数在管着——也就是说,sbrk成功不代表这 256MB 物理内存真的存在,超额分配在特定条件下会以OOM killer的形式找你算账。这一点在做大块预留的时候务必心里有数。

3.2 chunk 头部设计与对齐取整

现在开始写分配器。第一步是设计块的结构。

/* mini_alloc.c —— 用 sbrk 实现的最简分配器 */ #define _GNU_SOURCE #include <unistd.h> #include <stddef.h> #include <string.h> #include <stdio.h> #include <stdint.h> #define ALIGN_UNIT ((size_t)16) #define ALIGN_UP(x) (((x) + ALIGN_UNIT - 1) & ~(ALIGN_UNIT - 1)) typedef struct chunk { size_t size; /* 整块总大小(含头部),最低位标记占用状态 */ struct chunk *next_free; /* 仅在空闲时有效,用于串空闲链表 */ } chunk; #define IN_USE ((size_t)1) #define SIZE_MASK (~(ALIGN_UNIT - 1)) static chunk *g_free_list = NULL;

size字段里塞了一个标志位,这是个很典型的技巧,值得展开讲。

因为块大小必然是对齐单位的整数倍(这个在ALIGN_UP那步就保证了),所以size的低 4 位永远是 0。这 4 位空着浪费,不如拿来存状态。这里只用了最低位表示“已占用”,剩下 3 位还能存“来自mmap”“属于哪个线程的 arena”之类的信息。glibc 的真实实现就是这么干的:低三位分别是PREV_INUSE、IS_MMAPPED、NON_MAIN_ARENA,取值的时候用掩码把低位抹掉,取标志的时候用与操作。

glibc 还有一招更巧妙:真实 chunk 结构里有个prev_size字段,用来存前一个块的大小。但如果前一个块正在使用中,这个字段就完全没用,于是 glibc 把它“借”给前一个块的尾部数据用。这种空间复用是性能优化里很常见的手法,代价是代码复杂度上升。

配套的工具函数:

static size_t chunk_size(const chunk *c) { return c->size & SIZE_MASK; } static int chunk_in_use(const chunk *c) { return (c->size & IN_USE) != 0; }

3.3 扩展堆与分配路径

扩展堆用一个独立函数封装,方便加日志和错误处理:

static chunk *heap_extend(size_t need) { void *old = sbrk(0); if (old == (void *)-1) return NULL; void *got = sbrk((intptr_t)need); if (got == (void *)-1) return NULL; chunk *c = (chunk *)got; c->size = need | IN_USE; c->next_free = NULL; return c; }

这里先sbrk(0)拿一次当前断点,主要是为了日志和断言用(可以断言got == old)。生产代码里可以省掉。

分配函数分两条路径:先翻空闲链表,翻不到再扩堆。

void *my_malloc(size_t n) { if (n == 0) n = 1; size_t need = ALIGN_UP(n + sizeof(chunk)); if (need < n) /* 加法溢出 */ return NULL; /* 1. first-fit 扫空闲链表 */ chunk **pp = &g_free_list; while (*pp) { chunk *c = *pp; if (chunk_size(c) >= need) { *pp = c->next_free; c->size = chunk_size(c) | IN_USE; return (char *)c + sizeof(chunk); } pp = &c->next_free; } /* 2. 没有合适的,向内核要 */ chunk *c = heap_extend(need); if (!c) return NULL; return (char *)c + sizeof(chunk); }

几个设计决策解释一下。

为什么用 first-fit 而不是 best-fit?这里纯粹是图省事。first-fit找到第一个够大的就用,代码最短,命中率也还行。best-fit要遍历完整条链表找最贴合的,碎片少但慢。真实分配器基本都不这么线性扫,而是按大小分档,比如 16 字节一档,32 一档,64 一档,查表 O(1) 定位到对应的链表。这个分档思路就是 glibctcache和fastbin的雏形。

为什么返回(char *)c + sizeof(chunk)?头部和用户数据在同一块连续内存里,头部在前,数据在后。用户拿到的指针指向数据区,往前偏移sizeof(chunk)就能找回头部。这个技巧在free的时候要用到,也是所有分配器的通用做法——用户指针本身就能反推出元数据位置,不需要额外维护一张映射表。

need < n那个判断不能省。如果n接近SIZE_MAX,n + 16会溢出成一个很小的值,ALIGN_UP之后可能变成 0,然后你返回一个看起来合法的指针给用户。后果是灾难性的。这类整数溢出的坑在真实分配器里是必须防的。

3.4 释放与合并:为什么不能把内存还给系统

释放最简单,把块挂回空闲链表就行:

void my_free(void *p) { if (!p) return; chunk *c = (chunk *)((char *)p - sizeof(chunk)); c->size = chunk_size(c); /* 清掉 IN_USE 位 */ c->next_free = g_free_list; g_free_list = c; }

注意这里没有做任何合并。也就是说,你malloc(64)、malloc(64)、free第一个、free第二个,会得到两个独立的 64 字节空闲块。下次来个malloc(128),它俩加起来够,但任何一个单独都不够,于是还是得扩堆。这就是外部碎片。

要修也不难:free的时候不直接头插,而是按地址顺序插入链表,插入后检查前一个块和后一个块的尾部是否正好相邻,相邻就合并成一个更大的块。伪代码如下:

1. 找到链表中第一个地址大于当前块的节点 prev 2. 检查 addr(prev) + size(prev) == addr(cur) ? 合并 3. 检查 addr(cur) + size(cur) == addr(next) ? 合并

这个操作用关键点在于“地址相邻”,而地址能直接算出来,是因为堆是连续的。这正是sbrk这条路线最大的优势——块与块之间的地址关系是确定的,合并逻辑很干净。换成mmap那条路线,每块都是独立映射,地址不连续,就得靠更复杂的手段了。

但即便做了合并,还是有件事做不到:把内存还给内核。原因是断点只能整体往里缩,你缩之前必须保证堆顶那一大段全是空闲的。如果堆顶正好有一个长期存活的对象,底下再怎么合并也白搭。这就是文章开头那个现象的根本原因。

真实的glibc里有个malloc_trim(0),做的事情就是尝试从堆顶往下找连续空闲区域,能缩多少缩多少。但只要有任何一个块卡在顶部不动,它就只能干看着。所以“free之后RSS不降”在大块内存场景下几乎是无解的——除非这块内存走的是mmap,munmap一调直接还给系统,干净利落。这也是 glibc 把大块分配的阈值定在 128KB 的原因之一。

3.5 编译、strace 验证与 /proc 观测

写个 main 跑一下:

int main(void) { printf("break start: %p\n", sbrk(0)); int *a = my_malloc(sizeof(int) * 4); char *b = my_malloc(100); char *c = my_malloc(20); printf("a=%p b=%p c=%p\n", (void *)a, (void *)b, (void *)c); my_free(b); char *d = my_malloc(80); /* 应该复用 b 的空间 */ printf("d=%p\n", (void *)d); printf("break end : %p\n", sbrk(0)); (void)a; (void)c; (void)d; return 0; }

编译:

gcc -O0 -g -o mini_alloc mini_alloc.c ./mini_alloc

预期输出里,d的地址应该和b一致,证明空闲链表复用生效了。

再用strace看系统调用:

strace -e trace=brk ./mini_alloc 2>&1 | head -20

如果程序启动阶段 glibc 已经扩容过堆,你会看到开头几行brk之后就没有了——因为sbrk每次都会走brk,但brk把断点设到已经在页内的位置时,内核直接返回,不算新的映射。想看明显的扩堆动作,把main里的分配量加大到几 MB 再试一次。

观测地址空间的实时变化,推荐这个组合:

# 终端 1 ./mini_alloc_with_pause & # 终端 2 watch -n 0.5 'grep -E "VmRSS|VmSize" /proc/<pid>/status'

再配合cat /proc/<pid>/maps | grep heap,能看到[heap]段的起止地址真的在动。这种“改一行代码、看一眼映射”的循环,比读十页文档建立直觉快得多。

4. 踩坑记录与问题速查

这一段是我觉得最值钱的部分。上面那些代码抄下来能跑,但真往项目里放,大概率会在下面这几个地方摔跟头。

4.1 最致命的一个坑:和 malloc 混用

这个必须放在第一位说。在一个已经使用malloc的程序里直接调sbrk,几乎一定会出问题。

原因在于状态冲突。malloc内部维护着自己的断点记录(glibc 里叫brk相关的内部状态),它以为自己知道堆的边界在哪。你绕过它直接sbrk推了一大块,它不知道;等你free的时候,它可能认为自己可以收缩堆,一收缩就把你占的那块覆盖掉了。更隐蔽的情况是,sbrk让断点往前走了,但malloc的元数据还认为那段是空闲的,下次分配直接从那里切——你的数据被无声无息地改写,而且不崩,只是算错。

表现症状非常有迷惑性:程序跑得好好的,某个字段偶尔变成垃圾值,或者跑几个小时之后在完全无关的地方SIGSEGV。用valgrind查可能报“invalid write”,但堆栈指向的位置和你怀疑的地方八竿子打不着。

正确的做法有两条路:

路子一,完全隔离。如果你的程序里还要用malloc(大概率要,printf、fopen内部都在用),那就别碰sbrk,改用mmap拿独立的内存区域。mmap出来的地址和堆是分开的两段,互不干扰,munmap也能独立释放。这条路胜在安全,代价是多一次系统调用,且内存不连续。

路子二,完全不混。在程序最开始、任何 libc 函数调用malloc之前,把整个内存池用sbrk一次性拿到手,之后所有内存都从池子里切,一个字节都不给malloc。这条路要求严格,得保证所有第三方库也不用malloc(基本做不到),所以实际只适合极简环境或者教学 Demo。

注意:不要试图“先sbrk拿到手再还给malloc”。没有这个接口,malloc也没有办法接管外部内存。

4.2 常见问题速查表

现象可能原因排查手法处理方式
sbrk返回(void*)-1地址空间耗尽或RLIMIT_DATA限制perror看errno;ulimit -d查限制减小申请量;检查是否泄漏
断点涨得比申请量大很多页对齐导致的向上取整对比sbrk(0)前后差值属正常行为,不是 bug
free后RSS不降堆顶有存活对象,无法收缩/proc/PID/maps看[heap]范围改用mmap;或接受现状
数据偶尔被改写与malloc混用导致状态冲突全部改用mmap后复测隔离两种来源
多线程下随机崩溃sbrk是进程级状态,非线程安全用-fsanitize=thread跑一遍加互斥锁,或每线程mmap
分配器某次malloc特别慢空闲链表太长,线性扫描打点统计链表长度按大小分档,或改成best-fit
sizeof(chunk)不是 16 的倍数结构体成员排列或架构差异printf("%zu", sizeof(chunk))加_Static_assert静态检查

关于“多线程”这一条,值得再啰嗦两句。sbrk的底层是修改进程的brk字段,这是全局共享的。两个线程同时调sbrk,内核层面对于系统调用本身是原子的,不会把断点改坏,但你在用户态的分配器逻辑不是原子的——两个线程可能同时判断“空闲链表里没有合适的块”,然后都去扩堆,或者同时操作g_free_list的指针,把链表搞成环。现象是死循环或者SIGSEGV。所以要么加锁,要么每个线程用mmap拿自己的 arena。glibc 的解决方案就是后者,主分配区之外还有最多 8×核数个non-main arena。

4.3 几条实测有效的心得

第一,先在纸上或者注释里画一遍地址布局再动手写。我见过太多人写了半天free崩溃,最后发现是(char *)p - sizeof(chunk)里的sizeof算错了对象——本来该减头部大小,结果减成了sizeof(p)(指针大小)。用char *而不是void *做算术,就是为了避开这个坑,因为void *上的算术是 GNU 扩展,行为不一定符合你的直觉。

第二,给每个关键点加断言,而不是加printf。比如在my_free开头断言chunk_size(c) >= sizeof(chunk),在my_malloc返回前断言((uintptr_t)ret & 15) == 0。断言失败了会立刻停下,指向的问题位置精确;printf打完继续跑,错误会扩散到很远的地方才崩,调试成本高一个数量级。正式发布时用-DNDEBUG关掉就行。

第三,用strace -e trace=brk,mmap,munmap建立直觉。让你自己的分配器和 glibc 跑同样的 workload,对比strace输出。你会非常直观地看到:glibc 一次要一大块慢慢切,你的实现精确地要多少要多少。前者系统调用少但内部管理复杂,后者系统调用多但逻辑简单。这个对比能让你真正明白“分配器本质上是在系统调用开销和内存利用率之间做平衡”。

第四,malloc_stats()和mallinfo2()是被低估的调试工具。虽然它们统计的是 glibc 自己的分配器,但输出的system bytes(从内核拿的总量)和in use bytes(实际用掉的)两个数字对比,能立刻告诉你内存是花在用户对象上还是花在分配器的管理结构上。如果system bytes比in use bytes大出一倍以上,就要怀疑碎片了。

5. 从玩具到生产级,还差哪几步

上面那个两百行的分配器,已经具备了真实分配器的所有核心结构:块头、空闲链表、对齐计算、堆扩展。但要拿去做点正经事,还差下面这几块,我按投入产出比排个序。

5.1 大小分档,把查找从 O(n) 降到 O(1)

当前实现的瓶颈在my_malloc里那个while循环。想象一下服务跑了一个月,系统里有两万个大小不一的空闲块,每次分配都要从头扫,最坏情况扫完两万个。这时候malloc会慢到你以为自己写了个假的。

标准解法是按大小分档。把 16 到 1024 字节之间的块按 16 字节一档分成 64 个链表,1024 到 4096 按 64 字节一档分 48 个,再大的单独一棵树。分配时先算出请求落在哪个档,直接去对应链表拿头节点,O(1)。找不到再向上一档找,最多找几档,通常第一档就命中了。

这个优化带来的性能差异是数量级的。我印象很深的一次对比:同样一百万次小对象分配,线性链表版本跑了 4 秒多,分档版本 0.2 秒以内。这也解释了为什么所有主流分配器——glibc、jemalloc、tcmalloc、mimalloc——全都做分档。

5.2 内存归还是门艺术,不是技术

前面反复提到的“free了不还给系统”,本质上是连续堆的结构性缺陷。要真正解决,只有三条路。

路一,走mmap。大块内存独立映射,munmap直接归还,这是最干净的方案。缺点是mmap系统调用本身开销大(微秒级),小块用它会得不偿失。

路二,堆顶收缩。也就是malloc_trim那套逻辑:从断点往下找,找到连续空闲区域就sbrk(-n)。难点在识别“连续空闲区域”——你需要一个能按地址排序的数据结构记录所有空闲块,每次free都检查堆顶能不能缩。实现不难,但要小心别把还在用的块覆盖掉。

路三,接受现状。对于长期运行的服务,堆涨到稳态之后其实不会无限增长。分配器的空闲链表会兜住后续的请求,RSS维持在一个高点不动。很多线上服务就是这么跑的,RSS有个稳定上限,不是泄漏,别拿这个当借口去追查。

我的实际经验是:如果RSS在跑满一轮负载后稳定下来,就不再涨,那就不用管;如果每一轮负载都涨一点,那才是真的有问题,该查业务代码了。

5.3 什么时候真的该上 mmap

最后给一张决策表,方便你在写代码的时候快速判断:

场景特征推荐方案理由
大量生命周期接近的小对象普通malloc分配器的分档和缓存最擅长这个
单块大于 128KB 的缓冲区mmap+munmap独立映射,释放即归还
启动时确定大小的固定池mmap(MAP_ANONYMOUS)一次拿够地址连续,便于做下标和偏移计算
需要超大连续空间(如环形缓冲)mmap配合MAP_NORESERVE规避物理内存预留,按需提交
教学、调试、观察内存行为sbrk断点变化直观,能看清全貌
跨平台代码标准mallocsbrk和mmap都不是通用接口

写到最后想分享一个我自己的判断标准:当你开始质疑malloc的时候,先问自己是不是真的需要绕过它。绝大多数性能问题,出在算法和数据布局上,而不是分配器那几微秒的差异。sbrk这个接口真正的价值,不在于让你去替代malloc,而在于它把“程序是怎么从内核手里拿到内存”这件抽象的事,变成了一个可以打印出来、可以单步调试、可以看到地址变化的具体过程。把这个过程走一遍,你对内存的认知会和之前完全不同——后面再看smaps、看碎片、看OOM,都能 trace 到根上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询