空闲内存还有 2 GB,为什么进程仍要新申请

内存分配器是程序与内核之间的零售层:它整页批发虚拟内存,再按尺寸切块卖出,free 只是把块放回自己的空闲链表。碎片分两种,内部碎片来自尺寸档位的向上取整,外部碎片来自空闲块被长期存活的对象隔开、拼不出连续的大块。最常见的误区是把 RSS 不降当成内存泄漏,或以为 free 会把内存立刻还给操作系统。读完你能分清泄漏、滞留与碎片,看懂 glibc 的 arena 与 tcache、jemalloc 的尺寸类与延迟回收,并用对参数和观测指标。

是什么

内存分配器是程序与内核之间那道"零售"层:malloc/free、C++ 的 operator new/delete、Go 运行时的 mheap,做的都是同一件事——向内核整页整页批发虚拟内存,再按调用方要的尺寸切块卖出,用完收回自己的货架。一句话直觉:free 是"放回仓库",不是"退货给供应商"。

碎片有两本账。内部碎片是"包装比货大":你要 100 字节,分配器至少要给足对齐和块头,多出来的部分谁也再用不了。外部碎片是"货架有空位,但拼不出连续的一整段":空闲总量足够,最大连续块却不够。

长跑服务最容易被它咬:网关、缓存、数据库进程的 RSS 爬到峰值就下不来;堆快照里活对象没涨,容器却被 OOMKilled;空闲块加起来还有 2 GB,一次 1 MB 的请求却仍只能向内核要新页。这些现象常常不是泄漏,而是分配器把空闲切碎了,或者干脆攥着没还。

核心原理

先看最朴素的做法:空闲块用链表串起来,按大小分箱(bin)。申请时找一个够大的块,切下需要的一段,剩下的留在箱里;释放时看左右邻居是不是也空闲,是就合并成更大的块。切分与合并,是分配器的心跳。

问题在于合并只对"物理相邻"的块生效。像一排停车位,你开走中间那辆车,左右都停着车,两个空位仍然停不进一辆加长车。程序里长期存活的小对象就是那两辆不肯走的车:它们把堆钉成一格一格的,后来的大请求只能去堆顶要新内存,RSS 于是继续涨。

外部碎片还有两个容易被忽略的性质。其一,它是路径相关的:同样一组对象,申请和释放的顺序不同,最后堆的布局就不同,所以"我本地复现不出来"很正常。其二,它会自增强:一个 8 字节的长寿对象就可能让所在的分配页(slab)一直无法整页释放,也让相邻的空闲块永远合并不成更大的块;堆越老,洞越碎,大请求越只能往堆顶要新页。开启透明大页后更明显,内核要的是 2 MB 连续区间,用户态的空闲块再碎一点就凑不出来。

尺寸档位是第二层。为了不做"遍历所有空闲块找最合适"的线性搜索,多数现代分配器(jemalloc、tcmalloc、Go 运行时)都把请求向上取整到固定的尺寸类;glibc 走的是另一条路,按对齐后的实际大小切块,但 16 字节对齐和块头同样带来内部浪费。jemalloc 的做法是每翻一倍设四档,官方文档说这样能把内部碎片限制在约 20%;档位越密,内部浪费越小,但每个档位都要备一批可能用不满的 slab,外部碎片和元数据开销反而上去。jemalloc 作者在 Meta 的工程博客里把这层取舍讲得很直白:档位太疏,对象尾部浪费大;档位太密,冷门尺寸长期占着内存不干活。

举个具体的:64 位下的小尺寸档大致是 8、16、32、48、64、80、96、112、128 字节,请求 100 字节就落到 112,浪费 12%;请求 1 字节也至少占 8 字节。单看一次不多,但一个百万级对象的缓存,光是向上取整就可能多出几十 MB,这部分在堆快照里算"已分配",看起来像业务在用。

第三层是并发。glibc 在 64 位下默认允许最多 8×CPU 核数个 arena,每个 arena 有独立的堆和空闲链表,线程落在自己的 arena 里分配,减少抢锁。代价是空闲块被按 arena 隔开:A 线程释放的块,B 线程未必看得见。再叠一层每线程缓存(tcache 默认每个尺寸留 7 个块),很多"已经 free"的内存其实正躺在某个线程的私有货架上。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    char *p[3];
    for (int i = 0; i < 3; i++) {
        p[i] = malloc(64 * 1024);
        p[i][0] = 1;                 /* 触碰一页,让映射真正落地 */
    }
    char *hole = p[1];
    free(hole);                      /* 中间挖洞,两侧仍被占用 */
    char *q = malloc(96 * 1024);     /* 洞只有 64 KB,装不下 */
    printf("hole=%p q=%p reused=%d\n", hole, q, q == hole);
    return 0;
}

