天天看点

【C/C++学习笔记】C++内存管理详解C++内存管理详解

C++内存管理详解

一、内存分配方式简介

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。

全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

二、堆和栈的主要区别

(1)管理方式不同

栈是系统自动分配和释放的,堆的申请和释放工作由程序员控制,忘记释放或者只释放部分内存,容易产生内存泄漏。

(2)空间大小不同

栈是一块连续的区域,大小一般是1~2M;堆是不连续的区域,空间很大,上限取决于有效的虚拟内存。

(3)能否产生碎片不同

栈是后进先出的队列,内存是连续的,而堆则在多次的new和delete后会产生很多碎片。

(4)生长方向不同

对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

堆由低地址向高地址增长,栈由高地址向低地址增长。

(5)分配方式不同

堆是动态分配,没有静态分配。栈是静态分配和动态分配,静态分配由编译器完成,例如局部变量的内存分配;动态分配则由alloca函数分配,不同于堆的手工释放,它的分配是完全由编译器自动释放。

(6)分配效率不同

栈是系统的底层数据结构,计算机在底层对栈提供了专门的寄存器存放栈的地址,专门指令执行压栈出栈,这就决定了栈的效率比较高。

堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

无论是堆还是栈,都要防止越界现象的发生,因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果。

三、C++ 内存常见错误

(1) 内存泄露

如果在堆上分配的内存使用完成后没有释放或者释放不完全就会造成内存泄露。

大量的内存泄露会耗尽内存,导致后续内存分配失败,程序崩溃。

(2) 内存越界访问

内存越界访问有两种:一种是读越界,即读了不属于自己的数据,如果所读的内存地址是无效的,程序就立即崩溃。如果所读的内存地址是有效的,在读的时候不会出现问题,但是由于读到的数据是随机的,他会产生不可预料的后果;另一种是写越界,又叫缓冲区溢出。所写的数据是随机的,他也会产生不可预料的后果。

内存越界访问造成的后果非常严重,因为它造成的后果是随机的,表现出来的症状和时机也是随机的,让bug的现象和本质看似没有什么联系,这给bug定位带来了极大的困难。所以在时机开发过程中,对于外部传入的参数要仔细检查。

(3) 野指针和垂悬指针

野指针:访问一个已销毁或者访问受限的内存区域的指针,野指针不能通过判断是否为NULL来避免。

垂悬指针:指针正常初始化,曾指向一个对象,该对象被销毁了,但是指针未制空,那么就成了悬空指针。(可以通过智能指针避免)

指针指向的内存被释放掉后,如果指针没有被置空,会产生野指针。此时野指针指向的内存已经被赋予新的意义,继续对野指针指向的内存访问,会造成如同越界访问一样是不可预料的后果。解决野指针最好的方法:释放内存后立即把对应指针置为空值。

(4) 访问空指针

在访问指针指向的内存时,确保指针不是空指针。访问空指针指向的内存,通常会导致程序崩溃,或者不可预料的错误。常见的情况有内存分配失败,返回空指针,未判断返回结果直接使用空指针

(5) 引用未初始化的变量

未初始化变量的内容是随机的,使用这些数据会造成不可预料的后果,调试这样的bug也非常困难。最好的解决办法:在声明变量的时候就对它进行初始化。

注意:可以使用memset等函数初始化内存,但不能用memset操作类对象

(6) 不清楚的指针运算

不确定的指针运算,最好写个小程序验证一下,避免使用出错。

如:int *p=....;

p+n等价于(size_t)p+n*sizeof(*p);

(7)结构体成员顺序变化引发的错误

注意使用过程要和结构体成员一一对应。

(8)结构体大小变化引发的错误

 注意结构体成员大小可能发生改变的成员。

(9)分配释放不配对

malloc要和free配对使用,new要和delete/delete[]配对使用,重载了类new操作,应该同时重载类的delete/delete[]操作。

(10)返回指向临时变量的指针

栈里面的变量都是临时的。当前函数执行完成时,相关的临时变量和参数都被清除了。不能把指向这些临时变量的指针返回给调用者,这样的指针指向的数据是随机的,会给程序造成不可预料的后果。

(11)试图修改常量

例如:

int main(int argc, char* argv[])
{
    char* p = "abcd";
    *p = '1';
    return 0;
}
           

(12)误解传值和传引用

在C/C++中,参数默认传递方式是传值的,即在参数入栈时被拷贝一份。在函数里修改这些参数,不会影响外面的调用者。

引用是一个变量的另一个名字,又称别名。对引用变量作出的任何更改,实际上都是对它所引用的变量内存位置中存储数据的更改。 当使用引用变量作为形参时,它将变为实参列表中相应变量的别名,对形参进行的任何更改都将真正更改正在调用它的函数中的变量。

(13)重名符号

无论是函数名还是变量名,如果在不同的作用范围内重名,自然没有问题。但如果两个符号的作用域有交集,如全局变量和局部变量,全局变量与全局变量之间,重名的现象一定要坚决避免。gcc有一些隐式规则来决定处理同名变量的方式,编译时可能没有任何警告和错误,但结果通常并非你所期望的。

(14)栈溢出

在编程时应该清楚自己平台的限制,避免栈溢出的可能。对于需要内存要求大的变量使用堆。

(15)误用sizeof

C++通常是按值传递参数,而数组则是例外,在传递数组参数时,数组退化为指针(及按引用传递),此时用sizeof是无法获取数据的大小。

(16)字节对齐

字节对齐主要目的是提高内存访问效率,在某些平台上,就不仅仅是效率问题,如果不对齐得到的数据是错误的。大多数情况下编译器会保值全局变量和临时变量按照正确的方式对齐。内存管理器会保证动态按照正确的方式对齐。要注意的是:在不同的类型的变量之间转换时要小心。

字节对齐也会造成结构体大小的变化,在程序内部用sizeof来取的结构的大小就可以了。若数据要在不同的机器间传递时,在通信协议中要规定对齐的方式,避免对齐方式不一致引发的问题。

(17) 字节顺序

字节顺序历来是设计跨平台最头痛的问题。字节顺序是关于数据在物理内存中的布局问题,最常见的字节顺序有两种:大端模式和小端模式

大端模式:高位字节数据存放在低地址处,低位字节数据存放在高地址处。

小端模式:低位字节数据存放在内存低地址处,高字节字节数据存放在内存高地址处

如:long n=0x11223344

模式         第1字节  第2字节  第3字节  第4字节

大端模式  0x11,   0x22,   0x33,    0x44 

小端模式  0x44,   0x33,   0x22,    0x11

在普通软件中,字节顺序问题并不引人注目。而在开发与网络通信和数据交换有关的软件时,字节顺序就要多注意了。

(18)多线程共享变量没有用valotile修饰

valotile作用:告诉编译器不要把变量优化到寄存器中。在开发多线程的程序是,如果这些线程共享一些全局变量,这些全局变量最好使用valotile修饰。这样可以避免因为编译器优化而引起的错误。

参考:

https://www.cnblogs.com/findumars/p/5929831.html

https://blog.csdn.net/lyl0625/article/details/6578225

继续阅读