【C/C++】内存管理再学习以及指针使用好习惯

内存分配方式

首先了解一下内存的分配方式有哪些:

  • 堆(heap)
  • 自由存储区(free store)
  • 全局/静态存储区
  • 常量存储区

自由存储区和堆有啥区别

  • 堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,很早就有了这个概念,C语言的malloc和free就是对堆进行操作。
  • 自由存储区是C++时期出现的概念,通过new来申请的内存区域可称为自由存储区,但是多数情况下,new实现的基础仍然是C语言的malloc和free,但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。

我们所需要记住的就是:

堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。

堆和栈有啥区别

堆和栈的概念出现在数据结构中和内存分配中。

数据结构中堆和栈

堆栈是两种数据结构:堆(二叉堆)和栈(后进先出)。

堆和栈都是一种数据项按序排列的数据结构。

  1. 栈:像装数据的桶或箱子,后进先出
  2. 堆:像一棵倒过来的树,是一种经过排序的树形数据结构,每个结点都有一个值。通常我们所说的堆的数据结构,是指二叉堆。堆的特点是根结点的值最小(或最大),且根结点的两个子树也是一个堆。
    由于堆的这个特性,常用来实现优先队列,堆的存取很随意(最大堆、最小堆)

内存分配中栈和堆

主要有以下几个方面的差异:

  • 分配方式,生长方向
  • 分配效率,碎片问题
  • 空间大小

分配方式、生长方向
一般情况下程序存放在Rom(只读内存,比如硬盘)或Flash中,运行时需要拷到RAM(随机存储器RAM)中执行,RAM会分别存储不同的信息,如下图所示:
在这里插入图片描述
栈区:分配局部变量空间,向下增长,自动分配;
堆区:分配程序员申请的内存空间,向上增长,手动分配;
可读写区:分配静态/全局变量空间;
只读区:分配常量和程序代码空间。


分配效率、碎片问题
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出,当申请一个很大的数组时,就会有栈溢出现象。

堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小(占用一个字节),这样,代码中的 delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中(产生内存碎片)。

所以:

栈:由系统自动分配,速度较快。但程序员是无法控制的。
堆:是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。
也就是说堆会在申请后还要做一些后续的工作这就会引出申请效率的问题。


空间大小
栈:栈顶的地址和栈的最大容量是系统预先规定好的(连续空间),在windows下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。

堆:堆是向高地址扩展的数据结构(向上增长),是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大,大数组一般在堆上申请。

使用指针导致的内存错误

常见的有内存泄漏、使用未初始化的内存、内存覆盖、内存访问越界、访问空指针、访问野指针。

内存泄漏

内存泄漏比较令人讨厌,放到下章单独进行讲解。

使用未初始化的内存

char *p = malloc(10);,p 已被分配了 10 个字节。但是这 10 个字节可能包含垃圾数据,如图所示。
在这里插入图片描述
如果在对这个 p 赋值前,某个代码段尝试访问它,则可能会获得垃圾值,程序可能具有不可预测的行为。良好的习惯是始终结合使用memsetmalloc分配内存,或者使用calloc

char *p = malloc(10);
memset(p,’\0,10);

现在,即使同一个代码段尝试在对 p 赋值前访问它,该代码段也能正确处理 Null 值(在理想情况下应具有的值),然后将具有正确的行为。

内存覆盖

由于 p 已被分配了 10 个字节,如果某个代码片段尝试向 p 写入一个 11 字节的值,则该操作将在不告诉您的情况下自动从其他某个位置“吃掉”一个字节。让我们假设指针 q 表示该内存。
q被p指针覆盖前内容如下:
在这里插入图片描述
q被p指针覆盖后内容如下:
在这里插入图片描述
可以看出q在不知情的情况下被修改了。
在编程中出现改问题的常见地方如下:

char *name = (char *) malloc(11); 
// Assign some value to name
memcpy(p,name,11); // Problem begins here

该段代码的memcpy操作尝试将11个字节写到p,而p仅分配了10个字节的内存。
因此,每当向指针写入值时,都要确保对可用字节数和所写入的字节数进行交叉核对。一般情况下,memcpy 函数将是用于此目的的检查点。

内存读取越界

