18.1 C++内存高级话题-new、delete的进一步认识
18.2 C++内存高级话题-new内存分配细节探秘与重载类内operator new、delete
18.3 C++内存高级话题-内存池概念、代码实现和详细分析
18.4 C++内存高级话题-嵌入式指针概念及范例、内存池改进版
18.5 C++内存高级话题-重载全局new/delete、定位new及重载
文章目录
2.new内存分配细节探秘与重载类内operator new、delete
2.1 new内存分配细节探秘
在main主函数中输入下面三行代码:
char* ppoint = new char[10];
memset(ppoint, 0, 10); //观察从哪里初始化
delete[] ppoint; //观察释放影响的内存位置
断点设置在第一行,按F5键开始调试程序,当程序执行流程停在断点行时,按F10键逐行向下执行程序,在内存1窗口中观看指针变量ppoint所指向的内存中的内容如图所示。
在图中可以看到ppoint所指向的内存起始地址是0x00BBF1E0,目前分配的是10字节内存,每字节内存中的内容都是00。
现在为了进一步观察内存,把内存地址提前40字节(能查看到更前面的内存中的内容),现在查看的内存地址是0x00BBF1E0,用该数字减40(十进制数字),得到的内存地址是0x00B4F188(这是十六进制数字),现在在内存1窗口中直接输入地址0x00BBF1B8来查看该地址内容如图。
上图中用横线标示出了分配给ppoint的10字节,其中的内容全部都是00。
此时此刻,按F10键执行代码行“delete[]ppoint;”,注意观察上图内存的变化,执行完成后,内存变成如下图所示。
通过上图可以注意到,释放一块内存,影响的范围很广,虽然分配内存的时候分配出去的是10字节,但释放内存的时候影响的远远不止是10字节的内存单元,而是一大片。
观察上图,看一看内存的分配与释放时临近内存的合并:
(1)上图(a)所示这5块表示分配出去了5块内存(一共new了5次),当然每次new的内存大小可以不同。
(2)上图(b)表示率先释放了第3块(中间那块)内存,所以中间那块内存的颜色看起来和其余4块有差别。
(3)再过一会儿,把第2块内存也释放了,看上图(c)。这时free函数(释放内存的函数)还要负责把临近的空闲块也合并到一起(把原来的第2、3块内存合并成一大块),这种与临近空闲内存块的合并是free函数的责任。这一点读者必须要知道。
所以可以看到,free一个内存块并不是一件很简单的事,free内部有很多的处理,包括合并数据块、登记空闲块的大小、设置空闲块首位的一些标记以方便下次分配等一系列工作。
通过上面的调试和观察得到一个结论:
分配内存这件事,假设分配出去的是10字节,但这绝不意味着只是简单分配出去10字节(而是比10字节多很多),而是在这10字节周围的内存中记录了很多其他内容,如记录分配出去的字节数等。
前面说过,分配内存最终还是通过malloc函数进行的。不同的编译器下的malloc函数,也许各有不同,但是大同小异,细微实现上可能千差万别,但是该做的事情是必须要做的,该有的步骤是必须要有的。
一般来说,分配10字节内存,编译器或者说真正负责分配内存的malloc函数可能会分配出如图所示的内存。
从上图可以看到,程序员只申请了10字节,但编译器处理的时候要额外多分配出许多内存空间来保存其他信息。不同的编译器可能这里的其他信息项不太一样,但是,请记住一个结论:编译器要有效地管理内存的分配和回收,肯定在分配一块内存之外额外要多分配出许多空间保存更多的信息。编译器最终是把它分出去的这一大块内存中间某个位置的指针返回给ppoint,作为程序员能够使用的内存的起始地址。也就是说,程序员拿到的ppoint的地址实际上是malloc所分配出去的地址中中间的某个地址。
不难想象,本来程序员申请10字节的内存,结果系统一共分配出来了40多字节,非常浪费内存,但是没办法,系统要做到正常地管理内存(分配、回收、调试查错),就需要这些信息!试想,一次申请1000字节,多浪费40字节,也还比较好接受,不算浪费太多,但若是程序员一次只申请1字节(如“char*ppoint=newchar;”),结果系统一下多分配出40多字节,浪费得实在太多。
当然,上图不是全部,如果是对象数组,可能在分配内存时不太一样,但是不管怎么说,这里追求的不是malloc究竟做了哪些细节的事情,只是要了解malloc大概做了什么事情。最终得到一个结论:分配内存时为了记录和管理分配出去的内存,额外多分配了不少内存,造成了浪费,尤其是对于频繁分配小块的内存,浪费就显得更加严重。
2.2 重载类中的operator new和operator delete操作符
在上一节中讲述了new关键字的调用关系如下
A *pa = new A();
operator new();
malloc();
A::A();
delete pa;
A::~A();
operator delete();
free();
上面只是“new A();”和“delete pa;”的调用关系,如果站在编译器的角度,把“new A();”和“delete pa;”翻译成C++代码,看一看。
new A();应该是如下的样子:
void * temp = operator new(sizeof(A));
A * pa = static_cast<A *>(temp);
pa->A::A();
delete pa;应该是如下的样子
pa->A::~A();
operator delete(pa);
在上一节中用到了一个类A,现在可以自己写一个类A的operator new和operator delete成员函数来取代系统的operator new和operator delete函数,自己写的这两个成员函数负责分配内存和释放内存,同时,还可以往自己写的这两个成员函数中插入一些额外代码来帮助自己获取一些实际的利益(后面会演示)。
这里演示一下如何写一个类A的operator new和operator delete成员函数来取代系统的operator new和operator delete函数。特别说明一下:因为new和delete本身称为关键字或者操作符,所以类A中的operator new和operator delete叫作重载operator new和operator delete操作符,但是这里将重载后的operator new和operator delete称为成员函数也没问题,这方面并没有太严格的规定。
目前,类A的内容如下:
class A
{
public:
};
int main()
{
A * pa = new A();
delete pa;
}
执行起来,一切正常。
看看笔者如何重载。这种写法比较固定,对于读者来说,一回生二回熟,这些代码当然一般很少在实际项目中使用,但作为向高阶C++程序员迈进,应该知道有这种写法:
class A
{
public:
static void* operator new(size_t size); //应该为静态函数,但不写static似乎也行,估计是编译器内部有处理,因为new一个对象时还没对象呢,静态成员函数跟着类走,和对象无关
static void operator delete(void* phead);
};
void* A::operator new(size_t size)
{
cout << "A::operator new被调用了" << endl;
A* ppoint = (A*)malloc(size);
return ppoint;
}
void A::operator delete(void* phead)
{
cout << "A::operator delete被调用了" << endl;
free(phead);
}
main主函数中的内容不变,设置断点进行调试,确定可以调用类A的operator new和operator delete成员函数,并且观察调用operator new时传递进去的形参size的值,发现是1(因为类A本身是1字节大小——sizeof(A)==1)。
向类A中增加public修饰的构造函数和析构函数:
class A
{
public:
static void* operator new(size_t size); //应该为静态函数,但不写static似乎也行,估计是编译器内部有处理,因为new一个对象时还没对象呢,静态成员函数跟着类走,和对象无关
static void operator delete(void* phead);
public:
A()
{
cout << "类A的构造函数执行了" << endl;
}
~A()
{
cout << "类A的析构函数执行了" << endl;
}
};
main主函数中代码不变。执行起来,结果如下,一切正常,类A的构造函数和析构函数都被正常地调用:
现在既然在类A中实现了operator new和operater delete,那么在new和delete一个类A对象的时候,就会调用程序员自己实现的类A中的operator new和operator delete。
如果程序员突然不想用自己写的operator new和operator delete成员函数了,怎样做到呢?当然不需要把类A中的operator new和operator delete注释掉,只需要在使用new和delete关键字时在其之前增加“::”(两个冒号)即可。两个冒号叫作“作用域运算符”,在new和delete关键字之前增加“::”的写法,表示调用全局的new和delete关键字。此时,就不会调用类A中的operator new和operator delete了。
在main主函数中增加如下代码:
A * pa2 = ::new A();
::delete pa2;
上面的代码不会再调用类A中的operator new和operator delete成员函数了,但依旧会执行类A的构造函数和析构函数。
至于重载operator new和operator delete有什么用,后续再说,现在读者只需要知道,可以重载类中的operator new和operator delete即可。
2.3 重载类中的operator new[]和operator delete[]操作符
在类A的外面增加这两个成员函数的实现:
{
A* pa = new A[3]();
delete[] pa;
}
这是因为上面这两行代码是为数组分配内存,这需要重载operator new[]和operator delete[]。笔者来实现一下,在类A定义的内部增加两个public修饰的成员函数声明:
static void* operator new[](size_t size);
static void operator delete[](void* phead);
void* A::operator new[](size_t size)
{
cout << "A::operator new[]被调用了" << endl;
A* ppoint = (A*)malloc(size);
return ppoint;
}
void A::operator delete[](void* phead)
{
cout << "A::operator delete[]被调用了" << endl;
free(phead);
}
读者可能注意到了,在这里写的这两个成员函数和不带“[]”的两个成员函数代码是完全相同的。
这里要特别注意这种数组操作符的调用流程,执行起来,结果如下:
从结果可以看到,operator new[]和operator delete[]只会被调用1次,但是类A的构造函数和析构函数会被分别调用3次,这一点千万别搞错,不要误以为3个元素大小的数组new的时候就会分配3次内存,而delete也会执行3次。
将断点设置在operator new[]函数体内,调试起来,观察形参size的值,发现是7。为什么会是7呢?因为这里创建的是3个对象的数组,每个对象占1字节,3个对象正好占用3字节。另外4字节是做什么用的呢?
跟踪到operator new[]里面的代码行“Appoint=(A)malloc(size);”,执行该行,观察ppoint的返回值,如图所示。
通过图可以观察到,ppoint返回的内存地址是0x011fece8。继续按F10键逐行调试,当程序执行完main主函数的“A *pa=newA[3]();”代码行,观察pa的返回值,如图所示。
通过图可以观察到,pa返回的内存地址是0x011fecec。
这是什么意思呢?也就是说真正拿到手的指针是0x011fecec,而0x011fece8实际上是编译器malloc分配内存时得到的首地址,这里9c比98多了4字节,4+3正好是7,等于operator new[]函数中形参size的值。
多出这4字节是做什么的?其实是记录数组大小的,数组大小为3,所以,这4字节(一个int或者unsigned int类型数据的大小)里面记录的内容就是3,可以想象,释放数组内存的时候必然会用到这个数字(3),通过这个数字才知道new和delete时数组的大小是多少,从而知道调用多少次类A的构造函数和析构函数。
如图所示,看看3这个数字记录在内存中的位置(03000000代表的就是3)。
所以,编译器在程序员的背后还是做了很多事情的。对象数组内存分配概貌如图
有了这些基础知识,就可以开始涉猎内存管理问题了