您的位置:首页 > 教育 > 培训 > C++内存管理

C++内存管理

2025/2/27 15:21:15 来源:https://blog.csdn.net/weixin_74012727/article/details/141927751  浏览:    关键词:C++内存管理

如果大家学习过C语言那么一定知道内存管理,简单来说内存管理就是向堆申请空间的过程,C语言有malloc,calloc,realloc等函数来申请空间,free来释放申请的空间,C++也又属于自己的内存管理,比如用new来申请空间,用delete来释放空间等,下面就跟着我来一起学习吧!

一、C/C++内存分布

C/C++中程序内存区域划分:

  1. 内核空间:用户代码不能读写。
  2.  栈:又叫堆栈,存放非静态局部变量/函数参数/返回值等等,栈是向下增长的。
  3. 内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口

    创建共享共享内存,做进程间通信。

  4. 堆:用于程序运行时动态内存分配,堆是可以上增长的。

  5. 数据段:存储全局数据和静态数据。

  6. 代码段:存储可执行的代码/只读常量 

在学习具体内容之前,我们先看一段代码:

int globalVar = 1;
static int staticGlobalVar = 1;
void Test()
{static int staticVar = 1;int localVar = 1;int num1[10] = { 1, 2, 3, 4 };char char2[] = "abcd";const char* pChar3 = "abcd";int* ptr1 = (int*)malloc(sizeof(int) * 4);int* ptr2 = (int*)calloc(4, sizeof(int));int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);free(ptr1);free(ptr3);
}

现在开始提问:

现有A、B、C、D四个选项,分别是栈、堆、数据段(静态区)、代码段(常量区)。

globalVar在哪里?____
staticGlobalVar在哪里?____
staticVar在哪里?____
localVar在哪里?____
num1 在哪里?____
char2在哪里?____
*char2在哪里?___
pChar3在哪里?____
*pChar3在哪里?____
ptr1在哪里?____
*ptr1在哪里?____

 大家先思考3分钟。

现在公布答案:

globalVar在哪里?__C__
staticGlobalVar在哪里?_C___
staticVar在哪里?__C__
localVar在哪里?_A___
num1 在哪里?__A__
char2在哪里?__A__
*char2在哪里?_A__
pChar3在哪里?__A__
*pChar3在哪里?_D___
ptr1在哪里?_A___
*ptr1在哪里?__B__

大家做对了吗?

 用一张图就可以搞清楚上面的关系。

 我们学习内存管理主要是针对堆上来开辟和释放空间。

二、C语言中动态内存管理方式

在C语言中主要用malloc/calloc/realloc/free这4个函数来动态管理内存。

void Test()
{int* p1 = (int*)malloc(20);if(p1 == NULL){perror("malloc fail");return;}int* p2 = (int*)calloc(4, sizeof(int));if(p2 == NULL){perror("calloc fail");return;}int* p3 = (int*)realloc(p2, sizeof(int) * 10);{if(p3 == NULL){perror("realloc fail");return;}}free(p1);free(p3);
}

malloc、calloc、realloc它们三个都是在堆中开辟空间,但有所不同。

malloc和calloc都是直接开辟空间,但用malloc开辟的空间不会初始化,而用calloc开辟的空间所有字节会初始化为0。

而realloc是用来扩容,在已开好的空间的基础上扩容,若后面空间足够就直接扩容(p3==p2),若后面空间不够就重新找一个足够的空间扩容(之前的p2指向的空间自动释放了)。所以直接free(p3)就可以了,不用free(p2)。

当然,当开辟的空间较大时,可能会发生开辟失败,返回NULL指针。对于realloc空间开辟失败返回NULL,但它扩容之前的那块空间不会被释放。

如果不释放内存,就会造成内存泄漏,影响程序正常运行。

三、C++内存管理方式

C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦(稍后会讲到),因此C++又提出了自己的内存管理方式:通过new和delete关键字进行动态内存管理。

new和delete的用法请看下面代码:

int main()
{//不用指明大小,也不需要强制转换//new后面直接跟类型,表示new出来一个int类型的对象int* p1 = new int;  //不会初始化//也可以直接new出来10个int类型的对象int* p2 = new int[10];  //不会初始化//new出来的对象,释放时直接delete就可以delete p1;//new[]出来的对象,释放时用delete[]delete[] p2;//申请对象+初始化int* p3 = new int(1);int* p4 = new int[10]{ 0 };//10个值全是0int* p5 = new int[10]{ 1,2,3,4,5 };//后五个是0delete p3;delete[] p4;delete[] p5;return 0;
}