内存读取越界 (overread) 是指所读取的字节数多于它们应有的字节数。这个问题并不太严重,在此就不再详述了。下面的代码提供了一个示例。

char *ptr = (char *)malloc(10);
char name[20] ;
memcpy(name,ptr,20); // Problem begins here

在本例中,memcpy 操作尝试从 ptr 读取 20 个字节,但是后者仅被分配了 10 个字节。这还会导致不希望的输出。

访问空指针或野指针

野指针不是空指针,是一个指向垃圾内存的指针(比如未初始化的指针、已释放但未置NULL的指针、越界的指针、指向被释放后临时变量的指针)。

访问指针的时候虚拟地址就会向物理地址映射,此时页表会去查看这块地址,而这块地址被存放在只读区,当页表发现地址是无效的,就会反映给操作系统,操作系统就会发送11号信号终止此进程,所以进程异常终止程序崩溃

因此,使用指针前最好判断一下是否为空。

内存泄漏

内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。原因有两点:

  1. 忘记释放内存
  2. 内存释放前消灭了指针,导致后续无法释放

如何快速定位到泄漏点呢?

  1. 查看代码中new/delete或者malloc/free是否成对出现
  2. 在类中追加一个静态变量 static int count;在构造函数中执行count++;在析构函数中执行count--;,通过在程序结束前将所有类析构,之后输出静态变量,看count的值是否为0,如果为0,则问题并非出现在该处,如果不为0,则是该类型对象没有完全释放。
  3. 检查类中申请的空间是否完全释放,尤其是存在继承父类的情况,看看子类中是否调用了父类的析构函数,有可能会因为子类析构时没有释放父类中申请的内存空间。
  4. 对于函数中申请的临时空间,认真检查,是否存在提前跳出函数的地方没有释放内存。

泄漏场景

重新赋值

char *memoryArea = malloc(10);
char *newArea = malloc(10);

分配后的内存内容如下:
在这里插入图片描述
如果后续执行(指针重新赋值):memoryArea = newArea;
则memoryArea 以前所指向的内存位置变成了孤立的(指向内存的指针跑到了其他地方),如下图所示。它无法释放,因为没有指向该位置的引用。这会导致 10 个字节的内存泄漏。
在这里插入图片描述
因此,请牢记:在对指针赋值前,请确保内存位置不会变为孤立的

首先释放了父块

假设有一个指针memoryArea,它指向一个 10 字节的内存位置。该内存位置的第三个字节又指向某个动态分配的 10 字节的内存位置,如图所示。
在这里插入图片描述
当执行free(memoryArea);后,会发现newArea指针也会变得无效,导致指向的内存位置无法释放(指向内存的指针被消灭了)。
因此,每当释放结构化的元素,而该元素又包含指向动态分配的内存位置的指针时,应首先遍历子内存位置(在此例中为 newArea),并从那里开始释放,然后再遍历回父节点。

free(memoryArea->newArea);
free(memoryArea);

返回值的不正确处理

有时,某些函数会返回对动态分配的内存的引用。跟踪该内存位置并正确地处理它就成为了calling 函数的职责。

char *func()
{
	return malloc(20); // make sure to memset this location to ‘\0’…
}

void callingFunc ( )
{
	func(); // Problem lies here
}

在上面的示例中,callingFunc() 函数中对 func() 函数的调用未处理该内存位置的返回地址。结果,func() 函数所分配的 20 个字节的块就丢失了,并导致了内存泄漏。
因此,在调用返回值为指针的函数时,要定义一个指针用以接收函数返回的内存。

项目中指针未躲过的坑示例(内存泄漏)

通过指向类的NULL指针调用类的成员函数

#include <iostream>
using namespace std;
class A
{
	int value;
public:
	void dumb() const {cout << "dumb()\n";}
	void set(int x) {cout << "set()\n"; value=x;}
	int get() const {cout << "get()\n"; return value;}
};

int main()
{
	A *pA1 = new A;
	A *pA2 = NULL;
	
	pA1->dumb();
	pA1->set(10);
	pA1->get();
	pA2->dumb();
	pA2->set(20);//崩溃
	pA2->get();
	
	return 0;
}

为什么会这样?
通过非法指针调用函数,就相当于给函数传递了一个指向函数的非法指针!
但是为什么pA2->dumb()会成功呢?
因为导致崩溃的是访问了成员变量!!!

