【高质量C/C++】7.内存管理

【高质量C/C++编程】—— 7. 内存管理

程序员们经常编写内存管理程序,往往提心吊胆。如果不想触雷,唯一的方法就是发现所有潜伏的地雷并且排除它们。

很多技术不错的C/C++程序员中,很少有能拍胸脯说通晓指针和内存管理的。这说明了指针确实是C/C++的一大难点,但是一直躲避那就一直无法真正学会使用指针,所以在学习指针时我们要做到如下两点:

  1. 越是怕指针,就越要使用指针。不会正确的使用指针,肯定算不上合格的程序员
  2. 必须养成使用调试器逐步跟踪程序的习惯,只有这样才能发现问题的本质

一、内存分配方式

内存分配的方式有三种:

  1. 从静态存储区域分配。内存在程序编译好的时候就已经分配好,这块内存在程序的整个运行期间一直存在。如全局变量,static变量
  2. 在栈上创建。执行函数时,函数内部局部变量的存储单元就在栈上创建,函数执行结束时这些存储单元会自动释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
  3. 从堆上分配,也称为动态内存分配。程序在运行时用mallocnew申请任意大小的内存,程序员自己负责用freedelete释放内存。动态内存的生存周期由我们决定,使用非常灵活,但问题也最多。

二、常见内存错误及其对策

发生内存错误是一件非常麻烦的事情。编译期不能自动发现这些错误,通常是在程序运行时才能捕捉到。这些错误大多数没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有任何问题,你一走,错误又发作了。

常见错误及其对策

  1. 内存未分配,却使用了它

编程新手经常犯这种错误,他们没有意识到内存分配不会成功。常用的解决办法是:在使用内存之前检查指针是否为NULL

  1. 如果指针p是函数的参数,那么在函数入口用断言处理。
  2. 如果使用mallocnew申请的内存,应该用if语句进行防错处理。
  1. 内存分配成功,但是未初始化就使用它
  • 引起这种错误的原因主要有2个:一是没有初始化的概念,二是误以为内存的缺省初值全为零,导致引用初值错误。

  • 内存的缺省值究竟是什么没有一个统一标准,尽管有时候为零值,但我们也要注意进行初始化。所以无论用何种方式创建数组,都别忘了赋初值,即使是零值也不要省略,不要嫌麻烦。

    扫描二维码关注公众号,回复: 15534390 查看本文章
  1. 内存分配成功并且已经初始化,但是操作越过了内存边界
  • 在使用数组时经常发生下标多1或者少1的操作。在有些时候访问了越界的内存编译器不一定会报错,这个问题尽可能在写代码时把控好数组边界。

  • 若是在运行时修改了边界外的内容但是没有报错,那这块内存将在释放时报错。所以一般free处报错大多数是因为数组越界。

  1. 忘记释放内存,造成内存泄漏
  • 含有这种错误时,函数没被调用一次就丢失一块内存。刚开始时系统内存充足,你看不到错误。终有一天程序突然挂掉,系统提示 “内存耗尽”

  • 动态内存的申请和释放必须配对,程序中的mallocfree使用的次数一定要相同,否则肯定有错误(new/delete同理)

  1. 释放了内存,却继续使用它

有三种情况:

  1. 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
  2. 函数的return语句写错了,返回了指向栈内存空间的指针或引用,该内存会在函数体结束时销毁。
  3. 使用freedelete释放内存后,没有将指针设置为NULL,导致产生野指针。

