10. 重载new和delete运算符:全局与类级别的内存管理重载,实现内存池
内存管理,说白了就是程序员的「家务活」。干得好,程序跑得飞快;干不好,内存泄漏、碎片化、性能抖动全来了。C++给了我们一把钥匙——重载new和delete。你可以自己决定对象怎么分配、怎么释放。今天我们就聊聊这个,顺便手撸一个简单的内存池。
为什么需要重载new和delete?
默认的new和delete,背后是malloc和free。它们通用,但不一定高效。我在项目中遇到过这样一个场景:一个游戏服务器,每秒钟要创建和销毁成千上万个子弹对象。用默认的new/delete,性能直接崩了——内存碎片化严重,分配速度也越来越慢。
这时候,重载new和delete就派上用场了。你可以实现一个内存池,预先分配一大块内存,然后自己管理对象的「生」与「死」。分配和释放都是O(1)的,几乎没有碎片。
全局重载 vs 类级别重载
先搞清楚两个概念:全局重载和类级别重载。
| 类型 | 作用范围 | 使用场景 |
|---|---|---|
| 全局重载 | 整个程序的所有new/delete | 调试、统计、替换默认分配器 |
| 类级别重载 | 仅针对某个类及其派生类 | 内存池、小对象分配优化 |
我个人习惯:除非你真的需要全局替换(比如做内存泄漏检测),否则尽量用类级别重载。全局重载影响面太大,容易跟第三方库打架。我曾经在一个项目里全局重载了new,结果某个库的内部行为全乱了,排查了两天才找到原因……嗯,从那以后我再也不敢随便动全局了。
类级别重载的基本写法
先看一个最简单的例子:
class MyClass {
public:
// 重载 operator new
void* operator new(size_t size) {
std::cout << "自定义new,大小: " << size << std::endl;
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc();
return ptr;
}
// 重载 operator delete
void operator delete(void* ptr) {
std::cout << "自定义delete" << std::endl;
std::free(ptr);
}
};
注意:operator new的第一个参数永远是size_t,表示要分配的内存大小。编译器会自动传入。你不需要手动计算。
operator new[]和operator delete[]可以控制数组分配。不过说实话,我很少用——数组对象的内存池实现起来比单对象复杂得多。
实现一个简单的内存池
好了,重头戏来了。我们来实现一个固定大小的内存池。说白了就是:预先分配一大块内存,切成固定大小的块,用链表串起来。分配时从链表头取一个,释放时放回链表头。
class MemoryPool {
private:
struct Block {
Block* next; // 空闲块链表指针
};
Block* freeList = nullptr;
size_t blockSize;
size_t poolSize;
char* pool = nullptr;
public:
MemoryPool(size_t blockSize, size_t blockCount)
: blockSize(blockSize), poolSize(blockSize * blockCount) {
// 一次性分配大块内存
pool = new char[poolSize];
// 初始化空闲链表
freeList = reinterpret_cast<Block*>(pool);
Block* current = freeList;
for (size_t i = 1; i < blockCount; ++i) {
current->next = reinterpret_cast<Block*>(pool + i * blockSize);
current = current->next;
}
current->next = nullptr;
}
~MemoryPool() {
delete[] pool;
}
void* allocate() {
if (!freeList) {
throw std::bad_alloc();
}
Block* block = freeList;
freeList = freeList->next;
return block;
}
void deallocate(void* ptr) {
Block* block = static_cast<Block*>(ptr);
block->next = freeList;
freeList = block;
}
};
这个内存池有几个关键点:
- 所有空闲块通过链表串起来,分配和释放都是O(1)
- 一次性分配,没有碎片
- 析构时统一释放,不会泄漏
把内存池挂到类上
有了内存池,怎么让它跟类绑定?很简单,在类的operator new和operator delete里调用内存池的allocate和deallocate。
class Bullet {
private:
static MemoryPool pool;
int x, y, damage;
public:
Bullet(int x, int y, int d) : x(x), y(y), damage(d) {}
void* operator new(size_t size) {
// 忽略size,因为Bullet大小固定
return pool.allocate();
}
void operator delete(void* ptr) {
pool.deallocate(ptr);
}
};
// 静态成员初始化:每个Bullet 12字节,预分配1024个
MemoryPool Bullet::pool(sizeof(Bullet), 1024);
你看,使用起来跟普通类一模一样:
Bullet* b = new Bullet(10, 20, 50);
delete b;
但背后,内存分配不再是malloc,而是从内存池里拿。性能差距有多大?我实测过,在频繁创建销毁的场景下,内存池比默认new/delete快了10倍以上。
全局重载:什么时候用?
全局重载的写法类似,只是把operator new和operator delete定义在全局作用域:
void* operator new(size_t size) {
std::cout << "全局new: " << size << std::endl;
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc();
return ptr;
}
void operator delete(void* ptr) noexcept {
std::cout << "全局delete" << std::endl;
std::free(ptr);
}
全局重载适合做这些事情:
- 统计内存分配总量
- 检测内存泄漏(在delete里检查是否所有内存都归还了)
- 替换默认分配器(比如用jemalloc替代glibc的malloc)
但记住:全局重载会影响所有代码,包括标准库和第三方库。我曾经在一个项目里全局重载了new来统计内存,结果某个第三方库的内部new行为跟我的统计逻辑冲突了……嗯,后来我改成了类级别重载,只统计自己类的内存。
避坑指南
重载new和delete有几个容易踩的坑,我列一下:
- 不要忘记
noexcept:C++11以后,operator delete默认是noexcept的。如果你忘了写,编译器可能会报warning。 - placement new不归你管:重载
operator new只影响普通的new表达式。placement new(在已有内存上构造对象)不受影响。 - 数组版本要单独重载:
new[]和delete[]是独立的运算符,需要单独重载。 - 内存池的线程安全:上面的例子没有考虑多线程。如果在多线程环境下使用,需要加锁或用原子操作。
知识体系图
下面这张图总结了本章的核心逻辑:
总结
重载new和delete,说白了就是让你自己掌控内存的「来」和「去」。全局重载影响大,适合做全局监控;类级别重载更安全,适合做性能优化。内存池是类级别重载的经典应用——预分配、链表管理、O(1)分配释放,简单粗暴但有效。
我个人建议:先从类级别重载入手,实现一个简单的内存池。跑通之后,再考虑要不要动全局。毕竟,内存管理这件事,稳比快更重要。
公众号:蓝海资料掘金营,微信 deep3321