几年前写过一篇文章2万字|30张图带你领略glibc内存管理精髓。
那篇文章前前后后花了很长时间,不仅啃了大量源码,还画了三十多张图,试图把 glibc malloc 的各种细节讲清楚。
后来陆续收到不少读者反馈:内容很全面,但确实有点难啃。
说实话,多年过去之后,就连我自己在排查线上内存问题时,也不会再去回忆那些复杂的源码细节。
绝大多数时候,我真正关心的其实只有几个问题:
- malloc 到底是怎么分配内存的?
- 为什么多线程程序会有 Arena?
- Fast Bin、TCache 这些东西到底解决了什么问题?
- 为什么有时候会看到大量 mmap/munmap?
于是重新翻了一遍当年的笔记。
这一次不再从源码细节出发,而是从「演进」的角度理解 glibc malloc:
dlmalloc → ptmalloc → ptmalloc2 → TCache
因为理解一个系统最好的方式,往往不是记住它今天长什么样,而是理解它为什么会演变成今天这样。
现代 GLIBC 分配器的整体
在现代 Linux 系统中,malloc() 并不是简单地把请求直接丢给操作系统。
绝大多数情况下,用户态的 GLIBC 分配器会先在进程内部维护一套复杂的内存池,并尽量从已有内存中完成分配。只有当现有内存无法满足需求,或者遇到较大的内存申请时,才会进一步向内核申请更多内存。
从整体上看,可以把 GLIBC 相关的内存分配路径粗略理解为:
应用程序
│
├── malloc / calloc / realloc / free
│ │
│ ▼
│ ptmalloc2
│ ├── TCache
│ ├── Fast Bin
│ ├── Unsorted Bin
│ ├── Small Bin
│ ├── Large Bin
│ ├── Arena / Top Chunk
│ └── brk / mmap
│
├── 动态链接器内部少量分配逻辑
│
└── alloca
│
▼
函数栈空间
其中,本文重点关注的是第一条路径:
malloc / free → ptmalloc2
也就是绝大多数 C/C++ 程序 日常使用的主堆分配器。
需要注意的是,mmap 并不是一个独立于 ptmalloc2 之外的“分配器”。对于较大的内存请求,ptmalloc2 会绕过常规 Arena 路径,直接通过 mmap() 向内核申请一块独立的虚拟内存映射区域;释放时,也可能通过 munmap() 直接归还给内核。
这也是为什么我们在线上排查问题时,有时会在 strace 中看到大量:
mmap(...)
munmap(...)
mmap(...)
munmap(...)
它并不一定说明业务代码直接调用了 mmap(),更常见的情况是:某些 malloc() 请求超过了阈值,被 GLIBC 分配器走到了 mmap 路径。
另一类特殊情况是 alloca()。它不是堆分配,而是在当前函数调用栈上分配空间,函数返回后自动失效,因此不属于本文重点讨论的堆管理体系。
接下来,我们将重点剖析主堆分配器的三次重要演进:
dlmalloc → ptmalloc → ptmalloc2 → 现代 glibc malloc
其中,ptmalloc2 奠定了今天 glibc malloc 的主体结构,而 TCache 是 glibc 2.26 之后加入的重要优化。
始祖之作:dlmalloc(Doug Lea's Malloc)
如果把今天的 glibc malloc 比作一座现代化城市,那么一切都要从 Doug Lea 在 90 年代设计的 dlmalloc 开始。
在那个年代:
- 多核 CPU 还没有普及
- 高并发服务器并不常见
- 大部分程序都是单线程运行
因此,Allocator 面临的问题远没有今天这么复杂。
Doug Lea 需要解决的核心问题其实只有两个:
- 如何快速找到合适大小的空闲内存?
- 如何减少内存碎片?
围绕这两个目标,dlmalloc 建立了一套后来影响深远的设计。
单堆模型(Single Heap)
dlmalloc 的架构非常简单:
整个进程只有一个 Heap。
Process
│
▼
+----------------+
| Heap |
+----------------+
所有的内存申请和释放都发生在这块区域中。
与今天复杂的 Arena、TCache 相比,它更像一个大型仓库:所有人共用的一个仓库。
Chunk:Allocator 的基本管理单元
为了管理 Heap,dlmalloc 将内存划分为一个个 Chunk,可以简单理解为:
+-----------------------+
| Header(size | flags) |
+-----------------------+
| User Payload |
+-----------------------+
| Footer(概念示意) |
+-----------------------+
其中:
- Header 记录 Chunk 大小
- Payload 存放用户数据
- 边界信息用于后续合并空闲块
这里采用的是经典 Boundary Tag 示意图。
需要说明的是:
现代 ptmalloc2 的已分配 Chunk 并不一定保留 Footer,本文仅用于帮助理解其设计思想。
Bins:提高查找效率
如果 Heap 中有成千上万个 Chunk,每次 malloc 都从头扫描:
Chunk1
Chunk2
Chunk3
...
Chunk10000
效率显然无法接受,因此 dlmalloc 引入了 Bin 机制。
按照 Chunk 大小分类管理:
16B Bin
32B Bin
64B Bin
128B Bin
申请内存时:
malloc(64)
直接去对应 Bin 查找即可。
这就是后来各种 Fast Bin、Small Bin、Large Bin 的雏形。
Tree Bin:Best-Fit 的尝试
对于较大的 Chunk。
dlmalloc 进一步引入 Tree Bin。
512B
/ \
384B 768B
这样可以快速找到:最接近用户请求尺寸且足够大的 Chunk,即经典的 Best-Fit 策略。
需要特别说明:
Tree Bin 是 dlmalloc 的设计。在后来的 ptmalloc 和 ptmalloc2 中,这套结构逐渐被 Large Bin 体系所取代。
内存碎片
Allocator 不仅要快,还要尽量避免:
Heap
[Free 64B]
[Used 32B]
[Free 64B]
[Used 16B]
这种碎片化布局。
因此 dlmalloc 引入了 Boundary Tag 机制,当两个相邻 Chunk 同时释放时:
[Free 64B]
[Free 32B]
自动合并:
[Free 96B]
这就是 Coalescing(合并),也是后续所有 Allocator 都保留下来的核心思想。
单线程时代的终结
在单线程时代,dlmalloc 表现非常优秀。但随着 Web Server 和多核 CPU 的兴起,一个新的问题出现了。
假设有四个线程 Thread1、Thread2、Thread3 和 Thread4 同时调用 malloc(),由于整个进程只有一个 Heap,因此 Allocator 只能引入全局锁,就像下面这样:
lock(global_mutex);
...
unlock(global_mutex);
结果就是这四个线程在排队等待内存分配,随着 CPU 核数越来越多,而 Allocator 却只能一次服务一个线程,这最终成为 dlmalloc 最大的性能瓶颈,于是,Arena 机制登场了。
支持多线程:ptmalloc(Pthreads Malloc)
随着多核 CPU 的普及,dlmalloc 的单全局锁设计逐渐成为性能瓶颈。
在 dlmalloc 中,无论多少线程同时执行 malloc/free,最终都需要竞争同一把锁。当线程数量增加时,大量时间会消耗在锁等待上,而不是实际的内存分配工作上。
为了解决这一问题,Wolfram Gloger 在 dlmalloc 的基础上开发了 ptmalloc(Pthreads Malloc),并最终成为今天 GLIBC 默认使用的内存分配器。
核心创新:Arena(分配区)
ptmalloc 最重要的改进是引入了 Arena(分配区) 机制。
每个 Arena 都维护自己独立的:
- 空闲 Chunk 管理结构
- Top Chunk
- Arena Lock
这样,不同线程可以在不同 Arena 上完成内存分配,从而将锁竞争限制在单个 Arena 内部。
Process
│
┌───────────────┼───────────────┐
│ │ │
Arena 0 Arena 1 Arena 2
(Main) (Non-main) (Non-main)
│ │ │
Heap Heap Segments Heap Segments
需要注意的是:Arena 并不等同于 Heap。
Main Arena 使用传统的 brk/sbrk 扩展主堆,而 Non-main Arena 则通过 mmap 创建内存区域。当 Arena 内存不足时,还可以继续申请新的 Heap Segment,因此一个 Arena 可能管理多块 Heap。
Arena 的获取流程
线程执行 malloc() 时,并不是永久绑定某个 Arena,而是优先复用最近使用的 Arena:
malloc()
│
▼
优先获取当前线程最近关联的 Arena
│
▼
Arena 空闲?
┌──┴──┐
│ Yes │
└──┬──┘
▼
加锁并分配
No
│
▼
是否允许创建新 Arena?
┌──┴──┐
│ Yes │
└──┬──┘
▼
创建新 Arena
No
│
▼
遍历已有 Arena
尝试获取锁
因此:
ptmalloc 并不是“一线程一 Arena”,而是“线程优先复用 Arena,必要时多个线程共享 Arena”。
Arena 数量限制
如果 Arena 可以无限创建,那么系统内存会迅速膨胀。
因此 GLIBC 对 Arena 数量设置了上限:
#define NARENAS_FROM_NCORES(n) \
((sizeof(long) == 4) ? 2 * (n) : 8 * (n))
通常情况下:
- 64 位系统:Arena 上限 = CPU 核数 × 8
- 32 位系统:Arena 上限 = CPU 核数 × 2
例如在一台 16 核服务器上:
64位系统:
16 × 8 = 128 Arenas
32位系统:
16 × 2 = 32 Arenas
达到上限后,新线程只能复用已有 Arena,并开始产生锁竞争。
分配流程
简化后的 Arena 分配逻辑如下:
void* ptmalloc_malloc(size_t size) {
arena_t *arena = arena_get();
lock(arena->mutex);
chunk = allocate_from_bins(arena, size);
unlock(arena->mutex);
return chunk;
}
与 dlmalloc 相比,锁粒度从“整个进程一把锁”变成了“每个 Arena 一把锁”。
优势
相比 dlmalloc:
dlmalloc
↓
所有线程竞争同一把锁
ptmalloc
↓
多个 Arena 分摊锁竞争
锁竞争从:
O(线程数)
降低为:
O(线程数 / Arena数量)
这使得多线程环境下的内存分配性能获得了显著提升。
不过随着服务器 CPU 核数不断增加,即使有 Arena 机制,高频小对象分配仍然会产生锁竞争。为进一步优化这一问题,后续版本的 GLIBC 又引入了 Tcache(Thread Cache),让大量小对象分配直接在每线程私有缓存中完成,实现真正意义上的无锁分配。
走向现代:ptmalloc2 与多级缓存体系
随着多核服务器和高并发应用的发展,仅靠 Arena 已经无法彻底解决内存分配的性能问题。
虽然 Arena 将全局锁拆分成了多把局部锁,但线程在同一个 Arena 内进行高频小对象分配时,依然会产生锁竞争。
因此,后续的 ptmalloc2 在 Arena 机制之上,又构建了一套层次复杂的缓存与分箱体系,尽可能让内存分配在用户态完成,并减少锁竞争和系统调用次数。
现代 GLIBC 中的 malloc(),本质上已经演变为一个多级缓存系统。
多级缓存架构
malloc()
│
▼
TCache
│
▼ miss
Fast Bins
│
▼ miss
Unsorted Bin
│
▼ miss
Small Bins / Large Bins
│
▼ miss
Top Chunk
│
▼ miss
brk / mmap
设计目标非常明确:
尽可能复用已经存在的内存块,只有在万不得已时才向操作系统申请新的内存。
TCache(Thread Cache)
TCache 是现代 GLIBC 最重要的优化之一(glibc 2.26 引入)。
每个线程都维护自己的私有缓存:
Thread
└── TCache
├── Bin 0
├── Bin 1
├── Bin 2
└── ...
特点:
因此大量小对象分配流程变成:
malloc()
│
▼
TCache 命中
│
▼
直接返回
整个过程甚至不需要进入 Arena。
Fast Bins
Fast Bin 用于缓存较小的 Chunk。
其核心思想是:用空间换时间。
当小对象被释放时:
free(chunk)
│
▼
直接挂入 Fast Bin
此时:
- 不进行相邻块合并(Coalescing)
- 不修改复杂链表结构
- O(1) 完成释放
Fast Bin 本质上是一组按尺寸分类的单链表。
释放时直接头插,分配时直接从链表头取出,因此无需执行 Chunk 合并(Coalescing),时间复杂度接近 O(1)。
也正因为如此,它获得了极高的速度,但代价是会产生更多内存碎片。
Unsorted Bin
Unsorted Bin 可以理解为:最近释放内存块的一级缓存。
大多数释放的 Chunk 会优先进入这里:
free(chunk)
│
▼
Unsorted Bin
当新的分配请求到来时:
malloc(size)
│
▼
扫描 Unsorted Bin
如果发现合适大小的 Chunk:
直接复用
否则再将其分类转移到 Small Bin 或 Large Bin。
这种设计利用了程序的局部性特征:刚释放的内存,很可能很快再次被申请。
Small Bins
Small Bin 管理固定尺寸等级的小对象。
16B
24B
32B
40B
...
每种尺寸对应独立链表:
Small Bin[32]
Small Bin[48]
Small Bin[64]
...
查找过程无需遍历:
size → Bin Index
时间复杂度接近 O(1)。
Large Bins
Large Bin 管理较大的 Chunk。
由于大对象尺寸差异较大:
4KB
8KB
20KB
128KB
...
无法像 Small Bin 一样精确分类。
因此 Large Bin 会按照尺寸范围组织链表,并维护额外的大小排序信息,从而支持近似 Best-Fit 查找:
寻找 >= Request Size
且最接近的 Chunk
这样既减少内存浪费,也降低碎片率。
Top Chunk:最后一道防线
如果所有 Bin 都无法满足请求:
malloc()
│
▼
所有 Bin Miss
则从 Arena 的 Top Chunk 中切割空间:
Top Chunk
┌──────────────────────┐
│ Unused Area │
└──────────────────────┘
如果 Top Chunk 也不足,就需要继续向操作系统申请内存。
对于 Main Arena,通常通过 brk/sbrk 扩展主堆;对于 Non-main Arena,则通常通过 mmap 申请新的 Heap Segment。
而对于超过 mmap_threshold 的大块内存申请,则可能直接通过 mmap() 创建独立映射区域,而不经过常规 Arena 分配路径,向操作系统申请新的内存。
简化后的分配流程
void* malloc(size_t size) {
if (chunk = tcache_alloc(size))
return chunk;
if (chunk = fastbin_alloc(size))
return chunk;
if (chunk = unsortedbin_alloc(size))
return chunk;
if (chunk = small_or_large_bin_alloc(size))
return chunk;
return alloc_from_top_or_system(size);
}
性能收益
现代 GLIBC 的内存分配已经形成了:
Thread Cache
↓
Arena Cache
↓
System Memory
三级缓存架构。
绝大多数小对象分配都会在 TCache 中完成,完全无需加锁;只有缓存未命中时才会进入 Arena 层竞争锁。
这种设计极大降低了:
- 锁竞争
- 系统调用次数
- CPU Cache Miss
- 内存分配延迟
不过随着服务器规模继续扩大,Arena Lock、内存碎片以及 NUMA 感知不足等问题依然存在。这也是后来 jemalloc 和 tcmalloc 等现代分配器进一步优化的方向。
回头看整个 glibc Allocator 的演进,会发现它其实一直在解决同一个问题:如何让更多线程更快地拿到内存,同时又尽量避免内存碎片。
为了解决这个问题:
- dlmalloc 引入 Bin 和 Chunk 管理体系
- ptmalloc 引入 Arena,拆掉全局锁
- ptmalloc2 引入 Fast Bin,加速小对象回收
- glibc 2.26 引入 TCache,实现真正意义上的线程本地无锁分配。
于是今天一次看似简单的:malloc(64);
背后可能会经历:
TCache → FastBin → SmallBin → Arena → brk/mmap
理解这些设计并不是为了记住源码细节。
而是在下一次看到:
- CPU 不高但 malloc 很慢
- Arena 数量暴涨
- RSS 居高不下
- strace 里全是 mmap/munmap
这些现象时,能够知道它们究竟来自哪里。
毕竟,Allocator 的历史,本质上就是一部不断在「性能」与「碎片」之间寻找平衡的历史。
更多内存分配器的实战探讨,欢迎到 云栈社区 继续学习交流。