规则

  1. mallocnew申请内存后,应该立即检查指针是否为NULL。防止使用指针值为NULL的内存
  2. 不要忘记为数组和动态内存赋初值,防止使用未初始化的内存空间的内容。
  3. 避免数组或指针的下标越界,特别当心发生多1少1的操作。
  4. 动态内存的申请与释放必须配对,防止内存泄漏
  5. freedelete释放内存后,立即将指针置为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++中指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为二者是等价的。

  • 数组要么在静态存储区被创建(全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命周期内保持不变,只有数组的内容可以改变。

  • 指针可以随时指向任意类型的内存块,它的特征是可变,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险

下面是以字符串为例,比较指针与数组的特性:

  1. 修改内容

字符数组arr的容量是6个字符,其内容为hello\0。arr的内容可以改变

指针p指向字符常量"world"(位于静态存储区,内容为world\0),常量字符串的内容不能修改

char arr[] = "hello";	// 字符串hello存储在数组中
char* p = "world";		// 字符串world存储在静态存储区

arr[2] = 'L';			// 正确,字符数组中的内容可以修改
p[2] = 'R';				// 错误,字符串常量不能修改
  1. 内容复制与比较

不能对数组名进行直接复制与比较

  1. arr1的内容复制给arr2数组,不能用赋值语句,否则会产生编译错误,应该用标准库函数strcpy进行复制
  2. 不能用比较运算符==!=比较两个数组的内容,比较运算符比较的是二者的首地址而非内容,仍然要使用标准库函数strcmp进行比较其内容是否相等
char arr1[6] = "hello";
char arr2[6] = { 0 };

arr2 = arr1;				// 错误,编译错误
strcpy(arr2, arr1);			// 正确

if (arr1 == arr2);			// 比较的是两个数组的首地址
if (strcmp(arr1, arr2));	// 正确,比较两个字符数组的内容

对于指针也不能直接复制或比较

  1. 复制语句不能将指针p1的内容复制给指针p2,而是将p1指向的地址赋值给p2。要想复制p1指向内存的内容,可以用malloc函数先为p2申请一块容量为p1指向内容大小的空间,然后再用标准库函数strcpyp1中的内容复制给p2
  2. 比较运算符比较的也是p1p2中指向的地址,并非是指向内存中的内容,若要比较内容还是使用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指向的字符串内容
  1. 计算内存容量
  1. 运算符sizeof可以计算出数组的容量(字节数)
  2. 运算符sizeof无法计算出指针指向空间的大小,只会计算出指针变量本身的大小。
  3. 指针的大小与计算机的位数有关。在32位计算机中指针变量的大小是4字节,64位计算机中指针变量的大小是8字节
  4. 数组在作为函数的参数进行传递时,会自动退化为同类型的指针,所以函数传参时要传数组和数组长度,在函数体内无法通过数组名计算数组的大小。
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种:

  1. 指针变量没有被初始化。局部变量刚创建时内存里的数据是随机的,所以创建的同时就要初始化,要么置为NULL,要么指向合法内存。
  2. 指针被释放内存后没有置为NULL,让人误以为是合法指针
  3. 指针操作超越了变量的作用范围。这种情况往往防不胜防
// 类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

规则

  1. malloc的返回值是void*,所以在调用时显示地进行类型转换,将void*转化成指定指针类型
  2. malloc函数本身不需要识别申请的内存是什么类型,它只关心内存的总字数。面对自定义类型的大小不可能记住每一个的字节数,所以我们应使用sizeof运算符计算类型的大小,这是非常良好的作风
int* p1 = (int*)malloc(4 * length);				// 不良的风格
int* p2 = (int*)malloc(length * sizeof(int));	// 良好的风格

free的函数原型如下:

void free(void* memblock);

为什么free函数不像malloc函数那样复杂?

  1. 因为指针类型和其指向的内存空间大小都是事先知道的,free函数能正确地释放内存。
  2. 如果指针为NULL,则free不会对其操作,它被释放多少次都不会出现问题。
  3. 如果指针不为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. 指针参数是如何传递内存的?

  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");	// 运行错误
}

产生错误的原因是,参数pstr的拷贝,修改参数p的值不会影响str的值,而且该函数开辟的空间由p指针指向,参数p在函数执行结束后会销毁,导致指向这片内存的指针丢失,造成内存泄漏。

  1. 如果非要用指针去申请内存,则应该用二级指针:
// 获取内存
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);				// 释放内存
}
  1. 由于二级指针不容易理解,所以可以使用返回值来返回内存空间的地址:
// 获取内存
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);					// 释放内存
}
  1. 用返回值传递内存虽然好用,但是常常有人把return语句使用错了。这里强调不要返回指向 栈内存 的指针,因为该内存空间会在函数结束时销毁
char* GetMemory(void)
{
    char p[] = "hello world";
    return p;					// 返回了栈内存指针
}

void test(void)
{
    char* str = NULL;
    str = GetMemory();			// str的内容是垃圾
}
  1. 如果将该函数修改为如下:
