在性能优化这条路上绕了几年,我越来越觉得“内存分配器”这个词是被低估的。大多数人写 C++ 程序,对内存的认知止步于 new 和 delete、malloc 和 free,偶尔遇到内存暴涨或者性能毛刺,第一反应是“我是不是内存泄漏了”,很少有人会去想:默认的内存分配器到底是怎么工作的?它为什么慢?几十个线程同时 new 对象的时候,性能瓶颈到底卡在哪?
这个主题看着底层,但其实和日常开发离得特别近。我用一个实际例子说明:项目里有一段处理网络消息的代码,单线程跑很正常,一上多线程压测,吞吐量掉了接近一半。排查到最后,问题不是锁、不是算法,而是高频的 small object 分配让全局分配器成了瓶颈。后来接入了自定义的内存池,再压测数据直接翻了一倍。从那以后,我就把“内存分配器”当成了性能优化工具箱里的头号工具。
这篇文章适合三类人:想搞懂 new 和 malloc 背后原理的学生、正在做服务端或客户端性能优化的工程师、以及写通用库时需要自定义分配器的高级开发者。读完你会发现,内存分配这件事的复杂度远超想象,但也正因为如此,吃透它带来的收益同样远超想象。
1. 先看清分层:从一行 new 到底层内存,中间发生了什么
1.1 new 表达式、operator new 与 malloc 的区别
很多人以为 new 就是“申请内存”,其实这说法不完整。一个标准的 new 表达式,比如 auto obj = new MyClass();,编译器会拆成两步走:先调用 operator new 分配一块原始内存,拿到 void*;然后在这块内存上调用 MyClass 的构造函数。delete 则反过来,先调用析构函数,再调用 operator delete 释放内存。
这个拆分非常关键。它意味着我们可以只拦截“原始内存的分配与释放”这一步,而不影响对象构造和析构的语义。自定义内存分配器的本质,就是替换这个中间环节。
而 operator new 的默认实现,内部调用的又是 C 语言里的 malloc。所以完整链路是:
code复制new MyClass()
-> operator new(sizeof(MyClass))
-> malloc(size)
-> 操作系统的堆分配机制(brk / mmap)
理解这个链条有几层意义。第一,C++ 的分配并不比 C 的 malloc 更“高级”,无非是多了一层构造语义。第二,只要替换 operator new 或者给容器传入自定义 allocator,就能控制整条内存路径。第三,默认分配器的一切行为特征、性能瓶颈、碎片问题,本质上都是 malloc 层面带来的。
| 层级 | 职责 | 典型实现 |
|---|---|---|
| new 表达式 | 分配原始内存 + 构造对象 | 编译器生成代码 |
| operator new | 返回指定大小的原始内存 | 默认为 malloc |
| malloc/free | 管理堆内存块 | glibc ptmalloc / jemalloc / tcmalloc |
| 操作系统 | 提供虚拟内存页 | brk / mmap / VirtualAlloc |
1.2 glibc malloc 的本质:arena、bin 与系统调用
默认分配器里藏着很多有意思的设计。以 Linux 下最常见的 glibc malloc 为例,它并不会每次分配都去找操作系统要内存,那样效率太低,系统调用一次的成本动辄几百纳秒。它的策略是:先向操作系统申请一大块连续内存(专业术语叫 arena),然后在用户态把这块内存切成各种大小的 chunk,自己维护一套空闲链表。
这套机制里涉及几个重要概念。arena 分主 arena 和线程 arena,主 arena 用 brk 扩展堆段,线程 arena 用 mmap 映射匿名内存。每个 arena 里面有多种 bin:fastbin 是 LIFO 的单向链表,专门缓存小内存块;unsorted bin 是释放内存的临时中转站;small bin 和 large bin 按大小分桶管理空闲 chunk。除此之外还有一个 top chunk,相当于一整块待切割的“原石”。
当程序请求 24 字节时,malloc 并不会只给 24 字节。它要加上 chunk 头部的元数据(prev_size 和 size 字段,64 位下合计 16 字节),然后对齐到 16 字节边界,所以实际分配的可能是 32 字节甚至更多。这就是内部碎片的来源之一。另外,如果请求的大小超过阈值(glibc 的默认阈值约 128KB),malloc 会直接走 mmap 分配,返回时再 munmap 释放,看起来干净利落,但代价是每个大块分配都要经历两次系统调用,频繁分配大 buffer 时开销非常可观。
理解
