注意:这篇文章上次更新于949天前,文章内容可能已经过时。
C++ 中的 new
C++ 中使用 new 关键字来创建一个对象,创建对象的过程会产生以下操作:
- 在内存中申请一块空间,大小为目标对象的大小,并获得该内存空间的地址。
- 将该内存空间的地址转换为对象指针。
- 通过对象指针调用该对象的构造函数。
对于第一步来说,C++如何获得内存空间的呢?
答案是通过调用operator new(size_t size)
函数,其参数为要申请的空间大小,内部实现依然是通过 C 语言的 malloc 函数。值得一提的是,这个函数是可以进行重载的,这就为我们的内存管理留下了可操作空间。
默认情况下,new 表达式调用的是全局的 ::operator new(size_t size)
函数来申请内存空间,当我们在类中定义了成员函数 operator new(size_t size)
,编译器就会调用类中的成员operator new 函数来分配内存空间,所有内存管理的操作,也可以在成员operator new 函数中来实现。
delete 与 new 同理,会先通过指针调用该对象的析构函数,再调用 operator delete 函数来释放内存空间,其中 operator delete 也分为全局函数和成员函数,也支持重载,全局 operator delete 函数内部使用 C 语言的 free 来释放内存空间。
用代码表示为:
Foo* p = new Foo(x); // new 表达式,不可改变,不可重载
/*
* 一段其他逻辑后
*/
delete p;
上面这一行代码会被转化为:
Foo* p = (Foo*)operator new(sizeof(Foo)); // 调用 operator new 函数申请内存空间
new(p) Foo(x); // 此处为 placement new ,在new() 小括号中传入一个指针,在该指针所指的空间内调用构造函数创建对象。
/*
* 一段其他逻辑后
*/
p->~Foo(); // 调用析构函数
operator delete(p); // 调用operator delete 函数释放内存空间
我们所面临的问题是,当用户需要大量的创建 Foo 对象时,需要重复的反复的调用 malloc 函数来申请内存空间,这会带来两个问题:
- 调用 malloc 的次数太多,从而影响效率。(在 C++ 中申请和释放内存空间最终都是通过使用 C 语言的 malloc 和 free 函数来实现。其实 malloc 函数的执行速度并不慢,内存管理的目的也不是为了减少 malloc 的次数,当然,即使 malloc 函数的效率很高,减少它的调用次数也依然是好事情。)
- 浪费空间,C++为每个对象申请到的内存空间都会使用 cookie 来标志起始和结束位置,每个cookie各占用 4 个字节,如果你的对象数据成员很少的话,这将会是很大比例的浪费。
为了解决上面那两个问题,我们就需要定义成员operator new 和operator delete 函数,让 new 和 delete 的行为有我们自己控制。
内存管理的一些例子
第一个例子取材于 C++ Primer。
这个例子通过成员operator new 函数实现一次性申请足够大的空间(只有两个 cookie),在创建对象时将申请到的空间拿出一部分来构造对象。为了维护空间内的对象,为对象附加一个指针变量,使其形成链表的形式。这就是内存池的思想。
#include <cstddef>
#include <iostream>
using namespace std;
class Screen{
public:
Screen(int x):i(x){};
int get(){return i;}
void* operator new(size_t);
void operator delete(void*,size_t);
// ...
private:
// 为内存管理而设计的数据成员
Screen* next;
static Screen* freeStore;
static const int screenChunk;
private:
// 原本的数据成员
int i;
}
// 类外初始化静态成员变量
Screen* Screen::freeStore = nullptr;
const int Screen::screenChunk = 6;
void* Screen::operator new(size_t size){
Screen* p;
if(freeStroe){
// 内存池是空的,一次性申请 screenChunk 个 Screen 对象大小的内存空间
size_t chunk = screenChunk * size;
freeStore = p = reinterpret_cast<Screen*>(new char[chunk]);
// 将一大块内存空间分割成小块,当成链表串联在一起
for(; p != &freeStore[screenChunk-1];++p){
p->next = p+1;
}
p->next = 0;
}
p = freeStore;
freStore = freeStore->next;
return p;
}
void Screen::operator delete(void* p,size_t ){
// 将删除的对象插回 free list 的前端
(static_cast<Screen*>(p))->next = freeStore;
freeStore = static_cast<Screen*>(p);
}
这个版本的代码实现了基本的内存池,通过单向链表的方式维护了内存池,这种方式需要在类中专门定义一个用来维护内存池的指针,在本例中,用来维护内存池的数据成员和类原始数据成员一样大,显然一定程度上造成了空间的浪费。
下面的代码采用内嵌指针的方式解决上面代码中的问题。
class Airplane{
private:
struct AirplaneRep{ // 类的原始数据成员
unsigned long miles;
char type;
};
private:
union{ // 嵌入式指针
AirplaneRep rep; //
Airplane* next; //
};
public:
unsigned long getMiles(){
return rep.miles;
}
char getType(){return rep.type;}
void set(unsigned long m,chat t){
rep.miles = m;
rep.type = t;
}
public:
static void* operator new(size_t size);
static void operator delete(void* deadObject,size_t size);
private:
static const int BLOCK_SIZE;
static Airplane* headOfFreeList;
};
// 初始化静态成员变量
Airplane* Airplane::headOfFreeList;
const int Airplane::BLOCK_SIZE = 512;
void* Airplane::operator new(size_t size){
if(size != sizeof(Airplane)){ // 继承的情况下会出现不相等的情况
return ::operator new(size);
}
Airplane* p = headOfFreeList;
if(p){
// 如果 p 有效,把list 的头部下移一个单元
headOfFreeList = p -> next;
}
else{
// free list 已空,申请一大块内存
Airplane* newBlock = static_cast<Airplane*>
(::operator new(BLOCK_SIZE*sizeof(Airplane)));
// 将小块连成一个 freelist
// 跳过 第 0 块,因为它是本次 new 的结果
for(int i = 1;i < BLOCK_SIZE-1;i++){
newBlock[i].next = &newBlock[i+1];
}
newBlock[BLOCK_SIZE-1].next = 0;
p = newBlock;
headOfFreeList = &newBlock[1];
}
return p;
}
void Airplane::operator delete(void* deadObject,size_t size){
if(deadObject == 0) return;
if(size != sizeof(Airplane)){
::operator delete(deadObject);
return;
}
Airplane* carcass = static_cast<Airplane*>(deadObject);
carcass->next = headOfFreeList;
headOfFreeList = carcass;
}
在上面例子中,使用嵌入式指针的方式避免了额外的空间开销。
想象这样一种情况,使用者 new 了 10000 个对象,然后 delete 了 5000 个,又 new 了 6000 个,又 delete 10000 个…
这种情况下,每次释放的空间会呗插入到链表的头部,反复这样操作会出现一个链表长度的峰值,即使实际对象数量小于链表的长度,使得链表中有很多空闲空间,这样的情况也不算是内存泄漏。
当然,如果这些空闲的空间能想办法还给操作系统就更好了。
上面版本的内存管理操作已经很好了,但是对于不同的需要内存管理 class 都需要写一个这样的函数,为了避免过多的重复性的代码,我们把上述内存管理操作封装成类 allocator ,这样在需要内存管理的类中包含一个 allocator 对象即可。
class allocator{
private:
struct obj{
struct obj* next; // 内嵌指针
};
public:
void* allocate(size_t);
void deallocate(void*,size_t);
private:
obj* freeStore = nullptr;
const int CHUNK = 5;
}
void allocator::deallocate(void* p,size_t){
((obj*)p)->next = freeStore;
freeStore = (obj*)p;
}
void* allocator::allocate(size_t size){
obj* p;
if(!freeStore){
freeStore = p = (obj*)malloc(size*CHUNK);
for(int i=0;i<(CHUNK-1);++i){
p->next = (obj*)((char*)p + size);
p = p->next;
}
p->next = nullptr;
}
p = freeStore;
freeStore = freeStore->next;
return p;
}
// 对于其他需要内存管理的类来说 只需要在类中使用 allocator 成员即可。
class Foo{
public:
long L;
string str;
static allocator myAlloc; // 用于内存管理
public:
Foo(long l):L(l){}
// 重载这两个函数
static void* operator new(size_t size){
return myAlloc.allocate(size);
}
static void operator delete(void* p,size_t size){
return myAlloc.deallocate(p,size);
}
}
allocator Foo::myAlloc;
上面的设计比前两种的设计都干净多了,不再需要在分配内存的时候进行指针的类型的各种转型。
进一步可以发现,每个需要内存管理的类需要添加的内容依然很有规律性,它需要添加下面这部分代码:
public:
static void* operator new(size_t size){
return myAlloc.allocate(size);
}
static void operator delete(void* p,size_t size){
return myAlloc.deallocate(p,size);
}
protected:
static allocator myAlloc;
// 在类外添加一行静态成员的定义
allocator class_name::myAlloc;
于是为了更加的简洁,我们可以把上述代码写成宏的方式。
#define DECLARE_POOL_ALLOC() \
public: \
static void* operator new(size_t size){ \
return myAlloc.allocate(size); \
} \
static void operator delete(void* p){ \
return myAlloc.deallocate(p,0); \
} \
protected: \
static allocator myAlloc;
#define IMPLEMENT_POOL_ALLOC(calss_name) \
allocator class_name::myAlloc;
// 于是,我们的 Foo 类就可以写成
class Foo{
DECLARE_POOL_ALLOC()
public:
long L;
string str;
public:
Foo(long l):L(l){}
};
IMPLEMENT_POOL_ALLOC(Foo)
上面所有的例子中利用一条链表来维护内存空间,将其发展为 16 条链表,即下图的形式,并且不再以 class 内的 static 成员出现,而是一个全局的 allocator,这就是 std::alloc 的雏形。