C++ 这门语言,之所以在很多老程序员眼里“又爱又恨”,很大程度上就是因为你能直接摸到内存这块“烫手山芋”。Java、Go 这些语言帮你把内存收拾得干干净净,你只管 new 一个对象出来用,不用管它怎么没的。但在 C++ 里不行,你 new 出来的每一个字节,都得自己心里有数。今天咱们不聊那些高深莫测的底层原理,就从一个最朴素的困惑开始:new 一个对象,到底发生了什么?为什么有时候程序跑着跑着就崩了?为什么我明明把内存释放了,内存占用却不降?带着这些疑问,我们一步步把 C++ 内存管理这层窗户纸捅破,再看看怎么给自己写一个趁手的内存分配器,让程序跑得又快又稳。
一、先别急着骂 new,它其实是个“二道贩子”
很多朋友觉得 new 就是“申请内存”,这么理解没毛病,但不完整。new 实际上干了两件事:第一步,它去找操作系统要一块足够大的、连续的内存空间;第二步,在这块内存上调用构造函数,把对象真正“造”出来。delete 则是反着来:先调用析构函数,把对象里那些需要清理的资源(比如文件句柄、网络连接)释放掉,然后再把内存还给系统。
这里有个特别容易踩的坑:你 new 一个对象数组,比如 new Foo[10],编译器需要在数组前面多分配一点空间,用来记录数组长度,这样 delete[] 的时候才知道要调用几次析构函数。如果你 new 和 delete 不匹配,比如用 new[] 分配,却用 delete 释放,轻则内存泄漏,重则直接崩溃。所以记住一个铁律:new 配 delete,new[] 配 delete[]。
// 技术栈:C++11 及以上
#include <iostream>
class Demo {
public:
Demo() { std::cout << "构造函数被调用" << std::endl; }
~Demo() { std::cout << "析构函数被调用" << std::endl; }
};
int main() {
// 单个对象:分配内存 + 构造
Demo* ptr = new Demo;
// 释放:析构 + 归还内存
delete ptr;
// 对象数组:分配连续内存 + 逐个构造
Demo* arr = new Demo[3];
// 释放:逐个析构 + 归还内存
delete[] arr;
return 0;
}
这段代码里,你能很清楚地看到构造和析构的调用顺序。但底层的内存分配,new 实际上调用的是 operator new,它默认的实现就是 malloc。也就是说,你天天骂的 new 性能问题,根源其实在 malloc 那里。操作系统分配内存是一个比较重的操作,涉及到内核态和用户态的切换,频繁调用肯定慢。
二、操作系统给内存的“批发价”和“零售价”
想要理解自定义内存分配器为什么能提速,你得先明白一个概念:操作系统给进程内存,是按“页”批发的,一页通常是 4KB。也就是说,哪怕你只需要 1 个字节,malloc 也会向操作系统要一页,然后在这一页里帮你切出一小块给你。剩下的空间,malloc 自己管理起来,等你下次再要小内存时,它就能从这块“存货”里切给你,而不用再去找操作系统。
这个机制本身没问题,但有两个痛点。第一,频繁地 malloc/free 会产生内存碎片。你想想,内存就像一块大奶酪,你戳一个洞,他戳一个洞,最后留下很多细小的孔,但这些孔太小,装不下稍微大一点的数据。结果就是,虽然总内存看着还不少,但想申请一块连续的较大内存时,却申请不到。第二,malloc 为了管理这些零碎的内存块,内部需要维护一些链表或数据结构,每次分配和释放都要去查这些结构,多线程环境下还要加锁,这个开销在一些高频率小对象场景下,可能比业务本身还要贵。
三、自己动手,写一个“内存小卖部” —— 自定义内存分配器
自定义内存分配器,说白了就是自己先向操作系统批发一大块内存,然后自己在程序里零售。这样你就能完全掌控怎么切、怎么还,甚至可以不还,直接复用。这在游戏引擎、高频交易系统里非常常见。
3.1 最简单的固定大小对象分配器
假如你的程序里需要频繁地创建和销毁某个小对象,比如子弹、粒子、网络数据包,这些对象大小都一样,那我们可以专门为这种对象写一个分配器。思路很简单:预先分配一块大内存,切成固定大小的块,用链表把这些空块串起来。分配时从链表头部摘下一块,释放时再挂回链表。
// 技术栈:C++11 及以上
#include <cstddef>
#include <new> // 为了使用 placement new
// 固定大小对象的内存池
class FixedPool {
private:
struct Block {
Block* next; // 空闲块链表指针
};
Block* freeList = nullptr; // 指向第一个空闲块
char* pool = nullptr; // 底层大内存
size_t blockSize;
size_t poolSize;
public:
FixedPool(size_t blockSize, size_t blockCount)
: blockSize(blockSize), poolSize(blockSize * blockCount) {
// 一次性向系统申请一大块内存
pool = static_cast<char*>(::operator new(poolSize));
// 把这块内存切成 blockCount 个固定大小的块
// 并串成链表
char* current = pool;
for (size_t i = 0; i < blockCount; ++i) {
Block* b = reinterpret_cast<Block*>(current);
b->next = freeList;
freeList = b;
current += blockSize;
}
}
~FixedPool() {
::operator delete(pool); // 整块归还,简单粗暴
}
// 分配一块内存
void* allocate() {
if (freeList == nullptr) {
// 没有空闲块了,可以扩容,这里直接返回 nullptr 或者断言
return nullptr;
}
Block* block = freeList;
freeList = block->next;
return block;
}
// 释放一块内存,其实就是把它放回链表
void deallocate(void* ptr) {
Block* block = static_cast<Block*>(ptr);
block->next = freeList;
freeList = block;
}
// 禁用拷贝
FixedPool(const FixedPool&) = delete;
FixedPool& operator=(const FixedPool&) = delete;
};
// 一个测试用的简单对象
class Bullet {
public:
int x;
int y;
int damage;
Bullet(int px, int py, int d) : x(px), y(py), damage(d) {}
};
int main() {
// 创建能容纳 100 个 Bullet 的内存池
FixedPool pool(sizeof(Bullet), 100);
// 从池中分配原始内存
void* mem = pool.allocate();
// 在分配的内存上构造 Bullet(placement new)
Bullet* bullet = new (mem) Bullet(10, 20, 5);
// ... 使用 bullet ...
// 手动调用析构函数(因为内存来自池子,不能 delete)
bullet->~Bullet();
// 把内存归还给池子
pool.deallocate(mem);
return 0;
}
看到了吗?这样就不用每次都去 malloc 或者 new 了,分配和释放的效率极高,只是链表的头尾操作。而且因为所有块大小一样,内存碎片问题也不存在。
3.2 配合标准容器的自定义分配器
标准库里的 std::vector、std::map 这些容器,默认用的都是 std::allocator,它最终会调到 operator new 也就是 malloc。我们其实可以把自己的分配器传给它们,让它用我们的内存池。这样,你的容器的内存管理就完全由你掌控了。
自定义分配器需要满足 C++ 标准里规定的一些接口,写起来有点繁琐,但核心就那几个方法:allocate、deallocate、rebind、value_type。下面是一个能配合 std::vector 工作的最小实现。
// 技术栈:C++11 及以上
#include <vector>
#include <cstddef>
// 一个简单的栈式内存池,用来给容器服务
class StackPool {
private:
char* buffer;
size_t capacity;
size_t offset; // 当前分配位置
public:
StackPool(size_t cap) : capacity(cap), offset(0) {
buffer = static_cast<char*>(::operator new(cap));
}
~StackPool() {
::operator delete(buffer);
}
// 分配 size 字节,按 max_align_t 对齐(简单处理)
void* allocate(size_t size) {
if (offset + size > capacity) {
throw std::bad_alloc();
}
void* ptr = buffer + offset;
offset += size;
return ptr;
}
void deallocate(void*, size_t) noexcept {
// 栈式分配,不支持单独释放,直接忽略
// 注意:这只适合容器一次性用完释放整个池的场景
}
StackPool(const StackPool&) = delete;
StackPool& operator=(const StackPool&) = delete;
};
// 自定义 allocator
template <typename T>
struct PoolAllocator {
using value_type = T;
StackPool* pool; // 指向外部池子
PoolAllocator(StackPool* p) : pool(p) {}
// 模板构造函数,用于 rebind
template <typename U>
PoolAllocator(const PoolAllocator<U>& other) : pool(other.pool) {}
T* allocate(size_t n) {
// 分配 n 个 T 对象所需的内存
return static_cast<T*>(pool->allocate(n * sizeof(T)));
}
void deallocate(T* ptr, size_t n) noexcept {
// 这里不真正归还,栈式池统一管理
(void)ptr;
(void)n;
}
// 友元声明,让不同特化版本可以互相访问 pool
template <typename U>
friend struct PoolAllocator;
};
// 需要重载 == 和 !=
template <typename T, typename U>
bool operator==(const PoolAllocator<T>& a, const PoolAllocator<U>& b) {
return a.pool == b.pool;
}
template <typename T, typename U>
bool operator!=(const PoolAllocator<T>& a, const PoolAllocator<U>& b) {
return !(a == b);
}
// 测试
struct MyData {
int id;
double values[4];
};
int main() {
// 创建一个 1MB 的栈式池
StackPool pool(1024 * 1024);
// 用我们的分配器创建一个 vector
std::vector<MyData, PoolAllocator<MyData>> vec(PoolAllocator<MyData>(&pool));
// 往里面塞数据,内存都从池子里来
for (int i = 0; i < 1000; ++i) {
vec.push_back(MyData{i, {1.0, 2.0, 3.0, 4.0}});
}
// 这里不需要手动释放,vector 析构时会把内存“还”给 allocator
// 我们的 allocator 不真正归还,池子在 main 结束时整体释放
return 0;
}
这个例子有点意思,但后患无穷。因为栈式分配器不支持单独的 deallocate,如果 vector 扩容,需要把旧元素复制到新内存并释放旧内存,而我们的 deallocate 什么都没干,旧内存就永远留在池子里了,这会导致池子很快被耗尽。所以实际开发中,这种栈式池一般配合“一次性使用”的场景,或者你自己实现一个更完善的池。
3.3 更实用的:空闲链表 + 多级缓存
真正在生产环境里用得多的,是一种分层的分配器。比如 TCMalloc(Google 的内存分配库),它的核心思想就是:为不同大小的小对象维护多个空闲链表,每个线程有自己的本地缓存,减少锁竞争。
咱们简化一下这个思路:根据对象大小,把内存划分成 16 字节、32 字节、64 字节、……多个档次,每个档次一个固定大小池。分配时根据请求大小找对应的池子,释放时按指针所在的位置判断属于哪个池子,放回去。这样既灵活又高效。
// 技术栈:C++11 及以上
#include <cstddef>
#include <vector>
#include <map>
#include <mutex>
#include <new>
// 极简多级内存分配器
class SizePool {
private:
std::mutex mutex; // 为了多线程安全
struct Block {
Block* next;
};
std::map<size_t, Block*> freeLists; // 大小 -> 空闲链表头
std::vector<void*> allocatedChunks; // 记录所有从系统申请的大内存块
size_t alignTo(size_t size) {
// 对齐到 8 字节
return (size + 7) & ~(size_t)7;
}
public:
~SizePool() {
// 归还所有大内存块
for (void* chunk : allocatedChunks) {
::operator delete(chunk);
}
}
void* allocate(size_t size) {
// 对齐大小
size_t alignedSize = alignTo(size);
std::lock_guard<std::mutex> lock(mutex);
Block*& head = freeLists[alignedSize];
if (head) {
// 有空闲块,直接拿走
Block* block = head;
head = block->next;
return block;
}
// 没有空闲块,向系统批一大块(比如 4KB)然后切成 alignedSize 大小
constexpr size_t chunkSize = 4096;
void* chunk = ::operator new(chunkSize);
allocatedChunks.push_back(chunk);
// 把 chunk 切成多个块,挂到链表上
char* p = static_cast<char*>(chunk);
size_t count = chunkSize / alignedSize;
for (size_t i = 0; i < count; ++i) {
Block* b = reinterpret_cast<Block*>(p + i * alignedSize);
b->next = head;
head = b;
}
// 从链头取一块返回
Block* result = head;
head = result->next;
return result;
}
void deallocate(void* ptr, size_t size) {
size_t alignedSize = alignTo(size);
Block* block = static_cast<Block*>(ptr);
std::lock_guard<std::mutex> lock(mutex);
block->next = freeLists[alignedSize];
freeLists[alignedSize] = block;
}
};
// 全局池实例
SizePool g_pool;
// 全面替换 operator new 和 operator delete(简单粗暴法)
void* operator new(size_t size) {
return g_pool.allocate(size);
}
void operator delete(void* ptr, size_t size) noexcept {
g_pool.deallocate(ptr, size);
}
// 也提供数组版本
void* operator new[](size_t size) {
return g_pool.allocate(size);
}
void operator delete[](void* ptr, size_t size) noexcept {
g_pool.deallocate(ptr, size);
}
// 测试
#include <iostream>
struct Point {
double x, y, z;
Point(double a, double b, double c) : x(a), y(b), z(c) {}
};
int main() {
// 现在所有 new 都会走我们的池了
Point* p1 = new Point(1.0, 2.0, 3.0);
int* arr = new int[100];
delete p1;
delete[] arr;
Point* p2 = new Point(4.0, 5.0, 6.0);
std::cout << "点坐标:" << p2->x << ", " << p2->y << ", " << p2->z << std::endl;
delete p2;
return 0;
}
上面这个例子重写了全局的 operator new 和 operator delete,这相当于把你整个程序的动态内存分配全部接管了。这种做法的好处是,所有代码(包括标准库)的分配需求都会经过你的池子。风险也很明显:如果池子没写好,你的整个程序都会崩;而且这种全局锁在高并发下依然会有竞争问题。所以这只是个教学示例,真正的生产环境有更精巧的设计。
四、应用场景:到底什么时候该自己造轮子
自定义分配器不是银弹,别一上来就搞。如果你的程序只是偶尔分配几个小对象,或者大部分内存都是大块数组,那直接用 malloc 就够了。以下几种情况,才是自定义分配器发挥威力的舞台:
- 游戏引擎中的实体组件:每一帧要创建和销毁成千上万个子弹、敌人、特效,这些对象小且生命周期短。用内存池可以做到零碎片、常数时间分配。
- 网络服务器中的连接对象:每个连接都需要一个上下文对象,连接频繁建立和断开。配合对象池可以避免每次建立连接时都去操作系统要内存。
- 音视频解码:解码器需要大量缓冲区,这些缓冲区大小固定,反复使用。用内存池能减少分配和释放的 CPU 开销。
- 实时系统:不能在关键路径上等待 malloc 去合并内存碎片。自定义分配器可以预先分配好所需内存,运行时只从池里无锁取用。
五、技术优缺点:得与失要拎清
优点
- 性能提升明显:从池里拿一个块就是几次指针操作,比系统调用快好几个数量级。
- 避免内存碎片:固定大小的块反复使用,不会产生小碎片。
- 提高缓存命中率:由于分配的内存通常集中在一段连续的地址空间,访问时 CPU 缓存更容易命中。
- 可控性强:你可以统计分配次数、追踪内存使用情况,方便调试和监控。
缺点
- 实现复杂:要处理对齐、并发、扩容、块归还等一堆问题,写一个健壮的分配器远比想象中难。
- 容易踩坑:必须确保分配和释放严格配对,一旦归还错误地址,内存池就废了。
- 可能浪费内存:固定大小的池子如果大小定得太大,比如平均只需要 24 字节,你给 32 字节一个块,那每个对象就浪费 8 字节。
- 调试困难:如果出现越界写,可能不会立刻崩溃,而是破坏池子的空闲链表,最后在分配下一个对象时才炸,问题定位很痛苦。
六、注意事项:写给想自己动手的兄弟
- 对齐问题:C++17 之前,
malloc返回的内存能对齐任何基本类型。你自己切内存时也要保证对齐,至少要对齐到alignof(std::max_align_t),否则用 SSE、AVX 指令时会崩溃。C++17 之后还可以用alignas来辅助。 - 多线程安全:如果多个线程同时从池子里分配内存,必须加锁或者用线程本地存储(Thread Local Storage),否则链表会被撕碎。
- 句柄泄漏:如果你的池子分配了多个不同大小的类别,释放时必须知道它属于哪个类别。这就是为什么
operator delete在 C++14 里有了带 size 参数的重载,编译器会尽量把实际大小传给你。 - 不要混合使用:凡是从池子里来的内存,释放时也必须还给同一个池子。如果你在某个函数里把内存池的指针传出去,另外的地方可能不小心用
free去释放,那就麻烦了。 - 考虑使用 allocator_traits:如果写标准库分配器,尽量依托
std::allocator_traits来提供默认实现,这样可以少写很多样板代码。 - 先优化后量测:在优化性能之前,先用性能剖析工具(如 perf、Valgrind)找出真正的内存瓶颈。很多时候瓶颈是缓存不命中而不是分配速度。
七、文章总结
C++ 的内存管理,本质上就是“批发-零售”这个博弈。系统内存是批发市场,malloc 是二道贩子,而你的程序是最终消费者。默认情况下,这个二道贩子还算称职,但它不会为你的特定场景做定制优化。自定义内存分配器,就是绕过二道贩子,自己开一家特供商店,卖你专门需要的尺寸、专门类型的货物。
我们聊了 new/delete 的本质,聊了固定大小池和自定义 allocator,甚至重写了全局 new。你会发思,一旦你掌握了这种对内存的控制权,很多性能问题就迎刃而解。但与此同时,责任也变重了。内存管理的自由,在 C++ 里向来是一把双刃剑,用好了削铁如泥,用不好自伤其手。
所以,给你最实在的建议:除非你确实面临上面提到的那些场景,否则老老实实用 std::make_unique、std::make_shared 和容器。万一哪天你的程序真的被内存分配拖慢了,再回头看看这篇文章,从简单的固定大小池开始,一步一步打造一个只有你的程序才需要的专属内存仓库。那样,你才算真正会玩 C++ 了。
评论
围绕“C++底层内存管理new/delete机制与自定义内存分配器的架构优化实践”参与讨论