在Linux里折腾内存管理这摊事,brk()绝对是老资格的系统调用。很多写C的开发者每天都在跟它打交道——你随手malloc(100),glibc底层十有八九就是调brk去把堆顶往上挪。说一句比较直接的话:不理解brk,你对进程内存的理解就缺了最基础的一块拼图。
这篇文章我会把brk()从用户态到内核态的完整路径拆开讲,包括它到底管的是哪块内存、内核里是怎么实现的、为什么在某些场景下它比mmap更“划算”又在哪些场景下该躲着它走。内容会涉及可以直接上手的实验步骤、常见问题的排查思路,以及我在实际项目中踩过的坑。适合刚接触Linux内存管理的开发者,也适合那些已经在用malloc但一直没搞懂堆是怎么长出来的朋友。
1. 先把brk放到内存全景图里定位
1.1 用户态内存和物理内存之间隔着什么
很多人一上来就盯着“内存”两个字,容易把用户态看到的内存和物理内存混为一谈。实际上一个进程能访问的内存空间是虚拟地址空间,这段空间是内核给它画出来的一张“地图”,每个地址都要通过页表才能映射到真正的物理内存页。
Linux的进程虚拟地址空间在x86-64下通常长这样:从低地址往上依次是代码段、数据段、BSS段、堆(heap),然后是一大段未映射区域,再往上是被用来放共享库和mmap映射的区域,最后是栈。堆是向上增长的,栈是向下增长的,两边往中间挤。
brk()管理的就是这个“堆”。它的名字很直白,brk是“break”的缩写,历史上指的是程序断点(program break),也就是堆顶的位置。你调用brk,本质上就是告诉内核:我要把堆顶往上抬一点,或者往下放一点。往上抬就是分配,往下放就是释放。
这个设计非常古老,从Unix时代就存在了。它的核心思想是:堆是一段连续增长的地址区域,只要把边界指针移动一下,就能完成内存的分配和回收,不需要维护复杂的数据结构。对一个进程来说,堆是独占的,不需要和其他进程协调,所以它简单、直接、高效。
1.2 brk和sbrk、malloc的关系
你可能会在代码里看到三个函数:brk()、sbrk()、malloc()。它们的关系经常让人犯迷糊,我画个简单的分层:
- brk(void *addr):系统调用,直接把堆顶设置到指定地址。
- sbrk(long increment):基于brk封装出来的调整函数,给一个增量,返回调整前的堆顶地址。内部实际上是算出新的目标地址,然后调用brk。
- malloc系列函数:glibc内存分配器(ptmalloc)的实现。它不直接暴露brk给普通开发者,但小内存分配时,它内部会通过brk/sbrk向内核申请堆空间。
用一句话总结:brk是内核提供的接口,sbrk是它的一层外衣,malloc是站在sbrk肩膀上的分配器。你平时写代码不会直接调brk,但几乎每个malloc背后都有它的影子。
| 层级 | 接口 | 作用 |
|---|---|---|
| 内核 | brk系统调用 | 调整堆顶地址 |
| 库函数 | sbrk | 按增量调整堆顶 |
| 分配器 | malloc/free | 在堆里管理已分配/空闲块 |
这里有个很多人会忽略的点:malloc向用户返回的地址,并不是brk调整后立即可用的堆顶地址。malloc从堆里划出一块内存之前,需要先把这块区域纳入进程的地址空间,而brk只是改变了“边界”。真正让物理内存有实际意义的,是后续访问时触发的缺页异常。
1.3 brk分配的内存和mmap内存的关键差异
同样是分配匿名内存,Linux还有另一个系统调用mmap。malloc在分配大块内存时会走mmap,分配小块内存时走brk。那为什么要把事情搞这么复杂?因为两种方式的行为有本质区别。
brk调整堆顶,堆是线性增长的,分配的内存地址是连续的。释放时,只有释放的是堆顶附近的内存,内核才能通过下移brk收回空间。如果内存块在堆中间被释放了,那么这块“空洞”暂时没法归还给内核,只能留在堆里等后续再使用。
mmap则是每次在虚拟地址空间中找一块空闲区域做映射,它分配的内存不要求和其他区域连续,释放时可以整体解除映射,把整段虚拟地址空间归还给进程地址空间。但mmap每次调用都是完整的系统调用,还要创建新的VMA(虚拟内存区域)节点,涉及更多的内核处理开销。
这就是malloc的典型策略:小块用brk堆,减少系统调用次数和VMA开销;大块用mmap,方便释放后立即归还地址空间。glibc的具体阈值后面会讲,但总的原则是速度和灵活性的权衡。
2. brk在内核里的实现机制
2.1 一次brk调用内核里发生了什么
当你在用户态调用brk时,最终会进入内核的sys_brk处理流程。整个处理过程可以按这几个阶段理解。
第一步,内核读当前进程的mm结构中的brk字段,这个值就是当前堆顶地址。然后它会检查用户传进来的新地址是否合法。什么算合法?新的brk值不能低于原来数据段结束的位置,否则就是想把堆往下压到代码段里去了,这肯定不允许。同时新地址还不能超过一些限制,比如RLIMIT_DATA资源限制。
第二步,如果新brk比原来的大,说明是要扩展堆。内核需要确认从当前堆顶到新堆顶这段虚拟地址范围内,没有已经被映射的区域。如果这段范围和别的映射冲突了,就只能拒绝。这就是为什么堆和mmap区域之间的空隙要保留——它们可能会碰到。
第三步,确认没问题后,内核要把这段地址区间加入进程的VMA管理。如果原来已经有一个匿名VMA紧挨着这段区域,直接扩展这个VMA就行;如果没有,就新建一个VMA。这一步的关键操作是vma_merge,内核会尝试把新扩展的部分和已有的VMA合并,减少VMA数量。VMA数量越少,后续页表操作和查找效率越高。
第四步,如果新brk比原来的小,说明是收缩。内核会遍历堆末尾的VMA,看能否把它们截断或删除。但这里有个限制:如果释放的这段区域里有VMA仍然存在,是没法直接删掉的。简单说,brk只能从堆顶往下释放,不可能从堆中间挖掉一块还给你。
整个brk系统调用的执行路径其实很短,这也是它高效的原因之一。它不像mmap那样要处理复杂的查找、映射关系。但是,brk只是改变了虚拟地址空间的布局,这个过程中可能不会立即分配物理内存——真正的物理内存分配要等到你访问那些地址时,由缺页异常来触发。
2.2 do_brk_flags的核心逻辑
在内核源码里,sys_brk最终会调用do_brk_flags函数。这个函数做的事情可以用伪代码表达:
static int do_brk_flags(... addr, len, flags) { // 检查地址范围是否合法 // 处理可能发生的VMA合并 // 检查mmap区域的交叉冲突 // 调用vma_merge或直接创建新VMA // 返回新的brk地址或错误码 }有几个细节值得注意。do_brk_flags处理的是线性地址范围,它要求用户态的brk调用传进来的长度会按页对齐处理。你sbrk(1)只申请1字节,内核实际上会把堆顶对齐到下一页边界,一次性给你一页的映射。也就是说,brk的粒度是页,只是用户态感受不到这个对齐过程。
另一个细节是VMA的合并判断。内核在扩展堆时,会重点检查新扩展区域的前一个VMA是否就是堆的VMA,并且两者属性是否一致。如果一致,直接扩展原VMA的end,不需要创建新节点。千万不要小看这个合并操作,如果没有它,频繁的brk调用会导致VMA数量爆炸,一个进程几万个VMA都有可能。
2.3 为什么brk按页扩展而不是按字节
这个问题我当年想了好久:既然用户只申请了10个字节,内核却给我一页映射,这不是浪费吗?
答案是:这不是浪费,而是硬件架构决定的必然选择。CPU的MMU(内存管理单元)以页为单位进行地址转换,典型的页大小是4KB。如果一个进程可以精确映射任意字节,页表里就得记录海量的“半个页”映射,这会带来灾难性的性能问题。
所以内核按页分配物理内存,按页建立页表项,按页触发缺页异常。那用户申请的10个字节和内核分配的一页之间,剩余的字节哪去了?答案是它们在malloc分配器的管理范围内。malloc会把这一页切成多个小块,分给后续的多次申请。这样一来,一次brk系统调用+一次物理页分配就能服务多次malloc,系统调用开销被摊薄了。
注意:brk调用本身只调整虚拟地址,未必立刻触发物理内存分配。如果你申请了100MB但从不访问,实际物理内存可能一点都没消耗,这算是虚拟内存的一个“福利”。
2.4 释放内存时brk为什么“不积极归还”
这一节可能是全文最容易踩坑的地方——很多人发现程序free了内存,但htop显示RSS(常驻内存)没降下去,于是怀疑内存泄漏。实际上很可能不是泄漏,而是brk的机制决定了很多空闲块没法归还。
brk的特质是只能从堆顶收缩。假设你malloc了三块内存,分别位于堆的低、中、高三个位置,然后你把中间那块free了。此时堆顶还在最高位置,中间的空洞永远无法通过brk归还。只有把最高处的那块也free了,并且malloc决定收缩堆,brk才能往下走。
而且还有一个细节:glibc的malloc并不一定在free后就立刻调brk收缩堆。频繁的收缩和扩展反而会产生更大的性能损耗,分配器倾向于继续持有这些空闲内存,供后续分配复用。它是把堆当“缓存”来用的。
如果你想强制让堆还给内核,可以调用malloc_trim函数,或者借助mallopt设置M_TRIM_THRESHOLD参数。这个参数决定了空闲内存达到多少阈值时才自动修剪堆。默认值不低,所以很多场景下你观察到的RSS“居高不下”其实是一种正常现象。
3. 实操:动手观测brk的完整行为
3.1 用strace跟踪malloc背后的系统调用
纸上谈兵没用,直接上一段代码来验证。下面这个程序分配了4096字节,然后释放,我们来跟踪它到底调了什么系统调用。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { printf("before malloc, current brk: %p\n", sbrk(0)); void *p = malloc(4096); printf("after malloc, current brk: %p, ptr=%p\n", sbrk(0), p); free(p); printf("after free, current brk: %p\n", sbrk(0)); return 0; }编译运行,再用strace跟踪:
gcc -o brkdemo brkdemo.c strace -e brk,mmap ./brkdemo你会看到类似这样的输出:
brk(NULL) = 0x... brk(0x...) = 0x...第一行brk(NULL)是sbrk(0)的实现,作用是查询当前堆顶。第二行brk(新地址)才是真正的堆扩展。如果程序分配的是小块内存,你能清楚的看到brk被反复调用;如果改成malloc(2MB),你大概率看到的是mmap而不是brk。
注意:strace默认会展开库函数调用,如果你想看到具体的系统调用序列,养成用-e参数精准过滤的习惯。这比盯着满屏的syscall输出高效得多。
3.2 用/proc/self/maps观察堆的真实地址
系统调用层面看不直观,我们可以直接看进程的虚拟地址空间布局。在程序里打印/proc/self/maps,或者直接在shell里对正在运行的程序执行cat /proc/ /maps。
关键要看带[heap]标识的那一行。它长这样:
55d4c1a00000-55d4c1b00000 rw-p 00000000 00:00 0 [heap]中括号里的[heap]表示这段区域是堆。范围的前半部分是堆起始地址,后半部分是当前brk的位置。运行上面的示例程序,在malloc前后分别查看maps,你能看到[heap]这一行的后边界在发生变化。
我建议你自己做这个实验,会非常直观。先把300MB内存,再看maps,能看到[heap]边界哗一下抬上去;然后free掉,发现边界不一定降下来——这就是前面讲的brk“不积极归还”机制。
还有一个值得尝试的观察方式:/proc/ /status里的VmPeak、VmSize等字段。这些字段反映的是虚拟地址空间的峰值和当前值,能在长时间运行的进程里帮你判断堆到底膨胀到什么程度。
3.3 用mallopt参数改变malloc的路径选择
glibc的malloc默认有一个阈值,超过这个阈值的大块分配会优先走mmap。这个阈值可以用mallopt函数的M_MMAP_THRESHOLD参数动态调整。
#include <malloc.h> #include <stdio.h> #include <stdlib.h> int main(void) { mallopt(M_MMAP_THRESHOLD, 1024 * 1024); // 阈值设为1MB void *p = malloc(2 * 1024 * 1024); // 2MB,超过阈值 printf("allocated at %p\n", p); getchar(); // 挂住进程,方便查看maps free(p); return 0; }在程序挂住的时候去查看maps,能看到2MB的分配是单独的一段匿名映射,不在[heap]范围内。这证实了malloc的策略转变。反过来,如果把mallopt阈值调到100MB,再分配2MB,你会发现它走了brk。
glibc非常聪明的一点是它会动态调整默认阈值。初始默认阈值通常比较小,但如果你频繁地在“略高于阈值”的尺寸上分配又释放,分配器会自动调高阈值,尝试用brk来降低mmap的开销。这个机制的本意是优化性能,但也可能让堆变得很大,需要你心里有数。
3.4 地址空间膨胀时的观察方法
堆膨胀问题通常不会立刻显现,它会随着程序的运行一点点积累。要快速发现这类问题,可以用下面这个方法:
- 记录程序在空闲状态下的[heap]边界。
- 运行一轮主要业务逻辑。
- 再查看[heap]边界是否显著增长。
如果一轮业务逻辑后堆顶就大幅度上升,且怎么free都不下降,那十有八九存在“堆顶钉子户”——某个长生命周期对象恰好落在堆的高地址处,导致它以下的任何空闲块都无法归还。
我在实际项目中遇到过一个更隐蔽的情况:程序有一个全局的缓冲池,里面保存了很多大小不一的对象。由于这些对象要常驻,导致brk堆顶永远停在高位,即便其他业务内存全部释放,RSS也降不下来。后来改用mmap分配常驻大对象,堆顶回归正常,问题才解决。
4. brk相关问题与排查实录
4.1 为什么堆越用越大,free了也不降
这个问题高频出现。最常见的原因是堆中高地址处存在尚未释放的对象。brk只能释放堆顶附近的连续空间,只要高地址的对象不释放,堆顶就无法下移。
第二个原因是malloc持有top chunk。top chunk是堆顶最大的空闲块,malloc在堆顶没有足够空间时会对它进行扩展;free时它也会优先把释放的内容并入top chunk。glibc有一个阈值控制,只有当top chunk大到一定程度,并且空闲时间足够长时,系统才会真正收缩堆。这个阈值就是M_TRIM_THRESHOLD。
排查的时候,不能光看free有没有调用,还要看堆顶附近有没有常驻对象。建议的做法是:查看进程的maps堆边界,结合代码里是否有长期存活的大对象,判断是机制问题还是编码问题。
4.2 brk分配失败的典型场景
brk调用本身很少失败,但也不是绝对不会。最常见的失败原因是RLIMIT_DATA限制。这个资源限制在Linux上默认可能很大,但某些安全策略或容器配置会把它设置得比较小。一旦堆顶增长超过这个限制,brk就会返回错误。
另一个原因是堆顶和mmap映射区相遇了。虽然两者之间有广阔的空隙,但如果你在main函数外声明了巨大的全局变量,或者大量使用mmap,堆和mmap区域可能逐渐逼近。这时候brk没法再扩展,malloc会转而尝试mmap或直接失败。
补充一个容易忽略的点:brk的地址空间是按页管理的,所以即使你的malloc很小,brk也可能在页边界处扩张。频繁的小块分配可能导致堆的虚拟空间快速膨胀,但物理内存实际上没有完全对应上去。
4.3 多线程场景下brk为什么不再是主角
早期Unix的堆管理是进程级别的,多线程环境下大家共享同一个brk。这会产生两个显著问题:一是线程安全,需要锁保护堆顶位置;二是锁竞争,多线程密集分配小内存时,brk路径的性能会迅速劣化。
所以现代glibc改用了多分配区(arena)机制:主线程使用主分配区,每个子线程可以拥有独立的分配区。这些非主分配区在用mmap向内核申请内存时,有了自己的堆段,不依赖主线程的brk。这也是为什么多线程程序里大块分配和小块分配都会更多地看到mmap,而不是理想化的“小块分配全走brk”。
在写高并发服务时,如果你发现malloc的争抢成了热点,优先考虑per-thread缓存(比如tcmalloc、jemalloc)可能更合适。brk作为底层机制虽然古老,但它的问题在多线程下会被放大,这点要有心理准备。
4.4 碎片化和性能的取舍
brk分配的堆是连续地址,对缓存友好,缺页时会按顺序把相邻页一起分配,局部性比mmap好。但碎片化是堆最头疼的问题。低频次的大对象和频繁的小对象混合使用时,堆里容易形成大量空洞,通通无法归还,最终堆的VMA越拉越长。
关于碎片化有一个实用经验:如果业务中混合大小差异极大的分配,尽量分离大对象和小对象的分配路径。小对象继续走堆,大对象走mmap,可以让堆的空洞问题得到缓解。你也可以定期调用malloc_trim,在业务低峰期把堆上的空闲内存还给内核,代价是一次整体扫描和一系列底层的madvise调用。
| 场景 | 建议选择 | 原因 |
|---|---|---|
| 小对象频繁分配/释放 | brk堆 | 系统调用少,局部性好 |
| 大对象短期使用 | mmap | 释放后地址空间立即归还 |
| 常驻大对象 | mmap | 避免长期占用堆顶位置 |
| 多线程高频分配 | 分线程缓存/独立分配器 | 避免brk锁竞争 |
还有一个看似不起眼但影响很大的点:TLSF、jemalloc这些现代分配器在设计上放弃了直接使用brk调用,普遍依赖mmap。它们这么做不是因为brk内核实现有问题,而是因为进程级的连续堆在多核时代锁竞争太伤,per-CPU或per-thread的按需映射更适合现代服务端场景。所以你要是继续深入内存管理,不要只盯一个brk不放,要把整条路径都纳入视野。
结束语
我在实际项目里见过不少同学死磕brk相关的问题,最后发现是glibc的分配器行为跟自己的直觉不一致。我一直建议的方式是:先学机制,再学工具。机制上你理解了brk的“只能从堆顶释放”这个限制,就很容易解释当前程序RSS为什么居高不下。工具上,strace和/proc/self/maps是两把趁手的利器,一个看调用,一个看布局,两者配合能覆盖大多数堆排查场景。
最后分享一个小技巧:在怀疑内存被“泄漏”的时候,不要直接断定是代码问题。先看[heap]边界,再看top chunk的行为,很多时候是分配器策略加上brk机制在“背锅”。必要的场景下试试mallopt和malloc_trim,你可能会发现系统根本没什么大毛病,只是内存没有按照你预想的方式归还而已。这份对底层的掌控感,正是把Linux内存管理学到手里的真实回报。