
我是在一次线上服务内存持续上涨的排查中才真正把brk()这个系统调用从听说过变成吃透了。那段时间进程的RSS每跑一周就涨几百MB重启就恢复正常业务方怀疑是泄漏但堆内存快照又看不出明显增长点。直到用strace挂上去看到brk()被高频调用、堆顶一路往上抬才意识到问题出在分配模式而不是单纯的指针泄漏。从那以后我养成了一个习惯遇到Linux内存管理的疑难杂症先问三个问题——堆顶在不在涨RSS在不在涨两者是不是同步而这三个问题全都绕不开brk()。这篇文章就围绕brk()展开从进程地址空间、内核执行路径、malloc协作机制到实际排查手段把这条系统调用彻底聊透。无论你是面试前突击操作系统原理还是正在被线上内存问题折磨都能从中找到有用的东西。1. 先搞清楚堆在哪进程地址空间里的那块地皮1.1 虚拟地址空间的基本划分每个进程看到的内存其实是一张由内核维护的虚拟地址映射表。x86-64下一个普通用户态进程的地址空间大致长这样在常见发行版上观察到的典型布局0x0000000000000000 ---------------------- | 代码段 (.text) | 只读、可执行 | 数据段 (.data/.bss) | 读写 ---------------------- | 堆 (heap) | 向上增长由brk管理 | ... | | 内存映射区 (mmap) | 共享库、线程栈、大块匿名映射 | ... | ---------------------- | 栈 (stack) | 向下增长 ---------------------- 0x7ffffffff000 - 用户态地址上限约128TB堆不是与生俱来的区域。内核加载ELF可执行文件时把代码段和数据段映射进地址空间然后在数据段末尾确定一个起点存储在进程的mm_struct-start_brk里。堆从起点向上增长而堆的终点就是brk()要管理的program break。你可以把堆想象成一块待开发的地皮start_brk是南界break是施工队当前推进到的北界。北界之外虽然也划给了进程但还是一片没有页表、没有物理页的荒地——程序真的踩上去的那一刻内核才会开始施工。新手常有个误区以为堆和栈在中间某个位置会相撞。实际上64位用户态地址空间有128TB堆顶和栈底相隔十万八千里两者碰撞的概率约等于零。真正约束堆上限的是后文会提到的RLIMIT_DATA、已有VMA的位置以及物理内存在你触页之后能不能扛得住。这个认知先建立起来后面就不会被堆栈相撞这类说法带偏。1.2 program break堆的施工边界program break直译是程序断点但和调试器的断点没有半点关系我更愿意叫它堆顶指针的当前值。历史上传统Unix malloc就是围绕这条边界做文章的分配内存就把边界往上推释放内存就把边界往下拉。brk()系统调用的全部语义就是把堆顶搬到参数指定的新地址。内核在每个进程的mm_struct里维护两个关键字段start_brk堆的起始地址进程加载后基本固定brk堆当前的结束地址也就是program break的现值。用户态的sbrk(0)能拿到当前堆顶底层就是通过brk()系统调用实现的。这里有个细节容易被忽略向内核传0表示我只想看看现在堆顶在哪不想改任何东西内核会把当前mm-brk原样返回。这个技巧在各类诊断工具里经常用到也是为什么很多文章会用brk(0)来探测堆顶位置。再次强调一个重点brk()移动的只是虚拟地址空间的边界内核没有义务在调用返回时就分配物理页。真正触发物理内存分配的是后续的缺页异常。这种先划线、后开发的设计是虚拟内存高效运转的基石也解释了为什么brk()扩容的开销看起来如此之小。1.3 从32位到64位brk的生存空间变化聊brk不能不看位数差异。32位系统上用户态地址空间通常只有3GB经典的1G/3G划分mmap区域从TASK_UNMAPPED_BASE开始向下分配堆向上的空间非常有限。早期程序经常遇到堆顶撞上mmap区域导致分配失败的情况所以32位下malloc对大块内存几乎一律走mmap堆只服务小分配。64位把这个约束彻底松绑了用户态地址空间约128TB堆顶再往上推几十GB也碰不到任何东西。于是现代glibc对堆的使用大方了很多但这不意味着brk可以无限涨。内核在扩容路径上会逐项检查不能超过TASK_SIZE64位下基本不会触发、不能低于start_brk、不能与已有VMA重叠、还要受RLIMIT_DATA约束。绝大多数服务器上ulimit -d是unlimited所以实践中无限涨的最终约束来自overcommit和物理内存加swap的总量你尽可以brk出100GB虚拟空间但如果真的逐页写入迟早触发OOM或者被cgroup干掉。2. brk做了什么一次调用背后的内核路径2.1 系统调用入口与参数语义用户态调用brk()最终进入内核的sys_brk。这个系统调用在x86-64上不算复杂但细节很值得抠。它的核心逻辑可以简化成下面这样// 以下为逻辑示意非内核源码原文 SYSCALL_DEFINE1(brk, unsigned long, brk) { unsigned long oldbrk current-mm-brk; if (brk oldbrk) { /* 收缩方向把多出来的地址空间解除映射 */ current-mm-brk brk; do_munmap(current-mm, brk, oldbrk - brk); return brk; } /* 扩容方向的检查与落地 */ newbrk PAGE_ALIGN(brk); /* 一系列合法性检查 */ ... /* 合并或新建VMA */ vma vma_merge(...) 或者 新建匿名VMA; current-mm-brk brk; return brk; }看起来不过是改一个字段但里面藏着好几个关键点brk扩容前要做PAGE_ALIGN把新边界按页对齐。你只要求扩大1字节内核实际管理的最小单位也是4KB收缩方向调用do_munmap走的是解除映射路径。但munmap只能整页回收如果你的堆顶不对齐页边界最后一页可能缩不掉返回值语义和多数系统调用不一样失败时返回旧的brk值而不是-1。glibc的sbrk包装函数通过比较返回值来判定是否出错。调试时别惯性思维把返回值当普通错误码看。2.2 合法性检查为什么不是想涨就能涨扩容路径上的护栏一个接一个都写在do_brk_flags里不能超过TASK_SIZE。这是用户态地址上限32位下是真约束64位下几乎遇不到不能低于start_brk。防止堆顶挪进数据段不能与已有VMA重叠。堆上方如果有mmap映射——比如共享库、线程arena——brk想跨越它等于抢地盘内核直接拒绝返回旧brkRLIMIT_DATA检查。较新的内核把堆也算进数据段限额对应ulimit -d默认unlimited。这一串检查的意义在于brk()不是简单的数字改大一点它是在修改地址空间的拓扑结构。内核必须保证新边界不与任何已有映射冲突否则后续的缺页处理和VMA查找都会陷入混乱。面试常问为什么brk不能越过已有mmap区域答案就在这里地址空间不允许重叠映射堆只能在自己的地盘里扩张。理解了这一点再看32位系统上堆和mmap抢地盘的老话就明白那不是一个夸张的说法而是字面意义上的空间不足。2.3 VMA的分配与合并为什么堆是一整块通过检查后真正干活的是do_brk_flags核心操作围绕VMA虚拟内存区域展开。VMA描述一段连续的、属性相同的虚拟地址范围一个进程的地址空间就是由若干VMA组成的红黑树加链表。do_brk_flags的第一步是尝试用vma_merge把新扩展的区间合并进现有堆VMA。最常见的场景就是堆顶VMA的vm_end正好等于旧的brk新边界接着往后推于是只改一个vm_end字段就完成了扩容代价极小。如果中间有缝隙或属性不一致才会新建一个匿名VMA设置好vm_flags包括VM_DATA_DEFAULT_FLAGS、VM_ACCOUNT、mm-def_flags再插入VMA树。这就是brk适合小内存分配的底层原因每次扩容大概率只是把堆顶VMA的end指针往后推不新建VMA、不碰页表、不分配物理页。而mmap每次都要在红黑树里找空地、建新VMA除非恰好能合并开销明显更高。翻一下/proc/self/maps就能看到brk扩出来的堆始终是同一个[heap]区间只是区间范围越来越大而mmap的大块内存是列表里一个个没有[heap]标注的匿名映射。这两种形态直接反映了VMA层面的性格差异。2.4 惰性分配brk之后内存并没有真正来这是本文最应该记住的一句话brk()成功返回只代表这段虚拟地址归你了不代表物理内存已经备好。真实物理页的分配发生在第一次读写这段地址时CPU触发缺页异常内核在缺页处理里为对应虚拟页分配物理页、建立页表项再交还控制权给用户程序。这就是按需分页demand paging。搞懂这个能解释大量现象malloc(1GB)后只写第一页RSS只涨4KB但VIRT涨了1GB容器看内存限制要看RSS而不是VIRT用brk一次性扩大大量虚拟空间是廉价的昂贵的是逐个页面去触碰反过来程序一旦真的遍历堆上大段内存RSS会瞬间飙升这些匿名页在没有swap的情况下很难回收直到进程退出或内核主动回收。所以排查brk相关的高内存问题时请务必把brk涨了和物理内存真用了分开。看到VIRT暴涨先别慌先去确认RSS和页表状态很多内存泄漏其实只是虚拟水位线高企物理内存根本没有同步上涨。3. malloc与brk用户态和内核态的接力3.1 glibc分配器的三个层级malloc并不直接调brk()。以glibc的ptmalloc2分配器为例内部大致分三层用户请求层、bin/arena管理层、系统内存获取层。malloc(100)时分配器先到main_arena的空闲链表里找一块大小合适的chunk找不到才走到系统内存获取层。而这层的策略正是brk与mmap的分水岭。简单梳理一下决策逻辑场景分配路径特点小块默认阈值128KB以下main_arena的bin里找没有则扩展top chunktop chunk不足时用brk推高堆顶大块超过M_MMAP_THRESHOLD直接mmap匿名映射独立VMAfree时立即munmap非主线程的arena各arena自己的内存池内存来自mmap基本不碰brk注意M_MMAP_THRESHOLD默认128KB也就是说malloc(100KB)大概率走堆malloc(1MB)直接走mmap。这个阈值不是固定的glibc会动态调整。如果程序反复分配150KB又释放前几次走mmap之后分配器发现这个尺寸反复出现会把阈值抬上去让后续分配改用堆避免每次都付mmap/munmap的系统调用代价。阈值上限在64位下约32MB。3.2 小分配走堆大分配走mmap为什么这么分工核心是性价比。小分配走堆利用的是brk移动边界的低成本一次brk能支撑后续N次malloc分配器把拿到的整块内存切成多个chunk慢慢用只有top chunk耗尽才再次brk系统调用开销被大幅摊薄。大分配走mmap图的是干净mmap内存独立成映射free时立即munmap物理页立刻归还不会在堆里留洞。两种策略各有代价走堆free掉的内存不一定能还给OS。释放的chunk不在堆顶时只会进bin留作复用堆VMA的end不会缩回去走mmap每次分配和释放都完整走一遍系统调用和页表操作高频次下开销明显所以glibc才搞动态阈值来平衡。我见过最典型的反例有人为了减少系统调用把M_MMAP_THRESHOLD调到几百MB结果程序里大量中等大小的临时对象全堆在堆里free之后内存不降容器RSS长期高居不下。反过来阈值调太低又会出现频繁mmap导致的性能抖动。默认值经过了大量实测非必要不要乱调。真要调先压测再看RSS和系统调用频率别拍脑袋。3.3 free之后brk为什么经常不为所动很多初学者做过这个实验malloc(100)再free用sbrk(0)看堆顶发现地址纹丝不动于是怀疑free没释放内存。其实free干的事是把这块chunk标记为可复用挂到分配器自己的空闲链表里。要想真正归还OS得同时满足两个条件被释放的内存位于堆顶且堆顶空闲总量超过M_TRIM_THRESHOLD默认128KB。如果释放的是中间chunk内核根本不知道这段地址是空的——从它的角度看整个堆VMA还是一整块。打个比方你租了一块地brk是围栏malloc是在地里盖仓库。free只是把某个仓库清空并挂上可出租牌子围栏并没拆。只有最北边的仓库空出来了而且空着的面积够大你才觉得把围栏往里挪一挪划算才会调brk。这就是free不等于还给OS的完整解释。3.4 trim机制与内存归还glibc在两种情况下会真正把堆顶还回去free时发现top chunk的空闲量超过M_TRIM_THRESHOLD会触发trim逻辑通过brk把堆顶收回来你主动调用malloc_trim(0)分配器会把堆顶尽可能多的空闲页归还OS。malloc_trim(0)在低峰期瘦身很管用但注意它只对主线程的堆有效非主线程arena的内存来自mmap不归它管。另外一个扎心的事实是如果程序里存在大量地址从小到大依次分配、再从中间开始释放的chunk堆顶永远缩不回去因为堆顶之上始终有存活对象挡着。这种情况下trim也无效只能从分配模式入手或者接受这个进程的内存水位线。4. 亲眼观察brk几个立竿见影的调试手段4.1 strace跟踪brk系统调用纸上得来终觉浅。写个最简单的C程序用strace看brk的实时表演#include stdio.h #include stdlib.h #include unistd.h int main() { char *p malloc(1024); printf(p%p brk%p\n, p, sbrk(0)); char *q malloc(64 * 1024); printf(q%p brk%p\n, q, sbrk(0)); free(p); free(q); printf(after free brk%p\n, sbrk(0)); return 0; }编译运行strace关键输出长这样机器不同地址不同brk(NULL) 0x55f4a3c2e000 brk(0x55f4a3c2f000) 0x55f4a3c2f000 brk(0x55f4a3c62000) 0x55f4a3c62000第一行brk(NULL)是进程启动时glibc初始化用的目的是获取当前堆顶。第二次brk是第一次malloc触发推高了4KB。第三次是malloc(64KB)一次性推高很多页。而两次free之后strace里看不到任何brk回缩——正好验证了free通常不还内存。想看回缩可以循环多次malloc再逐次free直到top chunk空闲量超过M_TRIM_THRESHOLD或者用mallopt把M_TRIM_THRESHOLD调低让分配器变得小气free时就会积极trim。实际排查线上进程时strace -f -e tracebrk,mmap -p PID挂一段时间基本能把进程的内存获取行为摸个七七八八。4.2 /proc文件系统里的证据strace之外/proc/pid/maps是观察VMA形态最直接的窗口。程序运行时堆那一行通常带[heap]标注55f4a3c2e000-55f4a3c63000 rw-p 00000000 00:00 0 [heap]起始地址对应start_brk结束地址对应mm-brk按页对齐后的值。如果这一行后面没有[heap]说明进程的堆几乎没有被使用过或者主线程根本没走sbrk路径。再配合smaps_rollup可以看到每个匿名映射的RSS、PSS明细对定位哪块内存是大头非常有帮助。再看/proc/pid/status里几个关键字段VmData数据段加堆的大小brk扩展直接反映在这里VmRSS实际驻留的物理内存RssAnon匿名页驻留量VmSize整个虚拟地址空间大小。快速判读技巧VmData在涨而VmRSS基本不动说明只是虚拟边界扩大、物理内存没跟上大概率是分配了没写的内存或堆碎片在累积VmData和RssAnon同步上涨说明真有数据写进了堆这时候才需要认真考虑是不是业务侧泄漏。4.3 一个验证堆不回缩的小实验做个更接近实际场景的实验循环100次每次malloc(64KB)然后free掉其中99个观察sbrk(0)的最终值。64KB小于默认阈值128KB不会走mmap全部落在堆上。程序结束时你会发现堆顶已经比起点高了几MB而且free了99个chunk之后堆顶一点没缩——因为最后一个chunk还存活它在堆顶挡住了所有回收的可能。接着free最后一个chunk如果空闲量超过M_TRIM_THRESHOLDstrace里就会出现一次brk回缩。但注意回缩的幅度往往也不是全部最多收回到最近的非空闲chunk之上再按页对齐。这个实验做一遍对free不等于还内存trim是有条件、有边界的会有一次刻骨铭心的认知。如果环境里有gdb还可以用gdb -p PID执行call sbrk(0)直接看当前堆顶或者info proc mappings快速检查映射布局比反复写日志更省事。5. brk与mmap的路线之争系统调用的性格差异5.1 两者关键差异对比整理一张表把两者的性格摊开看对比维度brk堆mmap匿名映射系统调用brk一个参数移动边界mmap/munmap参数多、路径长VMA数量通常1个扩展时尽量合并每次分配一个新VMA地址连续性连续沿top chunk推进分散由内核找空地free后的行为不保证归还OSmunmap立即归还碎片风险有地址中间反复分配释放会制造洞低每块独立线程亲和主线程堆非主arena不用它所有线程都可用系统调用频率低频一次brk服务多次malloc每次分配/释放都要调用这张表浓缩了两条路线的本质差异也解释了为什么glibc在两者之间反复横跳没有绝对优只有场景适配。5.2 为什么大块内存不走brk假设大分配也走brk会怎样程序malloc(100MB)十次再free九个堆VMA会涨到1GB中间全是空洞。只要堆顶那100MB不释放这1GB虚拟地址就锁死了。更麻烦的是空洞在分配器眼里是可复用碎片后续凑不齐大块就得频繁split/merge chunk性能和内存利用率双输。mmap则不同每次大分配都是独立VMAfree直接munmap物理页和虚拟映射一起消失不污染主堆。另一个现实因素是动态阈值。glibc让大块走mmap本质是在分配/释放的系统调用开销和堆碎片化的长期代价之间做取舍小块高频用brk摊薄开销大块低频但影响大用mmap保证干净。这个思想其实可以延伸到业务系统设计里高频小对象要池化低频大对象要快速释放分类管理永远比一刀切高效。5.3 多线程下brk的退场多线程程序里brk的存在感进一步降低。glibc为每个线程维护独立分配区arena主线程用main arena基于堆其他线程默认各自创建新arena——这些arena的内存区域全部来自mmap完全不碰brk。只有线程很多、arena数量达到上限时新线程才可能去共享已有arena。这意味着什么一个16线程、每线程大量小分配的服务堆顶可能纹丝不动所有内存行为都发生在一堆mmap的arena里。排查时只盯[heap]会漏掉真正的大头。正确做法是全局统计匿名映射总量而不是只看堆。现在的golang、rust等运行时更是基本绕开ptmalloc向OS要内存主要用mmapbrk慢慢退化成库初始化的必经之路。但理解它依然是理解整个Linux地址空间体系的钥匙就好比学数学要先弄懂加减法哪怕日常做微积分的时候根本不会单独用到。6. brk与内存压力的幕后页表、overcommit与OOM6.1 页表才是brk和物理内存之间的桥梁brk只改VMA边界物理内存和虚拟地址之间的桥梁是页表。当一个进程第一次访问brk新划出来的地址时MMU查页表发现这一项是空的或者不在内存触发缺页内核在缺页异常里分配物理页、把页表项填充好程序才能正常读写。这个过程对用户态完全透明但性能影响很大——第一次触碰一段新扩出来的堆会有明显的缺页开销。如果程序分配了大块内存后立刻全部写一遍这段预热的成本是躲不掉的。从页表角度还能解释一个现象brk扩容后即使不写也不会让页表膨胀多少。VMA区间内的页表项是按需建立的不触碰就不建立所以纯虚拟扩容消耗的只是VMA和mm_struct这些元数据。这也是为什么大虚拟内存、小RSS的程序往往很省物理内存但元数据也在持续消耗少量内核内存。一次分配特别多VMA的程序内核内存同样会被撑大这又是一个容易被忽略的优化死角。6.2 overcommit策略如何卡住brk的脖子brk是虚拟内存操作内核在允许它之前还要看系统的overcommit策略记录在/proc/sys/vm/overcommit_memory里0默认启发式内核拒绝明显超出物理内存加swap的分配但不精确1总是允许分配基本不检查风险自担2严格模式分配受CommitLimit约束超了直接ENOMEM。在严格模式下brk创建的堆VMA带VM_ACCOUNT标志会计入CommitLimit。如果系统里已经有很多commit的内存你的brk扩容可能直接失败malloc返回NULL。这种问题在云上批量启动大内存服务的场景偶尔会遇到排查时看dmesg里有没有commit limit相关信息然后决定是调整overcommit策略还是减少单机并发。顺带提一句启发式模式下一次brk扩几个GB通常不会被拒但紧接着大量缺页写内存系统就会进入回收压力最终可能走向OOM。6.3 cgroup与容器视角为什么只看RSS还不够容器场景下内存上限由cgroup控制。cgroup记录的是实际使用的物理内存匿名页加page cache等brk只要不触页就不怎么计费。所以一个进程可以brk出几个GB的堆只要不写容器内存占用几乎不变。但一旦业务真正遍历堆或者分配并写满大对象匿名页会迅速增加直到cgroup的memory.limit这时候内核可能触发cgroup级别的OOM杀进程或者报内存分配失败。因此容器里判断brk是不是内存上涨的元凶关键还是那句话看RSS和anon页。虚拟堆大不可怕可怕的是虚拟堆大且被写满了。我见过一些服务启动时预分配几十GB虚拟内存备用平时没事业务高峰写入时直接顶爆容器。这种设计通常要配合按需触页的思路改造而不是简单地调大limit。用mmap做预分配同样要注意这个陷阱因为mmap也是惰性分配物理页的。7. 排查内存问题时怎么读brk这张表7.1 内存只涨不降的常见剧本我处理过不少服务内存只涨不降的case最典型的剧本是现象容器RSS随运行时间缓慢上涨重启后回落业务上却看不出泄漏初步定位strace发现brk高频出现堆顶持续上移深入查看分配压力集中在主线程单次分配都不大很多对象在堆top chunk之下反复malloc/free结论不是传统意义的指针泄漏而是分配模式导致堆水位线不断抬升空闲chunk留在堆里无法归还OS。这种问题的经典解法在业务低峰期调用malloc_trim(0)观察RSS是否明显回落。回落说明是堆内碎片和top chunk不归还问题纹丝不动更可能是真实泄漏得上跟踪工具逐对象排查。7.2 从brk行为区分碎片化和真实泄漏给一个更精细的判断框架brk持续涨、每次增量小、峰值后回落不明显大概率是堆碎片化或水位线效应brk持续涨但有规律的大幅回缩trim机制在工作整体健康brk不涨但RSS涨内存大头在mmap区域往匿名映射区查brk不涨、RSS不涨但进程被OOM杀掉方向要换到别的进程或系统整体内存压力上。把brk的涨跌模式和RSS的涨跌模式对照着看很多内存问题的方向一下就清晰了。工具方面我习惯的组合是strace定位系统调用频率smaps_rollup看匿名映射明细malloc_info或malloc_stats看分配器内部状态。三层对齐基本能锁定问题区域。如果嫌麻烦先跑一个足够长的strace样本统计brk和mmap出现的频率和增量再看RSS曲线就能形成一个初步判断。7.3 一些可以落地的调优手段最后分享几个实操经验低峰期主动malloc_trim(0)对主线程堆有效能明显改善越跑越胖有大量中等大小临时对象的服务不要轻易动M_MMAP_THRESHOLD先确认主线程是不是分配主路径。如果分配分散在多线程调阈值没用因为非主线程arena不走堆分析对象生命周期把长生命周期和短生命周期的对象分开管理必要时上内存池这是减少堆碎片最根本的手段容器里看内存上限用RSS和匿名页指标不要被VIRT吓住brk膨胀不等于物理内存膨胀先确认页表是否真的被填满。我个人在排查这类问题时的体会是brk()是个很诚实的系统调用它不虚报也不隐瞒只要读懂了它的行为整个进程的内存脉络就基本透明了。遇到玄学内存问题先别急着上重量级分析工具静下心看三样东西堆顶在不在涨、RSS在不在涨、两者是否同步。这三者的组合能帮你排除掉大半的猜测。之后再沿着brk这条线往深处挖无论是VMA、页表、缺页还是分配器行为都有了清晰的坐标系。