这样的写法是不是比C语言简洁许多?但仅仅是因为写着方便才出现的吗?

其实不是的,简洁是一方面,另一方面如果new的是自定义类型的对象,那么会自动调用它的构造函数(包括拷贝构造),delete时会自动调用它的析构函数(重大改变,也是与C语言动态管理的主要区别)。

请看代码:

class A
{
public:A(int a1 = 0): _a1(a1){cout << "A(int a1 = 0)" << endl;}~A(){cout << "~A()" << endl;}void Print(){cout << "A::Print" << endl;}
private:int _a1;
};
int main()
{A* p1 = new A;A* p2 = new A(1);delete p1;delete p2;return 0;
}

代码运行的结果是:

这里可以很好的观察出这种现象。

 接下来,再看一种情况:
 

class A
{
public:A(int a1 = 1, int a2 = 1): _a1(a1), _a2(a2){cout << "A(int a1 = 0, int a2 = 1)" << endl;}A(const A& a){cout << "A(const A& a)" << endl;}~A(){cout << "~A()" << endl;}void Print(){cout << "A::Print" << endl;}
private:int _a1;int _a2;
};
int main()
{A* p1 = new A; //调用默认构造函数A* p2 = new A(1); //调用传一个参数的构造A* p3 = new A(1, 2); //调用传两个参数的构造A* p4 = new A[3]; //调用三次默认构造return 0;
}

我现在把默认构造函数改为了两个参数,new对象时都可以正常调用。

运行结果:

但如果,我把默认构造函数的第一个参数不给缺省值,那么:

class A
{
public:A(int a1, int a2 = 1): _a1(a1), _a2(a2){cout << "A(int a1 = 0, int a2 = 1)" << endl;}A(const A& a){cout << "A(const A& a)" << endl;}~A(){cout << "~A()" << endl;}void Print(){cout << "A::Print" << endl;}
private:int _a1;int _a2;
};
int main()
{A* p1 = new A; //调用默认构造函数A* p2 = new A(1); //调用传一个参数的构造A* p3 = new A(1, 2); //调用传两个参数的构造A* p4 = new A[3]; //调用三次默认构造return 0;
}

这两个位置就会报错,因为没有默认构造函数了,而new对象时要调用构造函数,所以会报错。

为了解决这一问题,可以换一个写法:

int main()
{A* p2 = new A(1); //调用传一个参数的构造A* p3 = new A(1, 2); //调用传两个参数的构造A aa1(1, 1);A aa2(2, 2);A aa3(3, 3);A* p1 = new A(aa1); //调用一次拷贝函数A* p4 = new A[3]{ aa1,aa2,aa3 }; //调用三次拷贝构造return 0;
}

 这种写法会调用拷贝构造,因为aa1,aa2,aa3都是已存在的对象。

运行结果也符合我们的想法:

这种方式要单独定义三个A类型的对象,比较麻烦,我们也可以用匿名对象。

int main()
{A* p2 = new A(1); //调用传一个参数的构造A* p3 = new A(1, 2); //调用传两个参数的构造//A aa1(1, 1);//A aa2(2, 2);//A aa3(3, 3);//A* p1 = new A(aa1); //调用一次拷贝函数//A* p4 = new A[3]{ aa1,aa2,aa3 }; //调用三次拷贝构造A* p4 = new A[3]{ A(1,1),A(2,2),A(3,3) }; //先构造,再拷贝构造->编译器直接优化为拷贝构造return 0;
}

 运行结果:

我们还有一种写法:

int main()
{A* p2 = new A(1); //调用传一个参数的构造A* p3 = new A(1, 2); //调用传两个参数的构造//A aa1(1, 1);//A aa2(2, 2);//A aa3(3, 3);//A* p1 = new A(aa1); //调用一次拷贝函数//A* p4 = new A[3]{ aa1,aa2,aa3 }; //调用三次拷贝构造//A* p4 = new A[3]{ A(1,1),A(2,2),A(3,3) };A* p4 = new A[3]{ {1,1},{2,2},{3,3} }; //隐式类型转换,先构造临时对象,再拷贝构造->编译器优化为拷贝构造return 0;
}

 运行结果:

 这里没有写delete,所以没有调析构函数。

四、new失败

堆上的空间是有限的,new对象时也可能会失败。

