
开篇故事一个负数的内存统计一个常驻服务上线后有人在监控面板上看到「当前堆占用」是-1072950624 字节。第一反应是打点代码写错了查下来没有——它只是老老实实地打印mallinfo().uordblks。程序当时持有约 3 GiB 堆内存int字段装不下 3 GiB绕回了负数。更危险的是反向情况如果没人盯着看一个看起来很小甚至为负的数字会让人误以为没有泄漏而进程正在悄悄吃掉整台机器的内存。这个程序后来把mallinfo()换成了mallinfo2()问题消失。但这引出三个更基本的问题mallinfo()返回的十个字段各自到底在数什么这些数字是怎么算出来的它为什么长这样——一个 1980 年代标准定义的接口为什么会出现在 2026 年的代码库里拿它做内存泄漏排查怎么判断一条曲线是泄漏还是正常缓存本文用五个可复现的小程序逐一实测回答。所有数字来自一台 Linux 5.15WSL2x86_64glibc 2.41完整输出见result.txt源码、Makefile与这份输出都收在文末的资源包里make result可复现。原理两节直接对照 glibc 2.41 的malloc/malloc.c与malloc/arena.c源码并给出与 man page 不一致处的实测裁决。换一台机器绝对数字会变不变的是字段口径和判定方法——涉及耗时的几处另附重复运行的抖动范围。1. 先说结论mallinfo()mallinfo2()字段类型intsize_t引入版本SVID2/XPG 时代的老接口glibc2.332021 年 2 月堆 2 GiB溢出打出负数正确编译告警glibc 2.33 起标记deprecated-Wall会警告无格式化%d%zu新代码直接写mallinfo2()老代码看到负数或编译告警就是该迁移的信号。下面两节先讲清楚它的出身和原理再回到字段和用法。2. 从哪来一个 Unix 标准留下的接口mallinfo()不是 glibc 的发明它是一个被标准化出来的接口。glibc 的malloc/malloc.c开头有一段 1990 年代写下的注释原文是This version of malloc supports the standard SVID/XPG mallinfo routine that returns a struct containing usage properties and statistics. It should work on any SVID/XPG compliant system that has a /usr/include/malloc.h defining struct mallinfo.翻译过来这个 malloc 实现要支持SVID/XPG 规定的标准 mallinfo 例程任何符合 SVID/XPG 的系统都应该有/usr/include/malloc.h里的struct mallinfo。SVIDSystem V Interface DefinitionATT 的系统 V 接口定义和XPGX/Open Portability GuideX/Open 组的可移植性指南是上世纪八九十年代 Unix 标准化竞赛的产物。两家都想规定一个合格的 Unix C 库该长什么样能报告堆用量就是其中一条。glibc 的 malloc 系Gloger 的 ptmalloc源自 Doug Lea 的 dlmalloc为了满足这份规范实现了它接口原封不动地活到了今天。你在 glibc 源码里读到的是一段 30 年前的接口约定这不是巧合。标准把结构体规定死了于是字段和实现对不上标准定义的struct mallinfo字段是按老版 System V malloc 的内部结构写的跟 ptmalloc 的实现并不匹配。glibc 注释自己也承认The main declaration needed is the mallinfo struct that is returned (by-copy) by mallinfo(). The SVID/XPG malloinfo struct contains a bunch of fields that are not even meaningful in this version of malloc. These fields are are instead filled by mallinfo() with other numbers that might be of interest.malloinfo、are are和malloc. These之间的双空格都是源码原样这里没有替 glibc 修字。「一堆字段在这个 malloc 里根本没意义我们只好拿别的数字填进去」。最好的证据是usmblks——malloc.h里它的注释是intusmblks;/* always 0, preserved for backwards compatibility */man page 补充了它的前世这个字段历史上是历史峰值highwater mark只在单线程时代维护过多线程化之后没人再更新它于是恒为 0但字段必须留着因为标准里有它。int字段同样是那个年代的遗产接口定型时堆超过 2 GiB 在 32 位机器上根本不可想象。这个债欠了 30 年直到 2021 年才还——glibc 2.33 的NEWS里有这么两条原文分列于该版两条独立条目这里放在一起* The mallinfo2 function is added to report statistics as per mallinfo, but with larger field widths to accurately report values that are larger than fit in an integer. * The mallinfo function is marked deprecated. Callers should call mallinfo2 instead.顺带一提mallinfo2的结构体在malloc.h里挂的注释仍然是SVID2/XPG mallinfo2 structure which can handle allocations bigger than 4GB——补一个标准框架下的新版而不是另起炉灶这是对接口被标准锁死最直白的应对。小结这一节mallinfo()存在的理由是兼容一个 1980 年代的 Unix 标准它的字段语义是标准和实现妥协的产物它的int类型是 32 位时代的遗产而mallinfo2()是 glibc 给这份遗产做的第一次正面修复。3. 背景glibc 的堆长什么样要理解字段和后面的遍历逻辑先得知道 glibc 从系统拿内存的两条路主堆sbrk向后连续扩展的那块。小块分配默认 128 KiB即M_MMAP_THRESHOLD都从这里切。mmap 独立映射大块分配 128 KiB直接mmap一段独立区域free时立刻还给内核。主堆内部再分正在使用的 chunk、fastbin 里的空闲 chunk、普通 bin 里的空闲 chunk、以及堆顶还没切的 top chunk。glibc 还支持多 arena多线程下每个线程可以从main_arena头部分裂出自己的 arenamalloc/arena.c把新 arena 头插进main_arena.next的环形链表避免所有线程抢一把锁。第 4.3 节会看到mallinfo的实现就是沿这条环走的。结构与字段的对应关系系统 ├─ sbrk ──→ 主堆 main_arena ─┬─ 在用 chunk ────→ uordblks │ ├─ fastbin 空闲 ──→ fsmblks (smblks 个) │ ├─ bin 空闲 ─────→ fordblks 的一部分 (ordblks 个) │ └─ top chunk ────→ keepcost 是它的可释放部分 ├─ sbrk/mmap ─→ 线程 arena 1..N同样有上面四类都计入 arena/uordblks └─ mmap ──────→ 独立映射区 ──────────────────→ hblks (个数) / hblkhd (字节)4. 原理这些数字是现算出来的4.1 现场推导不是记账最容易误解的一点mallinfo()背后没有任何统计子系统。glibc 不会每次 malloc 时 1——malloc 为了完成本职工作本来就维护着 free list、top chunk 和每个 arena 的system_mem该 arena 从系统要了多少字节。mallinfo做的只是在被调用的那一刻把这些现成的数据结构走一遍、加总、返回副本。这带来两个直接推论平时零成本——不调用就不遍历没有记账开销。调用有成本成本正比于要遍历的结构大小见 4.5 节实测而不是分配次数。4.2 一次调用的完整过程glibc 2.41 源码核心是malloc/malloc.c里的int_mallinfo(mstate av, struct mallinfo2 *m)对单个 arena先把 top chunk 记为「空闲」再走两条空闲链/* Account for top */availchunksize(av-top);/* 先把 top chunk 算作空闲 */nblocks1;/* traverse fastbins */nfastblocks0;fastavail0;for(i0;iNFASTBINS;i)/* NFASTBINSx86_64 上 10 条 */for(pfastbin(av,i);p!NULL;pREVEAL_PTR(p-fd)){nfastblocks;fastavailchunksize(p);}availfastavail;/* traverse regular bins */for(i1;iNBINS;i)/* NBINS1280 号不存在遍历其余 127 条 */{bbin_at(av,i);for(plast(b);p!b;pp-bk){nblocks;availchunksize(p);}}m-smblksnfastblocks;/* fastbin 空闲块另计 smblks */m-ordblksnblocks;/* 空闲块个数 top 普通 bin */m-fordblksavail;/* 空闲字节数 top fastbin bin */m-uordblksav-system_mem-avail;/* 在用 从系统拿的 - 空闲的 */m-arenaav-system_mem;m-fsmblksfastavail;if(avmain_arena){/* 只访问主 arena 时填一次 */m-hblksmp_.n_mmaps;/* 全局 mmap 计数进程级 */m-hblkhdmp_.mmapped_mem;m-usmblks0;/* 历史峰值字段就是这一行清零的 */m-keepcostchunksize(av-top);/* 堆顶可 trim 的量 */}REVEAL_PTR(p-fd)不是绕路glibc 2.32 起 fastbin 的单向表用了 safe-linkingfd存进去时被PROTECT_PTR用「本地址右移 12 位再异或」打散过读链必须先还原照老代码写p-fd拿到的是异或值。这一行也是本文引的源码片段里最容易照抄错的一处。三个值得注意的设计uordblks system_mem - avail在用字节不是逐个分配累加的而是从系统拿的总量减去能数出来的空闲。所以 fastbin 之外那些已 free 但还在 tcache 里之类的边角料只要没进 bin都会被算成在用。mmap 数字来自全局mp_结构进程级共享所以hblks/hblkhd天然是全进程口径——但它只数带 IS_MMAPPED 标记的分配非主 arena 的堆段虽然底层也是 mmap 来的却计入arena而不是hblkhd4.3 节实测会看到。arena、uordblks是把每个 arena 的贡献累加的因此是全进程口径——前提是遍历真的覆盖了所有 arena这正是下一节要实测裁决的问题。4.3 多 arena环形遍历 逐个加锁外层的__libc_mallinfo2()长这样memset(m,0,sizeof(m));ar_ptrmain_arena;do{__libc_lock_lock(ar_ptr-mutex);/* 锁住这个 arena */int_mallinfo(ar_ptr,m);/* 累加它的贡献 */__libc_lock_unlock(ar_ptr-mutex);/* 解锁再去下一个 */ar_ptrar_ptr-next;/* 沿环形链表前进 */}while(ar_ptr!main_arena);/* 绕回起点为止 */returnm;malloc/arena.c创建新 arena 时执行a-next main_arena.next; main_arena.next a;——新 arena 被头插进这条环。所以从源码看遍历会覆盖所有 arena。但 man page 不这么写。man 3 mallinfo2的 BUGS 一节明明白白地说Information is returned for only the main memory allocation area. Allocations in other arenas are excluded.「只统计主分配区其他 arena 的分配被排除」。应是 arena 机制出现之前留下的文本与今天的源码矛盾。信谁实测。demo_arena.c起 4 个线程每线程在屏障同步后同时分配 8 MiB全部低于 mmap 阈值走 arena从主线程对账before : arena0 uordblks0 (0.0 MiB) during : arena33722368 uordblks33580192 (32.0 MiB) delta : arena33722368 uordblks33580192 (32.0 MiB) [期望 32 MiB] --- malloc_stats()逐 arena 打印同一套 int_mallinfo--- Arena 0: system bytes 135168 in use bytes 5920 Arena 1: system bytes 8396800 in use bytes 8393568 Arena 2: system bytes 8396800 in use bytes 8393568 Arena 3: system bytes 8396800 in use bytes 8393568 Arena 4: system bytes 8396800 in use bytes 8393568裁决4 个线程的内存全部计入了。主线程读到的增量 33580192 字节 335544324×8 MiB 负载 25760chunk/heap 元数据开销一个字节都不缺malloc_stats顺带证明这些内存确实躺在 Arena 1…4而不是碰巧被谁算进了主 arena。glibc 2.41 上 man page 那句 BUGS 是过时的。实测中还冒出一个有意思的现象Arena 1..4的system bytes各有 8 MiB但max mmap regions 0——这些堆段底层明明是mmap出来的却全部记在arena字段里hblkhd一个字节都没涨印证了 4.2 节说的只数带 IS_MMAPPED 标记的分配。顺带说清快照的性质每个 arena 在自己被访问的那一刻是加锁读取的内部一致但 5 个 arena 是逐个访问的别的线程可以在你读完 Arena 0 之后、读 Arena 1 之前改 Arena 0——所以它不是一个全局原子快照。同理加锁意味着这个函数不能在信号处理器里调用可能死锁。4.4mallinfo()mallinfo2() 逐字段截断有了上面的铺垫mallinfo()的实现只有一层皮structmallinfo__libc_mallinfo(void){structmallinfom;structmallinfo2m2__libc_mallinfo2();/* 先按 size_t 算全 */m.arenam2.arena;/* 再逐字段赋给 int */m.ordblksm2.ordblks;...returnm;}溢出就发生在m.arena m2.arena;这一行size_t赋给int超出int范围时按实现惯用的二补码截断取低 32 位。没有报错、没有饱和、没有警告——第 6 节的实测会把这个回绕算术验算到底。这也说明两版的统计口径完全一致毕竟同一个函数算的差异只在装结果的容器大小。4.5 成本O(空闲块数)不是 O(分配数)既然每次调用都现场遍历成本是多少demo_cost.c测三种堆状态下的单次调用耗时各 200 次取平均(a) 初始空闲 chunk 极少 : 0.1 us/call (b) 持有 100 万个在用块 uordblks76 MiB: 0.1 us/call (c) 全部释放 fordblks76 MiB : 6457.0 us/call© 这个绝对值是抖动的同一台机器重复跑落在 5.4~6.5 毫秒都正常打印精度 0.1 µs(a)/(b) 两行在 0.0 和 0.1 之间跳。要看的是两件事(b) vs ©同一个 76 MiB 的堆块一个都没多没少只是从在用变成已释放——耗时从 0.1 µs 量级涨到约 6 毫秒约 6 ns/空闲块。空闲块才是成本的唯一来源。(a) vs (b)从几乎空的堆到 100 万个在用块耗时纹丝不动0.1 µs——在用块根本不被遍历印证 4.2 节的源码。实践推论正常服务的 free list 很短mallinfo2()是亚微秒级的廉价调用随便采但如果你的堆里躺着几十万个空闲块典型场景长期运行、大量不同尺寸的分配释放一次调用要持有 arena 锁好几毫秒——这时高频轮询它会和分配线程互相拖累。它也不是 async-signal-safe 的。5. 字段逐个看十个里面盯三个#includemalloc.hstructmallinfomimallinfo();/* 或 mallinfo2()字段同名 */字段含义值得看吗arena所有 arena 从系统拿到的总字节含在用 空闲看趋势ordblks空闲块个数top 普通 binfastbin 另计smblks辅助smblksfastbin 空闲块个数少用hblksmmap 独立映射区个数看分配粒度hblkhdmmap 独立映射区总字节看大块分配全在这里usmblks恒为 0单线程时代的历史峰值见第二节忽略fsmblksfastbin 空闲块总字节辅助uordblks当前在用的总字节 Σ arena 的 system_mem − 空闲最常用fordblks当前空闲块总字节看区分用了和没还keepcost堆顶可通过malloc_trim归还的最大字节仅 main_arena解释 RSS 时用日常盯三个就够uordblks真在用、fordblks空闲但还在进程手里、hblkhdmmap 大块。近似关系arena ≈ uordblks fordblks加 chunk 开销。实测分配、释放、再看 mmap 路径demo_basic.c四次采样节选单位字节 初始状态 arena0 uordblks0 fordblks0 hblks0 hblkhd0 64x64KiB 分配并释放后堆已扩张空闲块留在 fordblks arena135168 uordblks4768 fordblks130400 hblks0 hblkhd0 再持有 8x1MiB 后看 hblks/hblkhd 涨、arena 不动 arena135168 uordblks4768 fordblks130400 hblks8 hblkhd8421376 释放 8x1MiB 后hblkhd 归零mmap 内存真正还给内核 arena135168 uordblks4768 fordblks130400 hblks0 hblkhd0三个值得停下来的地方64 次 64 KiB 的分配释放结束后uordblks只剩 4768但arena是 135168。内存还给了 free listfordblks130400没还给操作系统。这就是空闲 ≠ RSS 下降的实证。8×1 MiB 全部持有时uordblks一动不动涨的是hblkhd。mmap 大块不计入uordblks——监控只盯uordblks的话会完全看不见这部分内存。要看全貌得uordblks hblkhd。释放 8×1 MiB 后hblkhd立刻归零。mmap 路径是即借即还所以大对象频繁分配释放时RSS 曲线会很干净。6. mallinfo2()那个 -1 GiB 是怎么来的两版结构体字段一一对应唯一区别是类型intvssize_t。3 GiB 的堆塞进 32 位int必然回绕4.4 节已经看到了回绕发生的位置。demo_overflow.c把它做成了可见的每块 64 KiB低于 mmap 阈值全部进主堆分配 3 GiB 后同时打印两个接口。程序每块只触碰首字节所以真实 RSS 远小于 3 GiB普通机器也跑得动——回绕取决于堆的账目跟物理内存无关。分配 3072 MiB ... mallinfo() (int): arena -1072881664 (-1.00 GiB负数即溢出) uordblks -1072950624 (-1.00 GiB) fordblks 68960 mallinfo2() (size_t): arena 3222085632 (3.00 GiB) uordblks 3222016672 (3.00 GiB) fordblks 68960验算回绕3222016672 - 2^32 -1072950624与打印值完全一致——就是把size_t的低 32 位当成了int。注意两个细节fordblks都是 68960没溢出。空闲块很小所以老程序里往往一部分字段看着正常、一部分是负数更容易骗过粗略的检查。glibc 直接在头文件里给mallinfo()标了__MALLOC_DEPRECATED-Wall编译即警告result.txt开头记录了这两条warning: ‘mallinfo’ is deprecated [-Wdeprecated-declarations] 19 | struct mallinfo mi mallinfo();这不是第三方库的建议是 libc 自己在说别用这个了。迁移是机械替换-structmallinfomimallinfo();-printf(%d\n,mi.uordblks);structmallinfo2mimallinfo2();printf(%zu\n,mi.uordblks);字段名完全一样只需改类型名、函数名和格式符%d→%zu。唯一的兼容性代价是老 glibc 2.33如 CentOS 7 的 2.17没有mallinfo2。需要兼容时#ifdefined(__GLIBC__)(__GLIBC__2||(__GLIBC__2__GLIBC_MINOR__33))structmallinfo2mimallinfo2();printf(used%zu\n,mi.uordblks);#elsestructmallinfomimallinfo();printf(used%d\n,mi.uordblks);#endif7. 实践用 uordblks 曲线区分泄漏和缓存只看一个瞬时数字没有意义看趋势。demo_leak.c跑 20 轮分配 1000 块 4 KiB、全部释放的循环唯一的区别是leak模式每轮丢掉一个指针每轮泄漏 4 KiB### demo_leak stable 0 4 0 1 4 0 ... 19 4 0 ← 平坦 ### demo_leak leak 0 8 4 1 12 4 2 16 4 ... 19 84 4 ← 每轮 4 KiB斜率精确等于泄漏量判定规则很简单平坦或锯齿正常。锯齿来自 glibc 自己的 free list 复用峰谷差就是工作集大小。单调爬升斜率不收敛泄漏。而且斜率就是泄漏速率——上面每轮 4 KiB和丢掉的块大小完全一致说明uordblks的增量可以直接当泄漏量用。上线时把它接成埋点每隔 N 秒记一次uordblks、hblkhd、fordblks三个值。只涨不跌且fordblks不同步上涨才是泄漏如果uordblks平稳而fordblks/arena涨那只是 free list 变大是缓存不是泄漏。采样频率参考 4.5 节free list 很短时随便采怀疑空闲块很多时先看ordblks再定频率。8. 陷阱清单不是全局原子快照。逐 arena 加锁读取每个 arena 内部一致跨 arena 不保证同一时刻见 4.3 节也不能在信号处理器里调用拿锁可能死锁。至于 man page BUGS 那句只统计主 arena2.41 实测已被证伪——但也别赌老版本行为见 FAQ。口径有限。只统计 malloc 族的分配。C 的new在 glibc 上最终走 malloc 所以可见但自定义 allocator、静态区、栈都不算。它更不是 RSS——arena里空闲的部分可能仍占着物理页。hblkhd只数 IS_MMAPPED 分配线程 arena 的 mmap 堆段记在arena里。空闲 ≠ 归还 OS。第 5 节实测64 次 64 KiB 分配释放完uordblks回落到 4768 字节但arena仍停在 135168——大头留在 free listfordblks130400。主堆只在满足条件时通过malloc_trim/M_TRIM_THRESHOLD默认 128 KiB向 OS 收缩keepcost就是最多还能 trim 掉多少。mmap 大块则不同free即归零hblkhd实测可见。可移植性仅 glibc。musl、macOS(BSD malloc)、Windows 都没有这两个函数malloc.h本身也不是标准头。跨平台代码要包起来#ifdefined(__GLIBC__)#includemalloc.h#defineHAVE_MALLINFO1#endif换 allocator 后失真。程序若用LD_PRELOAD换成 jemalloc/tcmalloc或者编译期替换glibc 的mallinfo统计不到它们的分配tcmalloc 有自己的MallocExtension接口。看到数字不涨先确认底下是谁在管内存。空闲块巨大时调用变贵。100 万个空闲块 ≈ 6 ms/次且持锁4.5 节实测高频轮询会拖累分配线程。9. FAQarena和uordblks差在哪arena是所有 arena 从系统要来的总量在用 空闲uordblks只是其中在用的部分。差值就是空闲空间和fordblks大致对应。为什么mallinfo2()的数比/proc/self/statm小statm数的是整个进程代码段、栈、静态区全都算mallinfo 只数堆的分配。两个数本来就不该相等交叉验证时应该看uordblks hblkhd与 RSS 的相对趋势。程序退出前fordblks不为 0是泄漏吗不是。fordblks是空闲块指针已经交还给 glibc 了进程退出时一并回收。泄漏的特征是uordblks单调上涨不是fordblks非零。man page 说只统计主 arena源码和实测都说全算信哪个信实测但写防御性代码时别依赖这个细节。本文实测的是 glibc 2.41demo_arena4 线程 ×8 MiB 全部计入BUGS 里那句应是 arena 机制出现前留下的文本。需要权威口径时用malloc_stats()/malloc_info()——前者与mallinfo共用同一套int_mallinfo且逐 arena 打印后者输出 XML 覆盖更全。10. 总结mallinfo()/mallinfo2()是 glibc 留在malloc.h里的快速堆体检接口。它的实现没有任何统计子系统——每次调用现场遍历 free list、逐 arena 加总、给你一份副本平时零成本调用成本正比于空闲块数。它的出身是 SVID2/XPG 这份上世纪的 Unix 标准结构体被标准锁死int字段是 32 位时代的遗产usmblks恒 0 是接口与实现对不上的活化石2021 年 2 月 glibc 2.33 才补上mallinfo2()并给老接口挂上 deprecated。新代码mallinfo2()盯uordblkshblkhdfordblks的趋势。老代码出现负数或 deprecation 告警就是迁移信号——2 GiB 必溢出第六节有实测。需要全局原子快照、可移植、或换过 allocator换malloc_info()或平台自己的观测工具。参考资料man 3 mallinfo2字段定义、BUGS——注意其中只统计主 arena一句与 2.41 源码不符glibc 2.41 源码malloc/malloc.cint_mallinfo/__libc_mallinfo2/__libc_mallinfo、malloc/arena.carena 环形链表、NEWS2.33 变更记录本文 demo 源码demo_basic.c、demo_arena.c、demo_cost.c、demo_overflow.c、demo_leak.c、Makefile与实测输出result.txt一并收在文末资源包里make result可整套复现资源地址https://download.csdn.net/download/weixin_49280144/93509409