【C/C++学习】之十五、内存管理
索引: 1、内存分配简介
2、内存分配常见错误
3、new()/delete()函数的使用
4、malloc()/free()函数的使用
|
在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。
栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清楚的变量的存储区。里面的变量通常是局部变量、函数参数等。
堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。
全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改
明确区分堆与栈
在bbs上,堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的,所以我决定拿他第一个开刀。
首先,我们举一个例子:
void f() { int* p=new int[5]; }
这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中,他在VC6下的汇编代码如下:
00401028 push 14h
0040102A call operator new (00401060)
0040102F add esp,4
00401032 mov dword ptr [ebp-8],eax
00401035 mov eax,dword ptr [ebp-8]
00401038 mov dword ptr [ebp-4],eax
这里,我们为了简单并没有释放内存,那么该怎么去释放呢?是delete p么?澳,错了,应该是delete []p,这是为了告诉编译器:我删除的是一个数组,VC6就会根据相应的Cookie信息去进行释放内存的工作。
好了,我们回到我们的主题:堆和栈究竟有什么区别?
主要的区别由以下几点:
1、管理方式不同;
2、空间大小不同;
3、能否产生碎片不同;
4、生长方向不同;
5、分配方式不同;
6、分配效率不同;
管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:
打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit。
注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。
碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。
生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。
分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。
虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。
无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)
在C/C++中,有以下几种内存分配方式:
1. 从静态存储区分配:此时的内存在程序编译的时候已经分配好,并且在程序的整个运行期间都存在。全局变量,static变量等在此存储。
全局区(静态区)( static )存放全局变量、静态数据、常量。程序结束后有系统释放
文字常量区 常量字符串就是放在这里的。 程序结束后由系统释放。
2. 在栈区分配:相关代码执行时创建,执行结束时被自动释放。局部变量在此存储。栈内存分配运算内置于处理器的指令集中,效率高,但容量有限。
3. 在堆区分配:动态分配内存。用new/malloc时开辟,delete/free时释放。生存期由用户指定,灵活。但有内存泄露等问题
栈区( stack ) 由编译器自动分配释放 ,存放为运行函数而分配的局部变量、函数参数、返回数据、返回地址等。其操作方式类似于数据结构中的栈。
堆区( heap ) 一般由程序员分配释放, 若程序员不释放,程序结束时可能由 OS 回收 。分配方式类似于链表。
动态分配时可能产生内存碎片,最终动态分配内存会浪费不必要的时间开销。
动态分配失败,需要检查返回值或捕获异常,这样也会造成额外的开销。
动态创建的对象可能被删除多次或者删除后继续使用,这样就会发生运行时错误或程序消耗内存。
int a = 0; // 全局初始化区 char *p1; // 全局未初始化区 int main() { int b; // 栈 char s[] = /"abc/"; // 栈 char *p2; // 栈 char *p3 = /"123456/"; //123456//0 在常量区, p3 在栈上。 static int c =0;// 全局(静态)初始化区 p1 = new char[10]; p2 = new char[20]; // 分配得来得和字节的区域就在堆区。 strcpy(p1, /"123456/"); //123456//0 放在常量区,编译器可能会将它与 p3 所指向的 /"123456/" }
内存分配常见错误:
1、内存泄露
在堆上对内存进行申请的时候,一定要提高警觉程度。 动态内存的申请与释放必须配对,程序中malloc 与free 、new与delete的一定要成对。千万防止光申请不释放的代码出现。
如果发生这种错误,函数每被调用一次就丢失一块内存。
我们来看一下如何防止:
我们将指针放进对象的内部并且让对象管理指针是最简单的防止内存泄露的方法。应把new返回的指针存储在对象的成员变量里,当需要时该对象的析构函数便能够释放掉该指针。
这种方法的优点就是将指针完全交给对象去管理,不需要担心在操作指针时出现内存泄露等问题。 但是指针仅有初步创建和释放的语义,通过把指针放进对象里可以保证该析构函数的执行及正确释放分配的内存。
C/C++中,内存管理器不会自动回收不再使用的内存,如果忘记释放不再使用的内存,这些内存就允许被重用,此时就会造成内存泄露。
2、内存越界
何谓内存访问越界,简单的说,你向系统申请了一块内存,在使用这块内存的时候,超出了你申请的范围。
读越界:读了不属于自己的数据。 如果读的内存地址是无效的就会崩溃。
写越界:也叫做 缓冲区溢出, 在写书的数据对别人来说是随机的,这样也会发生错误。
解决:遇到这种问题,首先你得找到哪里有内存访问越界,而一个比较麻烦得问题在于,出现错误得地方往往不是真正内存越界得地方。对于内存访问越界,往往需要进行仔细得代码走查、单步跟踪并观察变量以及在调试环境得帮助下对变量进行写入跟踪
char b[16]="abcd"; memset(b, 0,32);//越界
3、野指针
使用free或delete释放了内存后,没有将指针设置为NULL,也就是指向不可用内存区域的指针。
野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。野指针的成因主要有三种:
1、指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。
char *p =NULL; char *str = (char *)malloc(100); char *p =NULL; char *str = (char *)malloc(100);
2、指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。
3、指针操作超越了变量的作用范围。这种情况让人防不胜防。
class A { public void Func(void) { cout << "Func of class A" << endl; } }; void Test(void) { A *p; { A a; p = &a; // 注意 a 的生命期 } p->Func(); // p是“野指针” }
在Test执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了野指针。
4、分配不成功,使用该内存
编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行。
如果使用malloc()或new()来申请内存,应该使用if(q == NULL)或if(q != NULL)进行防错处理。
5、分配成功,但未初始化
犯这种错误主要有两个起因:
一是没有初始化的观念;
二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。
内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
6、试图修改常量
在函数参数前加上const修饰符,只是给编译器做类型检查用的。编译器进制修改这样的变量,但是并不强制,我们完全可以使用强制类型转换来处理,一般不会出错。
然后,我们的全局常量和字符串使用强制类型转换来处理在运行时仍然会出错。因为他们是放在“rodata”里面的,而“rodata”内存页面时不允许修改的。
7、返回指向临时变量的指针
栈里面的变量是临时的,当前函数执行完成时,相关的临时变量和参数都被清除了。不允许把只想这些临时变量的指针返回给调用者,因为这样的指针指向的数据是随机的,会给程序带来严重后果。
8、对同一指针删除两次
A* a = new A(); A* b = a; delete a; delete b;上面的代码就出现了两次删除同一个指针的问题了。
我们第一次delete会安全的释放掉*a,并且由a所指向的内存会被安全地返回到堆上。 而我们并没有返回该指针new的操作就把相同的指针第二次传递到delete()函数中,而且把之前*a中的对象传递给析构函数,然后把由a指向的内存第二次传递给该堆,这样的操作是灾难性的,因为这可能会破坏该堆及其自由内存表。
当我们不再使用指针并试图把该指针删除时,一定要慎重地考虑如何删除,而且一个指针只能删除一次。
9、p指向数组,调用delete p
当指针p指向数组时,不允许执行delete p操作。这是因为当p指向某种内置类型的数组时不能省略delete p[]中的[]。
在delete p中,底层解除分配原语是operator delete (void*),而delete [] p底层解除分配原语是operator delete[] (void*)。
10、内存耗尽
解决方法:
1、判断指针是否为NULL, 如果为NULL, 则使用exit(1)函数终止整个程序的运行。
2、判断指针是否为NULL, 如果为NULL, 则使用return 语句终止本函数。
3、为new和malloc()函数预设异常处理函数。
4、捕获new抛出的异常信息,并试图恢复。
new()/delete()函数的使用:
来看一下new的使用:
int *p = new int [length];
他相对于下面的malloc简单多了,这是因为new内置了sizeof,类型转换和类型安全检查功能,对于非内部数据类型而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式:
class A
{
public:
A(void);
A(int x);
...
};
void T(void)
{
A *a = new A;
A *b = new A(1);
...
delete a;
delete b;
}
但是当我们用new创建对象数组的时候,只能使用对象的无参数构造函数:
A *a = new A[100];
我们不能创建的同时赋初值:
A *a = new A[100](1);
创建了当然就要销毁了:
delete [] a;
当我们使用delete去delete this时,要注意哪些问题?
1、必须使用new分配的this对象,而不是用new[]。
2、包含delete this的成员函数必须是在this对象上最后调用的成员函数。
3、在delete this后,该成员函数的剩余部分一定不要接触this对象的任何部分,包括调用任何其他成员函数或者接触任何数据成员。
4、在delete this后没有其他部分代码并且不能检查this指针本身。
5、确保没有其他人对该对象进行删除。
delete this还会用在线程的消亡上,当线程由于某种原因不能正常结束的时候,会选择该方式结束,释放一些资源。
malloc()/free()函数的使用:
先来看一下malloc:
void* malloc(size_t size);
int *p = (int*)malloc(sizeof(int) * length);
我们用malloc申请了一块长度为length的整形类型的内存, 因为malloc()返回的类型是void* 所以在调用函数的时候要进行类型转换。
malloc函数本身并不识别要申请的内存是什么类型,只关心内存的总字节数。这里我们就可以用sizeof。
free函数原型:
void free(void* memblock);
可以看出,free没有malloc复杂,因为指针p的类型以及它所指向的内存的容量事先都知道,语句free(p)能正确地释放内存。如果p是NULL指针,那么free()函数对p无论操作多少次都不会出问题,如果不是NULL指针,那么free函数对p连续操作两次就会导致程序运行错误。
malloc与free是C/C++语言的标准库函数,new与delete是C++的运算符,它们都可以用于申请动态内存和释放内存。
对于非内存数据类型的对象而言,光是用malloc/free无法满足动态对象的要求,对象在创建的同时要自动执行构造函数,兑现公仔消亡前也要自动执行析构函数。由于malloc和free是库函数不是运算符,不在编译器控制权之内,不嫩巩固把执行构造函数和析构函数的任务强加于malloc和free;
C++需要一个能完成动态内存分配和初始化工作的运算符new,以及能完成清理与释放内存工作的运算符delete。
在这里,如果我们用free释放new创建的动态对象,那么对象因无法执行析构函数而可能导致程序出错。如果用delete释放malloc申请的动态内存,理论上可行,但是可读性差。
2012/10/15
jofranks 于南昌