char* GetMemory(void)
{
    char* p = "hello world";
    return p;					// 返回字符串常量
}

void test(void)
{
    char* str = NULL;
    str = GetMemory();			// str指向静态区的字符串常量
}

函数虽然不会运行错误,但是GetMemory函数的设计概念却是错误的,它获取的内存是静态区的字符串常量,该内容会在编译期开辟空间存储,并且不允许被修改值,它将在整个声明周期都存在。

2. free和delete把指针怎么啦?

freedelete函数只是把指针所指向的内存空间释放掉,并不会把指针本身干掉,而且只能释放在堆内存中申请的空间

指针被freedelete释放内存后,不会改变指针指向的地址,只是该地址内存被归还给操作系统,现在指针指向的这片内存已经不属于自己,该指针变成了野指针,这里对应的内存是垃圾。

如果不把释放后的指针置为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);	// 动态内存不会自动释放
}

我们发现了指针有一些似是非是的特征:

  1. 指针消亡了,并不代表它指向的内存空间会被自动销毁
  2. 内存释放了,并不代表指针会被销毁或变成NULL

这表明了释放内存并不是一个可以草率对待的事。

如果程序终止运行了,一切指针都会消亡,动态内存会被操作系统回收,既然如此是否可以在程序临终前不释放内存,不将指针置为NULL?

不可以,如果那段程序被取出来用到其他地方就完了

4. 有了malloc/free为什么还要new/delete?

mallocfree是C/C++的标准库函数,而new/delete是C++的运算符。它们都可以用于动态申请内存和释放内存。

对于类对象而言,光使用malloc/free无法满足动态对象的要求。对象在创建的同时要调用构造函数,在销毁的时候要调用析构函数。由于malloc/free是库函数而不是运算符,不在编译器的控制权限之内,不能够把执行构造函数和析构函数的任务强加于mallocfree

因此C++语言需要一个能完成动态内存分配和初始化的运算符new,以及一个能自动完成清理与释放内存的运算符delete。注意new/delete是运算符而不是库函数。

以下是我们分别使用malloc/freenew/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中用InitializeDestroy函数模拟构造函数和析构函数,因为mallocfree不能执行构造函数和析构函数,必须调用普通成员函数来实现资源清理功能。

  • 所以我们不要企图用malloc/free来完成动态对象的内存管理,应使用new/delete

由于内置类型的变量没有构造和析构的过程,对于它们而言malloc/freenew/delete没有区别

既然new/delete的功能全覆盖了malloc/free,那为什么C++没有淘汰malloc/free呢?

因为C++程序经常调用C函数,而C程序只能用malloc/free管理动态内存,不淘汰是为了兼容C语言

new/deletemalloc/free可以交叉使用吗?

  1. 如果用free释放new创建的动态对象,会导致该对象没有调用析构函数而出错,需要显示调用析构函数。
  2. 如果用delete释放malloc申请的动态内存,理论上程序不会出错,但是该程序的可读性很差。

所以new/delete以及malloc/free必须配对使用,不能交叉使用。

5. 内存耗尽怎么办?

如果在申请动态内存时找不到足够大的内存块,mallocnew将返回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);
    }
}
  • 方法三:为newmalloc设置异常处理函数,如Visual C++可以用_set_new_hander函数为new设置用户自定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。

上述方法中方法一和方法二使用最普遍,如果一个函数内有多处需要申请动态内存,那么方式一就显得力不从心(释放内存很麻烦),应该用方法二处理。很多人不忍心用方法三处理,如果发生内存耗尽则该程序已经无药可救,不能让操作系统自己处理,不然会害死它。

  • 对于32位及以上的系统而言,无论怎样使用mallocnew,几乎不可能导致内存耗尽,因为32位系统支持虚拟内存,如果内存用完了,会自动使用硬盘空间顶替,这时会因为硬盘的读写速度与内存速度相差悬殊,导致程序运行十分缓慢。

  • 这可以得出一个结论,对于32位以上的程序而言,内存耗尽的错误处理毫无用处,但是不加错误处理将导致程序的质量很差,千万不能因小失大。

猜你喜欢

转载自blog.csdn.net/weixin_52811588/article/details/127272353