这段程序不会崩,reused 却是 0:64 KB 的洞还在,96 KB 的请求只能绕开它。如果改成申请 32 KB,分配器就会把这个洞切开复用——同一个洞,对小请求是资源,对大请求是废地。

最后是归还。free 之后内存回到分配器,不等于回到内核。glibc 只在大块走 mmap 时于释放时直接 munmap;普通块要等它落到堆顶,或者你显式调用 malloc_trim,才可能还给系统。更微妙的是动态 mmap 阈值:释放一个大块会把阈值往上抬,最高到 32 MB,之后同样大的块就留在堆里,不再享受"释放即归还"。jemalloc 的 dirty page 按 decay 配置延迟回收,Go 的 scavenger 在后台用 madvise 分批归还(Linux 上默认 MADV_DONTNEED),都不是"free 完立刻降 RSS"。所以进程 RSS 更像高水位,而不是当前活对象的大小。

常见考点与误区

  • 误区一:free 就是把内存还给操作系统。 只有 mmap 大块在 free 时直接 munmap,普通块回到 arena 的空闲链表。面试问"free 之后 RSS 为什么不降",答"内核没收到通知"只是半句,还要补上滞留与碎片这两层。
  • 误区二:RSS 不降就是泄漏。 泄漏是活对象持续增长且不可达;碎片是活对象不多但空闲不连续;滞留是空闲块被缓存和 arena 攥着。三者处理方式完全不同。堆快照里 inuse 平稳而 RSS 很高,先怀疑后两者。
  • 误区三:块切得越小越省内存。 每块都有头部并按 16 字节对齐,请求 1 字节也要占一个最小块;海量小块还会把堆钉死,制造外部碎片。小对象该池化或批量分配,而不是"省着 malloc"。
  • 误区四:arena 和线程缓存越多越好。 它们降低锁竞争,也把空闲内存分散到多个堆和每线程缓存里。把 MALLOC_ARENA_MAX 调小是常见的省内存手段,代价是并发分配变慢,必须压测后再定。
  • 考点:内部碎片和外部碎片怎么量化? 内部浪费可以把 malloc_usable_size 减请求大小后累加;外部碎片没有唯一定义,常用"最大可满足请求除以总空闲"或 allocated 与 resident 的差来近似。答"空闲内存除以总内存"是错的。
  • 考点:为什么不用 best fit? 要遍历空闲链表,最坏 O(n);还会留下大量塞不下任何请求的极小尾巴。工业界用分离适配(按尺寸分箱)配合箱内快速查找,换的是可预测的常数时间。
  • 考点:glibc 为什么不在 free 时立刻合并? tcache 和 fastbin 路径为了快先不合并,等大请求触发整理时才把相邻空闲块并起来。延迟合并是性能与碎片之间的一笔交换。
  • 考点:动态 mmap 阈值。 释放一个 mmap 块会把阈值抬高到该块大小(64 位上限 32 MB),之后的块留在堆里,free 也不再归还。这是"压测时正常、跑久了 RSS 下不来"的经典来源。

实践建议

  • 先分清三种内存,再动手调参。 用 malloc_info、jemalloc 的 stats.allocated 与 stats.resident、Go 的 HeapAlloc/HeapInuse/HeapReleased 做对照:活对象、分配器持有、真正归还三条线分开画,才能判断是泄漏、滞留还是外部碎片。
  • 按生命周期分区,别让大小和寿命混住。 请求级临时对象用完即弃,可以走独立的 arena 或对象池;长期存活的小对象集中分配;大 buffer 复用而不是反复申请释放。jemalloc 默认把超过 8 MiB 的请求交给独立 arena(oversize_threshold),思路就是不让大对象和小对象混住,自建池化方案可以照抄这个分层。
  • 给长跑进程设上限。 多线程服务可试 MALLOC_ARENA_MAX=2~4,配合 MALLOC_TRIM_THRESHOLD_ 控制堆顶回收;换 jemalloc 时把 dirty_decay_ms 调低以更快归还,但要注意缺页和延迟上升。每项改动都要用同一份压测对比 RSS 与 P99。
  • 容器限额按峰值留余量,不要按活对象大小卡。 分配器的滞留与碎片会让 RSS 长期高于堆内活对象,限额贴着 live heap 设,等于给 OOMKilled 留了个定时器。同时监控 RSS 与 heap 的比值,比值持续走高就是碎片或滞留的信号。
  • 谨慎使用 malloc_trim,也谨慎开透明大页。 trim 能回收堆顶空闲,但会持锁并可能触发大量缺页,放在低峰或健康检查里做,别放进请求路径;大页能减少 TLB 缺失,却把"连续"的要求从 4 KB 抬到 2 MB,碎片敏感的服务要单独评估,或用启动时预分配的 hugetlb 池把连续性要求前移。

一句话总结:碎片不是内存变少了,而是空闲被切成拼不起来的小块;分配器只管零售,退货给内核是另一回事。

相关阅读

← 返回文章志