前言:内存管理是C++最令人切齿痛恨的问题,也是C++最有争议的问题,C++高手从中获得了更好的性能,更大的自由,C++菜鸟的收获则是一遍一遍的检查代码和对C++的痛恨,但内存管理在C++中无处不在,内存泄漏几乎在每个C++程序中都会发生,因此要想成为C++高手,内存管理一关是必须要过的,除非放弃C++,转到Java或者.NET,他们的内存管理基本是自动的,当然你也放弃了自由和对内存的支配权,还放弃了C++超绝的性能。
一、内存的常见分配方式
1.1分配方式简介
在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区:
- 栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
- 堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
- 自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。
- 全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。
- 常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
从静态区分配,一般是全局变量和static类型变量;从栈区分配内存,一般是局部的变量,会随着所在函数的结束而自动释放;从堆中分配,一般是使用手动分配,使用malloc()函数和new来申请任意大小空间,不过要手动释放空间,相应的使用free()函数和delete释放,如果不释放该空间,而且指向该空间的指针指向了别的空间.则该空间就无法释放,造成内存泄露,造成了内存浪费。
1.2明确区分堆与栈
在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.3堆和栈究竟有什么区别?
好了,我们回到我们的主题:堆和栈究竟有什么区别?
主要的区别由以下几点:
- 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可是相当困难的:)
1.4控制C++的内存分配
在嵌入式系统中使用C++的一个常见问题是内存分配,即对new 和 delete 操作符的失控。
具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。
这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new 和 delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。
作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。
但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new 和delete来代替系统的内存分配符,并且一个类一个类的重载new 和delete。
一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new 和delete就提供了这样的控制。
重载全局的new和delete操作符
可以很容易地重载new和delete操作符,如下所示:
void * operator new(size_t size) { void *p = malloc(size); return (p); } void operator delete(void *p); { free(p); }
这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc() 和free()。也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配。
class TestClass { public: void * operator new(size_t size); void operator delete(void *p); // .. other members here ... }; void *TestClass::operator new(size_t size) { void *p = malloc(size); // Replace this with alternative allocator return (p); } void TestClass::operator delete(void *p) { free(p); // Replace this with alternative de-allocator }
所有TestClass对象的内存分配都采用这段代码。更进一步,任何从TestClass继承的类也都采用这一方式,除非它自己也重载了new和delete操作符。通过重载new和delete操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。
为单个的类重载new[ ]和delete[ ]
必须小心对象数组的分配。你可能希望调用到被你重载过的new 和 delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆。
C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符。
class TestClass { public: void * operator new[ ](size_t size); void operator delete[ ](void *p); // .. other members here .. }; void *TestClass::operator new[ ](size_t size) { void *p = malloc(size); return (p); } void TestClass::operator delete[ ](void *p) { free(p); } int main(void) { TestClass *p = new TestClass[10]; // ... etc ... delete[ ] p; }
但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。
1.5常见的内存错误及其对策
发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。 常见的内存错误及其对策如下:
内存分配未成功,却使用了它。编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为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。导致产生“野指针”。
【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。
【规则4】动态内存的申请与释放必须配对,防止内存泄漏。
【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。
----------------------------免费资料领取,自行获取------------------------------
【文章福利】小编推荐自己的Linux内核技术交流群:【 865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!
二、内存的使用规则
1.在使用malloc()或new申请空间时,要检查有没有分配空间成功,判断方法是判断指针是否为NULL,如申请一块很大的内存而没有这么大的内存则分配内存会失败
2.申请成功后最好是将该内存清空,使用memset()后ZeroMemory()清空,不然存在垃圾而造成有时候输出很大乱码
3.不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。(这句话不太理解)
4.要防止数组或指针内存越界,
5.申请内存成功后,使用结束后要释放,系统不会自动释放手动分配的内存
6.内存释放后,指针还是指向那块地址,不过这指针已经是"野指针"了,所以释放内存后指针要指向NULL,不然很危险,容易出错,if()对野指针的判断不起作用
在设计代码时候,就要连同逻辑一起,将内存的释放和分配设计好。而不是发生问题之后再去 Debug。
RAII 原则,也就是“资源获取就是初始化”,是 C++ 的一种管理资源、避免泄漏的惯用法。其实本文介绍的就是这种思想的指导下的方法,包括现在流行的智能指针,也是这种思想的具体实现。
搞清楚对象的所有权,一个对象属于谁,就要由谁去负责管理。时刻遵循谁分配谁释放,谁污染谁治理,谁渣男谁接盘的准则。不仅代码如此,生活也要如此哦。这其实也是 RAII 的原则。
不要瞎 new / malloc。坚决控制 new 的次数。在性能不吃紧的情况下,宁可将内存拷贝一份,也不要随意传递指针,这是是很多 Java 程序员初写 C++ 的时候常犯的错误。
2.1一个正常的 C++ 模块应该是怎样的
我们用一个常见的矩阵运算类来尝试说明一下,一个设计得比较干净的模块应该是怎样管理内存的。
矩阵,是我们常用到的一个数学工具,尤其在写一些 3d 项目的时候,一个 4x4 的矩阵可以让我们很方便得描述一些三维变换。事实上一个 4x4 的矩阵是由 16 个浮点数表示的,我们就写一个 Mat4x4,来看看c++应该如何管理内存。
class Mat4x4 { public: float * mat = NULL; int matLen = 0; public: Mat4x4(); ~Mat4x4(); } // 实现 Mat4x4::Mat4x4() { // 此处为了演示,使用 malloc ,其实完全可以分配到栈内存上 matLen = 16 * sizeof(float); mat = (float *)malloc(matLen); memset(mat, 0, matLen); } Mat4x4::~Mat4x4() { if(mat != NULL){ free(mat); mat = NULL; } matLen = 0; }
由上述代码可以知道,我们在类的构造方法里malloc出来一个长度为16*4长度的内存空间,用来存放矩阵中用到的16个浮点数。而在析构函数中,我们将这段内存释放掉。这就符合我们的准则,谁分配谁释放。我们来看一下我们应该如何使用这个类。
通常我们有两种实例化这个类的方式:
Mat4x4 a; Mat4x4 * pA = new Mat4x4(); // 用完记得 delete delete pA;
但是,我们在使用过程中,尤其是局部使用这个变量的时候,应该尽量避免使用new的方式。首先,使用 new 的方式,会带来额外的性能开销,最重要的是,使用 new 的方式,你需要额外考虑何时将这个类释放,一个两个还好,如果有多个的话,会让你的代码看起来非常臃肿奇怪,而且如果有忘记释放的,就会造成内存泄露。很多从前写 Java 的童鞋在初写 C++ 的时候,本着万物皆可 new 的原则,往往在很多不需要 new 的地方去 new ,这让其代码看起来非常奇怪且容易出问题。
2.2赋值与拷贝
试想另外一种场景。
Mat4x4 a; // 给a赋值,此处省略一万字 Mat4x4 b; // 此处的 b ,我们想让 b 的内部的 16 个浮点数和 a 里的完全一样,应该怎么做
我们声明了一个 Mat4x4 a,并给它内部的 16 个浮点数赋值。接下来我们想要一个 Mat4x4 b,与 a 内部的值完全相同,我们应该怎么做。正常来说,我们要重载 Mat4x4 的赋值运算符。
class Mat4x4 { public: float * mat = NULL; int matLen = 0; public: Mat4x4(); ~Mat4x4(); Mat4x4 & operator = (Mat4x4 & _mat); } // 实现 Mat4x4::Mat4x4() { // 此处为了演示,使用 malloc ,其实完全可以分配到栈内存上 matLen = 16 * sizeof(float); mat = (float *)malloc(matLen); memset(mat, 0, matLen); } Mat4x4::~Mat4x4() { if(mat != NULL){ free(mat); mat = NULL; } matLen = 0; } // 重载等号运算符,将内存拷贝过来 Mat4x4 & Mat4x4::operator = (Mat4x4 & _mat) { memcpy(mat, _mat.mat, matLen); return *this; }
复写等号运算符之后,我们就可以直接使用赋值运算符了。
Mat4x4 a; Mat4x4 b; b = a;
注意,这里的 a 和 b 其实是两块完全不同的内存,我们通过重载其赋值运算符,将 a 的内容拷贝给了 b。
我们可以比较一下上面这种写法和下面这种写法的区别:
Mat4x4 * a = new Mat4x4(); Mat4x4 * b = new Mat4x4(); b = a;
可见,第一种写法调用了重载的赋值运算符,第二种写法,其实是根本没有调用 Mat4x4 赋值函数,调用的其实是 Mat4x4 * (这里是指针)的赋值,这种情况下,b 其实是指向了 a。而不是把 a 的内容复制一份。这种情况下,a 和 b 其实指向的是同一片内存,修改 a 的内容其实就是在修改 b 的内容,而 b 原先的内存,就成了永远无法被修改和释放的内存垃圾。这种情况是非常危险的,也是非常容易出问题的一种写法,除非你很清楚自己在干什么,否则要坚决避免这种写法。
其实在内存管理上,多个指针指向同一片内存就是一种非常危险的行为。在某些性能相关的场景下,我们有时不得不这样做,这是没有办法的事情。但是在性能不敏感的地方,坚决不要发生这样的事情。
2.3函数传值
还是以我们的 Mat4x4 为例子,假设一个场景,我们有一个函数,需要一个 Mat4x4 的变量作为参数,我们应该怎么做。正常来说,我们会传一个引用进去。
int SetMat(Mat4x4 & mat);
如上,在函数内部,我们可以按照一般对象的方式来使用这个形参。但是注意,如果你使用引用传参,那么如果你在函数内部修改他的值的话,是会连同函数外部的变量一起修改的,因为引用其实就是外部的变量(其实就是指针,引用其实就是指针的语法糖)。
那么有办法不修改吗?
int SetMat(Mat4x4 mat);
这样写就可以了,但是和传一个引用有什么区别呢?实际上,第二种方法在传值的时候是会调用 Mat4x4 的拷贝构造方法的,也就说,第二种方法实际上是将 mat 复制了一份传给了函数,也就是说,这中间会发生一次拷贝,而引用就不会。
2.4从函数中返回一个值
我们期望一个函数返回一个对象的时候,我们也许会这样做
Mat4x4 GetMat() { Mat4x4 mat; return mat; }
这样做是没有问题的,我们在一些场景下也会使用,但是其在返回的时候,事实上会调用 Mat4x4 的拷贝构造方法,也就是说这里的mat也会被复制一次。
那么有没有办法不进行复制呢?有人想到了引用和指针。
Mat4x4 & GetMat() { Mat4x4 mat; return mat; } Mat4x4 * GetMat() { Mat4x4 mat; return &mat; }
但是这种方法是错误的,由于函数内的mat对象是在栈上了,这个函数结束后就会被自动释放。返回后拿到的引用或者指针,指向的内存实际上已经被释放,再次访问一定会出问题。
有人说既然栈上不行,那么分配到堆上是不是就可以了。
Mat4x4 * GetMat() { Mat4x4 * mat = new Mat4x4(); return mat; }
确实,这样做是完全可以的,但是又涉及到一个设计的问题。我们设计程序的时候,往往遵循谁分配,谁释放的原则,如果写成这样,我们就等于是在函数内分配,函数外释放,显然会对我们的调用者产生如何管理这片内存的疑惑。所以,我们往往这样设计。
int ChangeMat(Mat4x4 & mat) { // ...... return 0; }
我们可以看到,内存由外部分配,通过引用和指针,将其传到函数中,函数负责填充这片由外部分配的内存。而其返回值往往是一个整型,用来表示函数的执行结果,通常返回0表示执行成功,返回负数代表错误。
如果我们非要函数内为我们分配内存呢,也是可以的,我们可以这样设计。
Mat4x4 * CreateMat(Mat4x4 * mat) { if(mat == NULL){ mat = new Mat4x4(); } // 操作 mat return mat; } // 调用 Mat4x4 * mat = NULL; // 由函数内部分配 mat = CreateMat(mat); delete mat; Mat4x4 * mat = new Mat4x4(); // 由调用者分配 mat = CreateMat(mat); delete mat;
这样设计的好处是,我们可以将是否由函数内部分配内存的决定权交给函数调用者,如果函数调用者传入的是一个 NULL,那么内存就由函数内部分配。这样写增加了灵活性。