一、内存分配方式
在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。
栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
堆:malloc在堆上分配的内存块,使用free释放内存
自由存储区:new所申请的内存则是在自由存储区上,使用delete来释放,编译器不管,由程序自己释放,一般一个new
就要对应一个 delete
。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
常量存储区:这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
程序代码区:存放函数体的二进制代码。
堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价
-
自由存储是C++中通过new与delete动态分配和释放对象的抽象概念,而堆(heap)是C语言和操作系统的术语,是操作系统维护的一块动态分配内存。
-
new所申请的内存区域在C++中称为自由存储区。藉由堆实现的自由存储,可以说new所申请的内存区域在堆上。
-
堆与自由存储区还是有区别的,它们并非等价。
假如你来自C语言,从没接触过C++;或者说你一开始就熟悉C++的自由储存概念,而从没听说过C语言的malloc,可能你就不会陷入“自由存储区与堆好像一样,好像又不同”这样的迷惑之中。这就像Bjarne Stroustrup所说的:usually because they come from a different language background.
二、明确区分堆和栈
首先举个例子如下:
下面这个例子中,由new看出我们分配了一块自由存储区内存,指针p位于函数内部是一个局部变量,所以它分配的是一块栈内存,这句话的意思就是在栈内存中存放了一个指向堆内存的指针p.
在程序中会先确定堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存中的首地址,放入栈中。
1 void f(){ 2 int *p = new int[5]; 3 }
释放的方式是delete [] p,这是告诉编译器我删除的是一个数组。
好了,我们回到我们的主题:堆和栈究竟有什么区别?
主要的区别由以下几点:
(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++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new
和 delete
,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。
而且某些程序对于内存分配有特殊的要求,以至于我们不能标准内存分配机制直接应用于这些程序,我们需要重载new和delete运算符来控制内存的分配。
但当你必须要使用new
和delete
时,你不得不控制C++中的内存分配。你需要用一个全局的new
和delete
来代替系统的内存分配符,并且一个类一个类的重载new
和delete
。
一个防止堆破碎的通用方法是从不同固定大小的内存池中分配不同类型的对象。对每个类重载new
和delete
就提供了这样的控制。
首先我们应该对new和delete表达式有一个较为详细的理解:
如下代码:
1 string *p = new strint("a value"); //分配并初始化一个string对象 全局
编译器在执行上述语句的过程中实际执行了以下三步:
- new表达式调用名为operator new(或operator new[])的标准库函数来改函数分配一块足够大、未命名的堆内存空间以便存储特定类型的对象。。
- 第二步编译器调用相应的构造函数构造这些对象,并为其传入初始值。
- 对象被分配了空间并构造完成,返回一个指向该对象的指针。
1 delte sp; //销毁*sp,然后释放sp指向的内存空间
编译器在执行上述语句时实际上执行了以下两步:
- 对sp所指的对象执行相应的析构函数
- 编译器调用名为operator delete(或operator delte 【】)的标准库函数释放内存空间。
如果应用程序希望控制内存分配的全过程,那么它们需要自己定义自己的operator new和operator delete函数。即使在标准库中已经有了这两个函数的定义。
我们既可以在全局作用域内定义这两个函数,也可以将它们定义为成员函数。当需要分配或释放的对象是类类型时,我们首先在类或基类的作用域中查找,如果没有,则在全局作用域中查找,如果也没有,那么就使用标准库中的函数。当然使用作用域运算符符也可以直接使用全局作用域::中的函数,而不在类内查找。比如直接使用::new直接调用全局作用域中的operator new函数。
先举例介绍全局作用域下的重载operator new函数和operator delete函数:
1 void * operator new(size_t size) 2 { 3 void *p = malloc(size); 4 return p; 5 } 6 7 void operator delete(void *p) 8 { 9 free(p); 10 }
以上代码可以替代标准库中的new和delete函数来进行内存的分配。
其次介绍下对单个类的new和delete表达式进行重载:
1 class Testclas{ 2 public: 3 void *operator new(size_t size); 4 void operator delete (void *p); 5 //other members 6 }; 7 8 void *Testclass:operator new(size_t size){ //将存储类型对象所需的字节数传入size_t形参 9 void *P = malloc(size); 10 return p; 11 12 } 13 void Testclass::operator delete(void *p){ 14 free(p); 15 16 }
如果继承的类不对new和delete重载,那么基类和继承的类都将使用上述代码,通过重载new和delete,你可以采用不同个分配策略,从不同的内存池中分配不同的类对象。
再介绍下数组的分配:
1 class TestClass { 2 public: 3 void * operator new[ ](size_t size); 4 void operator delete[ ](void *p); 5 // .. other members here .. 6 }; 7 void *TestClass::operator new[ ](size_t size){ //把存储数组中所有元素所需的空间传入size_t形参 8 void *p = malloc(size); 9 return (p); 10 } 11 void TestClass::operator delete[ ](void *p){ 12 free(p); 13 } 14 int main(void){ 15 TestClass *p = new TestClass[10]; 16 // ... etc ... 17 delete[ ] p; 18 }
对于多数C++的实现,new[]操作符中的个数是数组的大小加上额外的存储对象数目的一些细节。应该在内存分配机制中重要考虑这一点。
常见的内存错误及其对策
发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。 常见的内存错误及其对策如下:
- 内存分配未成功,却使用了它。编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为
NULL
。如果指针p
是函数的参数,那么在函数的入口处用assert(p!=NULL)
进行检查。如果是用malloc
或new
来申请内存,应该用if(p==NULL)
或if(p!=NULL)
进行防错处理。 - 内存分配虽然成功,但是尚未初始化就引用它。犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
- 内存分配成功并且已经初始化,但操作越过了内存的边界。例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在
for
循环语句中,循环次数很容易搞错,导致数组操作越界。 - 忘记了释放内存,造成内存泄露。含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中
malloc
与free
的使用次数一定要相同,否则肯定有错误(new/delete
同理)。 - 释放了内存却继续使用它。
有三种情况:
(1). 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
(2). 函数的return
语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
(3). 使用free
或delete
释放了内存后,没有将指针设置为NULL
。导致产生“野指针”。
那么如何避免产生野指针呢?这里列出了5条规则,平常写程序时多注意一下,养成良好的习惯。
规则1:用
malloc
或new
申请内存之后,应该立即检查指针值是否为NULL
。防止使用指针值为NULL
的内存。
规则2:不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
规则3:避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。
规则4:动态内存的申请与释放必须配对,防止内存泄漏。
规则5:用free
或delete
释放了内存之后,立即将指针设置为NULL
,防止产生“野指针”。
参考文献
【1】堆与栈的区别:https://blog.csdn.net/hairetz/article/details/4141043
[2] 堆与自由存储区的区别:https://www.cnblogs.com/QG-whz/p/5060894.html