找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4165

积分

0

好友

545

主题
发表于 1 小时前 | 查看: 3| 回复: 0

几年前写过一篇文章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 的历史,本质上就是一部不断在「性能」与「碎片」之间寻找平衡的历史。

更多内存分配器的实战探讨,欢迎到 云栈社区 继续学习交流。




上一篇:嵌入式开发进阶:GCC扩展语法、Little Kernel与内存管理要点
下一篇:H5渗透实战:水电卡系统负数金额漏洞与签名绕过分析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-6-25 03:15 , Processed in 0.782592 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表