【高质量C/C++编程】—— 7. 内存管理
程序员们经常编写内存管理程序,往往提心吊胆。如果不想触雷,唯一的方法就是发现所有潜伏的地雷并且排除它们。
很多技术不错的C/C++程序员中,很少有能拍胸脯说通晓指针和内存管理的。这说明了指针确实是C/C++的一大难点,但是一直躲避那就一直无法真正学会使用指针,所以在学习指针时我们要做到如下两点:
- 越是怕指针,就越要使用指针。不会正确的使用指针,肯定算不上合格的程序员
- 必须养成使用调试器逐步跟踪程序的习惯,只有这样才能发现问题的本质
目录
一、内存分配方式
内存分配的方式有三种:
- 从静态存储区域分配。内存在程序编译好的时候就已经分配好,这块内存在程序的整个运行期间一直存在。如全局变量,
static
变量 - 在栈上创建。执行函数时,函数内部局部变量的存储单元就在栈上创建,函数执行结束时这些存储单元会自动释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
- 从堆上分配,也称为动态内存分配。程序在运行时用
malloc
或new
申请任意大小的内存,程序员自己负责用free
和delete
释放内存。动态内存的生存周期由我们决定,使用非常灵活,但问题也最多。
二、常见内存错误及其对策
发生内存错误是一件非常麻烦的事情。编译期不能自动发现这些错误,通常是在程序运行时才能捕捉到。这些错误大多数没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有任何问题,你一走,错误又发作了。
常见错误及其对策:
- 内存未分配,却使用了它
编程新手经常犯这种错误,他们没有意识到内存分配不会成功。常用的解决办法是:在使用内存之前检查指针是否为
NULL
。
- 如果指针
p
是函数的参数,那么在函数入口用断言处理。- 如果使用
malloc
或new
申请的内存,应该用if
语句进行防错处理。
- 内存分配成功,但是未初始化就使用它
引起这种错误的原因主要有2个:一是没有初始化的概念,二是误以为内存的缺省初值全为零,导致引用初值错误。
内存的缺省值究竟是什么没有一个统一标准,尽管有时候为零值,但我们也要注意进行初始化。所以无论用何种方式创建数组,都别忘了赋初值,即使是零值也不要省略,不要嫌麻烦。
- 内存分配成功并且已经初始化,但是操作越过了内存边界
在使用数组时经常发生下标多1或者少1的操作。在有些时候访问了越界的内存编译器不一定会报错,这个问题尽可能在写代码时把控好数组边界。
若是在运行时修改了边界外的内容但是没有报错,那这块内存将在释放时报错。所以一般
free
处报错大多数是因为数组越界。
- 忘记释放内存,造成内存泄漏
含有这种错误时,函数没被调用一次就丢失一块内存。刚开始时系统内存充足,你看不到错误。终有一天程序突然挂掉,系统提示 “内存耗尽”
动态内存的申请和释放必须配对,程序中的
malloc
与free
使用的次数一定要相同,否则肯定有错误(new/delete
同理)
- 释放了内存,却继续使用它
有三种情况:
- 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
- 函数的
return
语句写错了,返回了指向栈内存空间的指针或引用,该内存会在函数体结束时销毁。- 使用
free
或delete
释放内存后,没有将指针设置为NULL
,导致产生野指针。
规则:
- 用
malloc
或new
申请内存后,应该立即检查指针是否为NULL
。防止使用指针值为NULL
的内存 - 不要忘记为数组和动态内存赋初值,防止使用未初始化的内存空间的内容。
- 避免数组或指针的下标越界,特别当心发生多1少1的操作。
- 动态内存的申请与释放必须配对,防止内存泄漏
- 用
free
或delete
释放内存后,立即将指针置为NULL
,防止产生野指针。
void test(int size)
{
// 申请空间
int* temp = (int*)malloc(size * sizeof(int));
// 检查空间是否申请成功
if (temp == NULL)
{
perror("malloc fail\n");
exit(-1);
}
// 初始化内存空间
memset(temp, 0, size*sizeof(int));
... ...
free(temp); // 释放内存
temp = NULL; // 置空指针
}
三、指针与数组的对比
C/C++中指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为二者是等价的。
数组要么在静态存储区被创建(全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命周期内保持不变,只有数组的内容可以改变。
指针可以随时指向任意类型的内存块,它的特征是可变,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险
下面是以字符串为例,比较指针与数组的特性:
- 修改内容
字符数组
arr
的容量是6个字符,其内容为hello\0
。arr的内容可以改变指针p指向字符常量
"world"
(位于静态存储区,内容为world\0
),常量字符串的内容不能修改
char arr[] = "hello"; // 字符串hello存储在数组中
char* p = "world"; // 字符串world存储在静态存储区
arr[2] = 'L'; // 正确,字符数组中的内容可以修改
p[2] = 'R'; // 错误,字符串常量不能修改
- 内容复制与比较
不能对数组名进行直接复制与比较
- 把
arr1
的内容复制给arr2
数组,不能用赋值语句,否则会产生编译错误,应该用标准库函数strcpy
进行复制- 不能用比较运算符
==
或!=
比较两个数组的内容,比较运算符比较的是二者的首地址而非内容,仍然要使用标准库函数strcmp
进行比较其内容是否相等
char arr1[6] = "hello";
char arr2[6] = { 0 };
arr2 = arr1; // 错误,编译错误
strcpy(arr2, arr1); // 正确
if (arr1 == arr2); // 比较的是两个数组的首地址
if (strcmp(arr1, arr2)); // 正确,比较两个字符数组的内容
对于指针也不能直接复制或比较
- 复制语句不能将指针
p1
的内容复制给指针p2
,而是将p1
指向的地址赋值给p2
。要想复制p1
指向内存的内容,可以用malloc
函数先为p2
申请一块容量为p1
指向内容大小的空间,然后再用标准库函数strcpy
将p1
中的内容复制给p2
- 比较运算符比较的也是
p1
和p2
中指向的地址,并非是指向内存中的内容,若要比较内容还是使用strcmp
函数
char* p1 = "world";
char* p2 = NULL;
p2 = p1; // 将p1指向的地址赋值给p2
// 先给p2申请空间,再将p1的内容复制到p2指向的空间中
p2 = (char*)malloc(6*sizeof(char));
strcpy(p2, p1);
if (p1 == p2); // 比较p1与p2指向的地址
if (strcmp(p1, p2)); // 比较p1与p2指向的字符串内容
- 计算内存容量
- 运算符
sizeof
可以计算出数组的容量(字节数)- 运算符
sizeof
无法计算出指针指向空间的大小,只会计算出指针变量本身的大小。- 指针的大小与计算机的位数有关。在32位计算机中指针变量的大小是4字节,64位计算机中指针变量的大小是8字节
- 数组在作为函数的参数进行传递时,会自动退化为同类型的指针,所以函数传参时要传数组和数组长度,在函数体内无法通过数组名计算数组的大小。
char arr[] = "hello";
char* p = "world";
size_t lenght = sizeof(arr); // 数组长度为6
size_t size = sizeof(p); // 指针大小为4或8(与计算机平台有关)
void PassArray(char arr[], int size) // 自动退化为同类型指针
{
std::cout << sizeof(arr) << std::endl; // 大小是指针大小而并非是数组长度
}
四、野指针
野指针不是NULL指针,而是指向垃圾内存的指针。人们一般不会错用NULL指针,因为用if
语句很容易判断,但是野指针危险,if
语句对它不起作用。
野指针的成因主要有3种:
- 指针变量没有被初始化。局部变量刚创建时内存里的数据是随机的,所以创建的同时就要初始化,要么置为NULL,要么指向合法内存。
- 指针被释放内存后没有置为NULL,让人误以为是合法指针
- 指针操作超越了变量的作用范围。这种情况往往防不胜防
// 类A的声明
class A
{
public:
void Func(void)
{
cout << "Func of class A" << endl;
}
};
void test(void)
{
A* p = NULL;
{
A a;
p = &a; // a的生命周期到大括号}处结束
}
p->Func(); // p是野指针,但是该语句不会错误执行
}
以上程序不会出错的原因是对象中只存储成员变量,方法存储在常量区,
->
并没有对指针进行解引用,而是只调用了成员方法,成员方法中也没有使用成员变量,所以不存在对象指针的解引用。
五、malloc/free使用要点
malloc
的函数原型如下:
void* malloc(size_t size);
用malloc
申请一块长度位length的整数类型内存,程序如下:
int* p = (int*)malloc(length * sizeof(int));
所以我们应将注意力集中在两个要素上:类型转换和sizeof
规则:
malloc
的返回值是void*
,所以在调用时显示地进行类型转换,将void*
转化成指定指针类型malloc
函数本身不需要识别申请的内存是什么类型,它只关心内存的总字数。面对自定义类型的大小不可能记住每一个的字节数,所以我们应使用sizeof
运算符计算类型的大小,这是非常良好的作风
int* p1 = (int*)malloc(4 * length); // 不良的风格
int* p2 = (int*)malloc(length * sizeof(int)); // 良好的风格
free
的函数原型如下:
void free(void* memblock);
为什么free
函数不像malloc
函数那样复杂?
- 因为指针类型和其指向的内存空间大小都是事先知道的,free函数能正确地释放内存。
- 如果指针为NULL,则
free
不会对其操作,它被释放多少次都不会出现问题。- 如果指针不为NULL,则
free
连续操作两次就会出现运行错误。
六、new/delete使用要点
运算符new
使用起来比malloc
简单得多
int* p = new int[length];
因为new
中内置了sizeof
、类型转换和类型安全检查功能。对于类对象而言,new
在创建动态对象过程中同时完成了初始化工作,如果对象有多个构造函数,new
的语句也可以有多种形式
// 类A的声明
class A
{
public:
A(void); // 无参构造函数
A(int x); // 带参构造函数
};
void test(void)
{
A* a1 = new A;
A* a2 = new A(2);
// ...
delete a1;
delete a2;
}
注意:如果使用new
创建对象数组,可以使用{}
运算符调用对象的构造函数,初始化数据不够的用0补全如:
A* a1 = new A[100]; // 创建100个A对象的数组
A* a2 = new A[100]{1,2}; // 创建100个A对象的数组,并初始化为1,2,0,0……
A* a3 = new A[100]{{1,2}, {2,3}}; // 创建100个A对象的数组,并用(1,2)、(2,3)、(0,0)……初始化
使用delete释放对象数组时不要丢了符号[]
。如:
delete []a1; // 正确的用法
delete a1; // 错误的用法,相当于delete a1[0],漏掉了另外99个对象
七、使用动态内存的相关问题
1. 指针参数是如何传递内存的?
- 如果函数的参数是一个指针,不要指望用该指针去申请动态内存。
// 获取内存
void GetMemory(char* p, int num)
{
p = (char*)malloc(num * sizeof(char));
}
void test(void)
{
char* str = NULL;
GetMemory(str, 100); // str仍为NULL
strcpy(str, "hello"); // 运行错误
}
产生错误的原因是,参数
p
是str
的拷贝,修改参数p
的值不会影响str
的值,而且该函数开辟的空间由p
指针指向,参数p
在函数执行结束后会销毁,导致指向这片内存的指针丢失,造成内存泄漏。
- 如果非要用指针去申请内存,则应该用二级指针:
// 获取内存
void GetMemory(char** p, int num)
{
*p = (char*)malloc(num * sizeof(char));
}
void test(void)
{
char* str = NULL;
GetMemory(&str, 100); // 参数传str的地址
strcpy(str, "hello");
free(str); // 释放内存
}
- 由于二级指针不容易理解,所以可以使用返回值来返回内存空间的地址:
// 获取内存
char* GetMemory(char* p, int num)
{
p = (char*)malloc(num * sizeof(char));
return p;
}
void test(void)
{
char* str = NULL;
str = GetMemory(str, 100); // str接收函数返回值
strcpy(str, "hello");
free(str); // 释放内存
}
- 用返回值传递内存虽然好用,但是常常有人把
return
语句使用错了。这里强调不要返回指向 栈内存 的指针,因为该内存空间会在函数结束时销毁
char* GetMemory(void)
{
char p[] = "hello world";
return p; // 返回了栈内存指针
}
void test(void)
{
char* str = NULL;
str = GetMemory(); // str的内容是垃圾
}
- 如果将该函数修改为如下:
char* GetMemory(void)
{
char* p = "hello world";
return p; // 返回字符串常量
}
void test(void)
{
char* str = NULL;
str = GetMemory(); // str指向静态区的字符串常量
}
函数虽然不会运行错误,但是
GetMemory
函数的设计概念却是错误的,它获取的内存是静态区的字符串常量,该内容会在编译期开辟空间存储,并且不允许被修改值,它将在整个声明周期都存在。
2. free和delete把指针怎么啦?
free
和delete
函数只是把指针所指向的内存空间释放掉,并不会把指针本身干掉,而且只能释放在堆内存中申请的空间
指针被free
或delete
释放内存后,不会改变指针指向的地址,只是该地址内存被归还给操作系统,现在指针指向的这片内存已经不属于自己,该指针变成了野指针,这里对应的内存是垃圾。
如果不把释放后的指针置为NULL,会让人误以为这是一个合法的指针。在程序比较长时,我们有时记不住指针是否被释放,通常使用if (p != NULL)
来判断,但是此时p
并不是一个空指针,因为它既不是NULL,也不是一个合法内存块。
char* p = (char*)malloc(100); // 申请内存
strcpy(p, "hello");
free(p); // 释放内存
... ...
if (p != NULL) // 这里没有起到防错作用
{
strcpy(p, "world"); // 出错
}
3. 动态内存会自动释放吗?
函数体内的局部变量在函数结束时会自动销毁,以至于很多人认为下面中p
是局部指针变量,它销毁时会让它指向的动态内存一起完蛋,但事实上这是错觉。
void Fun(void)
{
char* p = (char*)malloc(100); // 动态内存不会自动释放
}
我们发现了指针有一些似是非是的特征:
- 指针消亡了,并不代表它指向的内存空间会被自动销毁
- 内存释放了,并不代表指针会被销毁或变成NULL
这表明了释放内存并不是一个可以草率对待的事。
如果程序终止运行了,一切指针都会消亡,动态内存会被操作系统回收,既然如此是否可以在程序临终前不释放内存,不将指针置为NULL?
不可以,如果那段程序被取出来用到其他地方就完了
4. 有了malloc/free为什么还要new/delete?
malloc
和free
是C/C++的标准库函数,而new/delete
是C++的运算符。它们都可以用于动态申请内存和释放内存。
对于类对象而言,光使用malloc/free
无法满足动态对象的要求。对象在创建的同时要调用构造函数,在销毁的时候要调用析构函数。由于malloc/free
是库函数而不是运算符,不在编译器的控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc
和free
。
因此C++语言需要一个能完成动态内存分配和初始化的运算符new
,以及一个能自动完成清理与释放内存的运算符delete
。注意new/delete
是运算符而不是库函数。
以下是我们分别使用malloc/free
和new/delete
实现对象的动态内存管理:
// 类A的声明
class A
{
public:
A(void); // 构造函数
~A(void); // 析构函数
void Initialize(void); // 初始化函数
void Destroy(void); // 销毁函数
}
// 使用malloc/free完成对象动态内存管理
void UseMallocFree(void)
{
A* a = (A*)malloc(sizeof(A)); // 申请动态内存
a->Initialize(); // 初始化资源
// ...
a->Destroy(); // 清除资源
free(a); // 释放空间
}
// 使用new/delete完成对象动态内存管理
void UseNewDelete(void)
{
A* a = new A; // 申请动态内存并初始化
// ...
delete a; // 清除资源并释放内存
}
以上程序中的类
A
中用Initialize
和Destroy
函数模拟构造函数和析构函数,因为malloc
和free
不能执行构造函数和析构函数,必须调用普通成员函数来实现资源清理功能。所以我们不要企图用
malloc/free
来完成动态对象的内存管理,应使用new/delete
。
由于内置类型的变量没有构造和析构的过程,对于它们而言malloc/free
和new/delete
没有区别
既然new/delete
的功能全覆盖了malloc/free
,那为什么C++没有淘汰malloc/free
呢?
因为C++程序经常调用C函数,而C程序只能用
malloc/free
管理动态内存,不淘汰是为了兼容C语言
new/delete
和malloc/free
可以交叉使用吗?
- 如果用
free
释放new
创建的动态对象,会导致该对象没有调用析构函数而出错,需要显示调用析构函数。- 如果用
delete
释放malloc
申请的动态内存,理论上程序不会出错,但是该程序的可读性很差。所以
new/delete
以及malloc/free
必须配对使用,不能交叉使用。
5. 内存耗尽怎么办?
如果在申请动态内存时找不到足够大的内存块,malloc
和new
将返回NULL指针,宣告内存申请失败。通常有三种方式处理内存耗尽问题。
- 方法一:判断指针是否为NULL,如果是则马上用
return
语句终止本函数
void Fun(void)
{
A* a = new A;
if (a == NULL)
{
reutrn;
}
}
- 方法二:判读指针是否为NULL,如果是则马上用
exit(1)
终止整个程序的运行
void Fun(void)
{
A* a = new A;
if (a == NULL)
{
cout << "Memory Exhausted" << endl;
exit(1);
}
}
- 方法三:为
new
和malloc
设置异常处理函数,如Visual C++可以用_set_new_hander
函数为new
设置用户自定义的异常处理函数,也可以让malloc
享用与new
相同的异常处理函数。
上述方法中方法一和方法二使用最普遍,如果一个函数内有多处需要申请动态内存,那么方式一就显得力不从心(释放内存很麻烦),应该用方法二处理。很多人不忍心用方法三处理,如果发生内存耗尽则该程序已经无药可救,不能让操作系统自己处理,不然会害死它。
对于32位及以上的系统而言,无论怎样使用
malloc
和new
,几乎不可能导致内存耗尽,因为32位系统支持虚拟内存,如果内存用完了,会自动使用硬盘空间顶替,这时会因为硬盘的读写速度与内存速度相差悬殊,导致程序运行十分缓慢。这可以得出一个结论,对于32位以上的程序而言,内存耗尽的错误处理毫无用处,但是不加错误处理将导致程序的质量很差,千万不能因小失大。