使用已经释放的指针

struct X
{
	int data;
};

int foo()
{
	struct X *pX;
	pX = (struct X *) malloc(sizeof (struct X));
	pX->data = 10;
	free(pX);
	...
	return pX->data;//内存泄漏
}

使用未初始化的指针

如果你这样写,编译器会提示你使用了未初始化的变量p。

void fooA()
{
	int *p;
	*p = 100;
}
//或者
void fooB()
{
	int *p;
	free(p);
}

释放已经释放的指针

void fooA()
{
	char *p;
	p = (char *)malloc(100);
	cout << "free(p)\n";
	free(p);
	cout << "free(p)\n";
	free(p);
}

好的编程习惯是:释放后置为NULL,释放前检查是否为NULL

没有调用子类的析构函数

ParentClass *pObj = new ChildClass;
...
delete pObj;

上述代码会造成崩溃,如果父类的析构函数不声明为虚,那么不会调用继承类的析构函数,造成内存泄露。
因此将父类的析构函数声明为虚

内存溢出

当拷贝字符串的时候,常常会用到 memcpy函数。这里特别需要注意的就是字符串结尾的null字符:

char *p = (char *)malloc(strlen(str));
strcpy(p, str);

因此,在利用char *定义字符串时,一定要注意字符串结尾的'\0'字符,为了躲过这个坑,只需要把 strlen(str) 改为 strlen(str)+1。

union不当使用

刚写完一段代码,由于要运行在移动设备上, 我决定先测试一下内存的使用量, 结果发现了很严重的内存泄漏, 在前前后后翻看了new 和delete并确认没有漏写的情况下, 泄露依然存在!调试后最终确认了问题是因为union的不当使用造成的, 下面开始还原现场:

typedef struct DATA_
{
    DATA_(int size = 10)
    {
        pVoid = new char[nSize];
        this->size = size;
    }
    virtual ~DATA_()
    {
        if (pVoid)
        {
            delete pVoid;
        }
    }
    int nSize;
    void *pVoid;
}DATA, *LPDATA;

struct STRUCT1
{
    STRUCT1(int count)
    {
        pVoid = new DATA[count];
        this->count = count;
    }
    virtual ~STRUCT1()
    {
        if (pVoid)
        {
            delete pVoid;
        }
    }
    int count;
    union
    {
        void *pVoid;
        LPDATA pData;
    }
}

int main(int argc, char* argv[])
{
    for( int i = 0; i < 1000; i++)
    {
        void *p = new STRUCT1( 10 );
        delete p;
    }
    return 0;
}

在上面这段代码中,STRUCT1中包含了若干个DATA结构,DATA结构又申请了默认为10byte大小的内存,并且内存在对象析构的时候会用delete回收。乍一看这个代码貌似不会泄露内存,其实不然,待我分析:

在C++中构造函数与析构函数的调用是由编译器完成的,其中构造函数的调用一般是在对象空间开辟完以后(栈对象或者堆对象都是一样的),将参数压栈,this指针(对象的起始地址)放入eax寄存器(不同的编译器做法可能不同),然后跳到构造函数去执行。而析构函数的调用则是当对象内存被回收的时候被调用(栈对象是当代码执行到变量可见域外之前调用, 堆对象是在delete语句的位置进行调用)。

然而编译器并是那么智能的可以理解coder的意图,析构函数的调用是根据当前delete的指针类型来确定的,而(下面)这段代码却没有提供类型, 这导致了DATA_的析构函数将不会被调用,内存泄漏就在所难免了。

virtual ~STRUCT1()
{
    if (pVoid)
    {
        delete pVoid;//问题在这, 应该使用delete pData;
    }
}

指针其它常见问题

  • 有了mallocfree为什么还需要newdelete
  • 内存耗尽了怎么办
  • 大数组栈溢出怎么办

大数组避免栈溢出

在前面堆和栈的分配效率部分中,提到当定义大数组时,可能会栈溢出,怎么避免该问题呢?

  1. 栈变量改为堆变量,但是要记得释放free
    int* pa = malloc(sizeof(int)*1000*1000);
  2. 修改系统限制,增加栈空间(一般为4M),不推荐此方法,除非特别要求,开一个容量过大的数组,是很不明智的!
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值