int main()
{//1M 约等于 100万Byte//1G 约等于 10亿Bytevoid* p1 = new char[1024 * 1024 * 1024]; //每次申请约为1G空间cout << p1 << endl;void* p2 = new char[1024 * 1024 * 1024];cout << p2 << endl;void* p3 = new char[1024 * 1024 * 1024];cout << p3 << endl;return 0;
}

运行结果:

 从运行结果上看,我们不难发现第一块空间开辟成功,但第二块就失败了。

在C语言中,我们申请一块空间后会进行判断,若返回值不为空就正常进行,否则就不执行下面的代码了。

而在C++中,new失败后,不用再进行判断了,系统会抛出(throw)一个异常表示new失败了。

我们可以用try、catch这两个关键字来捕获异常。

int main()
{//new失败会抛异常try catchtry{void* p1 = new char[1024 * 1024 * 1024]; ///每次申请约为1G空间cout << p1 << endl;void* p2 = new char[1024 * 1024 * 1024];cout << p2 << endl;void* p3 = new char[1024 * 1024 * 1024];cout << p3 << endl;}catch (const exception& e) //exception是C++库中一个异常的类型{cout << e.what() << endl; //获取所发生的异常}return 0;
}

运行结果:
 

 这就说明了,后续没有内存使用了,内存分配不当。try中捕获到异常后后续代码不会执行,直接跳到catch中去。

我们也可以利用这一机制看看堆中内能给我们申请多少内存:

void Func()
{int n = 1;while (1){void* p1 = new char[1024 * 1024]; //大约申请1M空间cout << p1 << "->" << n << endl;++n;}
}
int main()
{try{Func();}catch (const exception& e){cout << e.what() << endl;}return 0;
}

运行结果(32位环境下):

可以看出,堆可以给我们申请的空间不到2G=2048M,这里是1900M。

在32位环境下,虚拟内存大约只有4G(2^32Byte),而堆就给你了差不多一半(大约是1.9G),其中内核部分占了差不多1G,说明了栈的空间是很小的(一般情况下是8M),堆的空间还是很大的。在64位环境下会更大。

五、new和delete底层原理

1、operator new和operator delete

new和delete是用户进行动态内存申请和释放的关键字,operator new和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间。

下面看一下这两个全局函数的源码实现:

/*
operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间失败,尝试执行空间不足应对措施,如果改应对措施用户设置了,则继续申请,否
则抛异常。
*/
void* __CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{// try to allocate size bytesvoid* p;while ((p = malloc(size)) == 0)if (_callnewh(size) == 0){// report no memory// 如果申请内存失败了,这里会抛出bad_alloc 类型异常static const std::bad_alloc nomem; //bad_alloc是exception的子类_RAISE(nomem);  //宏,底层其实就是throw}return (p);
}/*
operator delete: 该函数最终是通过free来释放空间的
*/
void operator delete(void* pUserData)
{_CrtMemBlockHeader* pHead;RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));if (pUserData == NULL)return;_mlock(_HEAP_LOCK); /* block other threads */__TRY/* get a pointer to memory block header */pHead = pHdr(pUserData);/* verify block type */_ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));//空间的的释放_free_dbg(pUserData, pHead->nBlockUse);__FINALLY_munlock(_HEAP_LOCK); /* release other threads */__END_TRY_FINALLYreturn;
} /*
free的实现
*/
#define free(p) _free_dbg(p, _NORMAL_BLOCK)

通过上述两个全局函数的实现知道,operator new实际也是通过malloc来申请空间,如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供该措施就继续申请,否则就抛异常。operator delete最终是通过free来释放空间的。

2、new和delete的实现原理

new其实分为两部分,一是开空间,二是调用构造函数,它没有单独搞一套开空间的东西出来,它其实本质就是调用的malloc,但它没有直接调用malloc,而是调用了"披着马甲"的malloc,为什么不直接调用malloc呢?因为malloc失败了就会返回空,而C++期望的是失败了抛出异常,按异常的机制来走,所以他用operator new为malloc套了一个"马甲",可以理解为new是malloc的"加强版",malloc是new的组成部分。

对于内置类型,new的大致流程就是调用operator new函数申请空间。

对于自定义类型,new的大致流程就是先调用operator new函数申请空间,然后在申请的空间上执行构造函数,完成对象的构造初始化。

表面上是new,但我们通过汇编代码可以看到它调用了operator new和构造函数。

