malloc 底下到底是什么
先纠正一个几乎人人都有的误解:malloc 不是系统调用。它是一个普普通通的用户态函数,跑在线的上面。它管理着一块从内核批发来的内存,而它管理的方式,决定了你的服务会不会内存只涨不落。
两级批发
内核管理内存的粒度是页(4 KB)。但你写代码时经常要 12 字节、40 字节这种零头。
如果每次 malloc(12) 都去问内核要,那:
- 每次都是一次系统调用,约 100 ns 起步(第 3 章);
- 内核最少给你一页,4096 字节里你只用了 12 个。
所以中间必须有一层。这就是 malloc:
- 内核 → 分配器:批发。分配器用
brk()(推高堆顶)或mmap()(要一块独立区域)一次要一大块,通常是 128 KB 起。这一步过线。 - 分配器 → 你:零售。
malloc从手上那块大内存里切一小片给你。这一步不过线,纯用户态。
所以绝大多数 malloc 调用根本不进内核——它只是在一个链表里找一块合适的空闲区域。这就是为什么 malloc 通常只要几十纳秒,而不是一次系统调用的钱。
同理,free 通常也不把内存还给操作系统——它只是在自己的账本上标记「这块可以再用了」。这一点非常重要,是「进程内存只涨不落」的直接原因。
分配器要解决的问题
分配器手上有一块连续内存,要应付任意顺序、任意大小的申请和释放。它需要回答三个问题:
- 怎么知道哪些地方是空的?——空闲链表。
- 申请来了,用哪一块?——分配策略(first-fit / best-fit……)。
- 释放了,怎么不让它碎掉?——合并相邻空闲块。
还有一个隐藏问题: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 次 brk | 8 KB | 30.5% | 37.8% |
| best-fit | 找最贴身的那一块 | 1 次 brk | 4 KB | 61.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,各自留着一些用不上的碎块。
这就是为什么很多服务换成 jemalloc 或 tcmalloc 之后内存占用明显下降——它们对多线程场景的设计更好(jemalloc 的分档更细、有主动的 decay 回收;tcmalloc 的线程本地缓存更激进)。
换分配器是不用改代码的:LD_PRELOAD=/usr/lib/libjemalloc.so ./myserver 就行。对内存敏感的服务值得一试。
- 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。它自己向内核要一大块内存(就是堆),然后用完全不同的方式管理:
| malloc | JVM 堆 | |
|---|---|---|
| 分配 | 找空闲块,约 15–100 ns | ★ 指针碰撞(ptr += size),几 ns |
| 释放 | 你手动调 free | GC 自动 |
| 外部碎片 | 会累积 | ★ GC 会移动对象来压缩,碎片被消除 |
| 代价 | 碎片 | GC 停顿 |
看清这个取舍:GC 能消除碎片,是因为它敢移动对象——而 C 不敢,因为你手里有裸指针,移了就废了。JVM 有对象引用这层间接,移动之后改引用就行。
所以「GC 语言不用操心内存」这句话只对了一半。你换来的是另一类问题:停顿时间、堆大小调优、以及一个 Android 开发者很熟悉的现象——大对象分配触发 GC,GC 移动对象,UI 掉帧。这就是为什么在滚动的 onBindViewHolder 里 new 对象是大忌。
顺带说,Android 的 ART 用的是并发标记清除加上分代,正是为了把停顿摊薄到不影响 16.6 ms 的帧预算里(第 4 章)。
这一章的一句话
malloc 是用户态的零售商,从内核批发大块内存再切碎卖给你,所以它通常不过线、只要几十纳秒。而它切碎的方式决定了碎片:空闲内存的总量够,不代表有一块连续的够——这就是服务内存只涨不落的主要来源。
下一章:既然 malloc 只是记账,那它到底会不会失败?答案是几乎不会——而这恰恰是 OOM killer 存在的原因。