卷 III · 幻境CH 14深度 14/24

malloc 底下到底是什么

先纠正一个几乎人人都有的误解:malloc 不是系统调用。它是一个普普通通的用户态函数,跑在线的上面。它管理着一块从内核批发来的内存,而它管理的方式,决定了你的服务会不会内存只涨不落。

brk / mmap空闲链表分裂与合并碎片

两级批发

内核管理内存的粒度是(4 KB)。但你写代码时经常要 12 字节、40 字节这种零头。

如果每次 malloc(12) 都去问内核要,那:

  • 每次都是一次系统调用,约 100 ns 起步(第 3 章);
  • 内核最少给你一页,4096 字节里你只用了 12 个

所以中间必须有一层。这就是 malloc

◆ 两级结构
  • 内核 → 分配器:批发。分配器用 brk()(推高堆顶)或 mmap()(要一块独立区域)一次要一大块,通常是 128 KB 起。这一步过线。
  • 分配器 → 你:零售。malloc 从手上那块大内存里切一小片给你。这一步不过线,纯用户态。

所以绝大多数 malloc 调用根本不进内核——它只是在一个链表里找一块合适的空闲区域。这就是为什么 malloc 通常只要几十纳秒,而不是一次系统调用的钱。

同理,free 通常也不把内存还给操作系统——它只是在自己的账本上标记「这块可以再用了」。这一点非常重要,是「进程内存只涨不落」的直接原因。

分配器要解决的问题

分配器手上有一块连续内存,要应付任意顺序、任意大小的申请和释放。它需要回答三个问题:

  1. 怎么知道哪些地方是空的?——空闲链表。
  2. 申请来了,用哪一块?——分配策略(first-fit / best-fit……)。
  3. 释放了,怎么不让它碎掉?——合并相邻空闲块。

还有一个隐藏问题:free(p) 只给了一个指针,分配器怎么知道这块有多大?

答案是在每块内存前面藏一个头部,记着大小和状态。你拿到的指针指向头部之后

           ┌──────────┬──────────────────────────┐
内存布局:  │  头部     │  你拿到的那块             │
           │ 16 字节   │  (malloc 返回的指针指这里)│
           └──────────┴──────────────────────────┘
                       ↑
                    p = malloc(n)

这解释了几件事:

  • 为什么 malloc(1) 实际占了 32 字节(16 头部 + 对齐到 16 的负载)。
  • 为什么写越界一个字节可能导致 free 时崩溃——你踩坏的是下一块的头部,分配器读到一个乱七八糟的大小。
  • 为什么 free 一个非法指针会段错误——它去 p-16 读头部,那儿可能什么都没有。
▶ 动手 · 真分配器

下面这台是一台真的分配器:真的维护空闲链表、真的按 16 字节对齐、真的分裂与合并、真的在找不到合适块时调 brk 扩堆。

请重点看第三个和第四个脚本,它们是本章的核心。看完再回来。

外部碎片:空间够,却装不下

跑一遍第三个脚本「★ 外部碎片」,你会看到这样的局面:

  • 连续分配四块 900 字节;
  • 释放掉第二块和第四块
  • 现在申请 1800 字节——失败,必须向内核要新内存

但空闲空间的总量明明是够的。问题在于它被中间那两块还活着的分配劈成了两半,没有一块连续区域装得下 1800 字节。

引擎里那个「问内核要过几次」的计数会从 1 变成 2——它真的又调了一次 brk

◆ 一句话总结外部碎片

你的进程「内存不足」,往往不是因为没有空闲内存,而是因为空闲内存不在一起。

这就是长期运行的服务「内存只涨不落」的主要机制之一:free 掉的内存进了空闲链表,但因为碎得太厉害,新的大请求用不上,只好继续向内核要。RSS 一路走高,而你的程序确实没有内存泄漏。

策略的代价:first-fit vs best-fit

第四个脚本演示了分配策略的真实差别。同一段分配序列,两种策略,结果是引擎当场算出来的:

策略怎么挑问内核要了几次堆大小利用率碎片率
first-fit找到第一个够大的就用2 次 brk8 KB30.5%37.8%
best-fit找最贴身的那一块1 次 brk4 KB61.0%19.4%

发生了什么?释放掉一个 2016 字节的大块和一个 624 字节的小块之后,来了个 500 字节的请求:

  • first-fit 撞见的第一个够大的就是那个 2016 的大块,一刀切下去——大块被毁了。接着 1800 的请求来了,谁都装不下,只能向内核要。
  • best-fit 挑最贴身的 624 那块,把大块完整留着。接着 1800 的请求正好装进去,一次都不用多要。

结果是内存占用差了一倍

但 best-fit 不是白拿好处:它每次都要扫完整个空闲链表才能确定哪块最贴身,而 first-fit 找到就走。分配次数一多,这个差距就出来了。

◆ 这是一个反复出现的取舍

眼下省事,还是给将来留路。