对于delete也是如此,通过汇编代码可以看到它先调用析构函数然后调用operator delete释放空间。

注意对于delete,是先调用析构函数,因为如果先调用operator delete释放空间了,那还怎么调用析构函数。所以是先调用析构函数释放内部资源,再调用operator delete释放空间。

对于内置类型,delete的大致流程是调用operator delete函数释放对象的空间

对于自定义类型,delete的大致流程就是先在空间上执行析构函数,完成对象中资源的清理工作,然后调用operator delete函数释放对象的空间。

3、new T[N]和delete[]

1、new T[N]

对于内置类型,调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请。

对于自定义类型,首先调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请,然后在申请的空间上执行N次构造函数。

2、delete[]

对于内置类型,调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间。

对于自定义类型,先在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理,然后调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间。

六、代码分析

先看一段代码:

class A
{
public:A(int a1 = 0, int a2 = 1): _a1(a1), _a2(a2){cout << "A(int a1 = 0, int a2 = 1)" << endl;}~A(){cout << "~A()" << endl;}
private:int _a1;int _a2;
};class B
{
private:int _b1 = 1;int _b2 = 2;
};int main()
{B* p1 = new B[10];delete p1;A* p2 = new A[10];delete p2;//err//delete[] p2; //OKreturn 0;
}

这段代码运行起来会崩溃,若将p2那部分注释掉就不会崩溃。

不对呀?p1和p2的代码不是一样吗,为什么将p2那部分注释掉就会崩溃呢?

我们试着分析一下:A对象和B对象的大小都是80字节,按理说应该用delete[]而不是delete才对,

是的,没错,但是在这里B这样写也不会报错的。

对于new T[N](T是自定义类型),这种情况,编译器会额外开4个字节来存放析构的次数,也就是说:

也就是说p1前面也应该有4个字节来存放次数,这个次数析构时用来算析构次数的。我们在A类中写了析构函数,所以它一共会开辟84个字节,在B类中没有写析构,但经过调试可以看到只开辟了80字节,这是因为编译器优化了,它看你没有写析构,它自己的默认析构也没什么作用,它自己也认为没有析构的必要,所以干脆不调析构了,所以只开辟了80个字节,delete p1不会发生崩溃是因为我就开辟了80字节,而p1就是起始位置所以free完全了,没有问题,而delete p2会发生崩溃由于不是delete[],编译器认为我不用看析构次数,所以我就从p2这个位置free就行(一段空间的中间位置),结果free没有完全,造成内存泄漏,报错。

所以一定要匹配使用,防止出错。 

七、定位new表达式(placement-new)

我们已经了解了new的底层原理,是先调用operator new再调析构函数,据此我们也可以不用new来申请空间,delete也类似。

class A
{
public:A(int a1 = 0, int a2 = 1): _a1(a1), _a2(a2){cout << "A(int a1 = 0, int a2 = 1)" << endl;}~A(){cout << "~A()" << endl;}
private:int _a1;int _a2;
};int main()
{//1A* p1 = new A(1);delete p1;//2A* p2 = (A*)operator new(sizeof(A)); //这里只申请空间,并没有调用构造函数new(p2)A(1); //显示调用构造函数初始化,只能用定位new表达式(placement-new)p2->~A();//显示调用析构函数,调用构造函数时不能用p2->A(1)的形式operator delete(p2);//释放空间//其实1和2效果是一样的。主要是书写形式不同return 0;
}

因为operator new和operator delete是全局函数,所以我们可以直接调用。定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象。

使用格式:

new (place_address) type或者new (place_address) type(initializer-list)。

place_address必须是一个指针,initializer-list是类型的初始化列表。

使用场景:

定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化,所以如
果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。

八、malloc/free和new/delete的区别

malloc/free和new/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。不同的地
方是:

  1. malloc和free是函数,new和delete是关键字。
  2. malloc申请的空间不会初始化,new可以初始化。
  3. malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可,
    如果是多个对象,[]中指定对象个数即可。
  4. malloc的返回值为void*, 在使用时必须强转,new不需要,因为new后跟的是空间的类型。
  5. malloc申请空间失败时,返回的是NULL,因此使用时必须判空,new不需要,但是new需
    要捕获异常。
  6. 申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数,而new
    在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成
    空间中资源的清理释放。

九、总结

到这里就结束了,本篇主要讲解了C/C++内存分布以及可能出现的问题,希望对大家有所帮助,有不足的地方多多指正,祝大家天天开心!

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com