first-fit 快但会破坏大块;best-fit 省空间但每次都要全扫。这不是「哪个更好」的问题,是你在为哪种负载优化的问题。

而真实的分配器两头都不站——见下一节。

真实的分配器:分箱

glibc 的 ptmalloc(以及 jemalloc、tcmalloc、mimalloc)用的思路是分箱(binning):不维护一条大链表,而是按大小分成很多个桶,每个桶里放差不多大的块。

这样 malloc(100) 直接去「96–112 字节」那个桶里拿第一个,既不用扫全链表(快),拿到的又是尺寸接近的(省)——把 first-fit 的速度和 best-fit 的空间效率合到了一起,代价是数据结构复杂得多。

glibc 的具体分箱是:

  • tcache:每个线程一份的小缓存(每档 7 个)。命中的话完全无锁,这是最快的路径。
  • fastbins:小块(≤ 128 字节)的单链表,不合并,追求速度。
  • smallbins / largebins:按大小分档的双链表,会合并。
  • 大于 128 KB跳过整个分配器,直接 mmap 一块独立区域给你。free 时直接 munmap 还给内核。

最后一条很实用:大块内存是真的会还给操作系统的(因为它是独立的映射,不会造成碎片)。所以「free 不还内存」这句话对大块不成立。分界线是 M_MMAP_THRESHOLD,默认 128 KB,而且会动态调整。

⚠ 多线程下的锁竞争

如果只有一个全局堆,那么多线程 malloc 就要抢同一把锁——高并发下这会成为严重瓶颈。

glibc 的解法是 arena:允许有多个独立的堆(默认最多 8 × 核数 个),不同线程用不同的 arena。

副作用是内存占用变高:每个 arena 都有自己的空闲块,互相之间不共享。一个 32 核机器上可能有 256 个 arena,各自留着一些用不上的碎块。

这就是为什么很多服务换成 jemalloctcmalloc 之后内存占用明显下降——它们对多线程场景的设计更好(jemalloc 的分档更细、有主动的 decay 回收;tcmalloc 的线程本地缓存更激进)。

换分配器是不用改代码的:LD_PRELOAD=/usr/lib/libjemalloc.so ./myserver 就行。对内存敏感的服务值得一试。

¤ 价目表 · malloc 的三档价钱
  • tcache 命中(小块,线程本地):约 15 ns,无锁,不过线。
  • 走 bins(要加锁、可能要分裂):约 50–100 ns,仍然不过线。
  • 要向内核批发(brk / mmap):约 100 ns 系统调用 + 之后每页一次 1.5 μs 的缺页(第 12 章)。

注意最后一档:malloc 返回得很快,但你第一次写那块内存时,每 4 KB 都要付一次缺页。所以「分配 1 MB 很快」和「用起来很快」是两回事。

内部碎片:要 1 字节给你 32

第五个脚本演示了另一种浪费。malloc(1) 实际占 32 字节:16 字节头部 + 负载对齐到 16 字节。利用率只有百分之几。

这叫内部碎片——浪费在块里面,与外部碎片(浪费在块之间)相对。

实践含义很明确:省内存不能靠少 malloc 几个字节,要靠少 malloc 几次。把一千个小对象合并成一个数组,省下的是一千个头部加一千次对齐浪费。这也是对象池、arena 分配器、以及各种「批量分配」技巧的根本理由。

↑ 回到应用层

「为什么 Java/Kotlin 里没有这些问题?」

因为 JVM 绕过了 malloc。它自己向内核要一大块内存(就是堆),然后用完全不同的方式管理:

mallocJVM 堆
分配找空闲块,约 15–100 ns★ 指针碰撞(ptr += size),几 ns
释放你手动调 freeGC 自动
外部碎片会累积★ GC 会移动对象来压缩,碎片被消除
代价碎片GC 停顿

看清这个取舍:GC 能消除碎片,是因为它敢移动对象——而 C 不敢,因为你手里有裸指针,移了就废了。JVM 有对象引用这层间接,移动之后改引用就行。

所以「GC 语言不用操心内存」这句话只对了一半。你换来的是另一类问题:停顿时间、堆大小调优、以及一个 Android 开发者很熟悉的现象——大对象分配触发 GC,GC 移动对象,UI 掉帧。这就是为什么在滚动的 onBindViewHoldernew 对象是大忌。

顺带说,Android 的 ART 用的是并发标记清除加上分代,正是为了把停顿摊薄到不影响 16.6 ms 的帧预算里(第 4 章)。

这一章的一句话

malloc 是用户态的零售商,从内核批发大块内存再切碎卖给你,所以它通常不过线、只要几十纳秒。而它切碎的方式决定了碎片:空闲内存的总量够,不代表有一块连续的够——这就是服务内存只涨不落的主要来源。

下一章:既然 malloc 只是记账,那它到底会不会失败?答案是几乎不会——而这恰恰是 OOM killer 存在的原因。