前言
我们之前学习完了类和对象的相关内容,已经正式入门了。我们本节来学习一下C++的内存管理,看一看C++的语法和C语言的语法有什么区别和联系。那么废话不多说,我们正式进入今天的学习。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1. C语言和C++的内存分布
在开始学习之前,我们需要知道:C++的内存管理模式与C语言的内存管理形式保持一致。
通过回顾之前所学习过的知识,我们可以知道:C++和C语言程序内存区域是要进行划分的
内存管理存在的意义是:帮助计算机更加方便地处理各种各样的数据。那么不同的数据在内存之中又是如何划分的呢?我们知道:程序中会存在一些局部数据,需要建立栈帧,而且这一类数据都是用一会就要被销毁的;程序中还有一些长期存在的数据,例如全局数据、静态数据;程序中还有一些不能修改的数据,例如常量数据;程序中还有一些动态申请的数据。根据这些数据的使用环境和生命周期,C++和C语言将数据划分为以下几个段:
**************************************************************************************************************
内核空间
用户代码不能读写,内核空间使用场景较少,不做过多介绍
栈
我们知道,函数的调用需要建立栈帧,栈帧就是存放在栈区域的。栈又叫堆栈,用于存放非静态局部变量、函数参数、返回值 等等。栈是向下增长的
内存映射段
内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存,做进程间通信。经常用于文件映射、动态库等,在Linux中应用广泛
堆
堆用于程序运行时动态内存分配 ,内存中动态申请的数据就存放在堆中,堆是向上增长的
数据段(静态区)
全局数据和静态数据就定义在数据段中
代码段(常量区)
用于存放常量 以及编译好的指令
**************************************************************************************************************
下面我们来用一个题目深入理解C++中的内存管理:
cpp
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.代码段(常量区)
(1)globalVar存放在哪里?____
(2)staticGlobalVar存放在哪里?____
(3)staticVar存放在哪里?____
(4)localVar存放在哪里?____
(5)num1存放在哪里?____
(6)char2存放在哪里?____
(7)*char2存放在哪里?___
(8)pChar3存放在哪里?____
(9)*pChar3存放在哪里?____
(10)ptr1存放在哪里?____
(11)*ptr1存放在哪里?____
(1)C
globalVar是一个全局的数据,存放于数据段中
(2)C
staticGlobalVar是一个全局的静态数据,故存放于数据段中
(3)C
staticVar虽然是函数中的一个变量,但它通过static的修饰变成了静态变量,故存放于数据段中
(4)A
localVar是Test函数中的一个临时变量,故存放于栈中
(5)A
num1是一个数组名,这里代表的是整个数组,故存放于栈中
(6)A
(注意:这里的 char2 开空间的时候会开5个字节,因为包含 \0)char2 是一个数组名,这里代表的是整个数组,表示在栈上开辟了一个五个字节的空间,并且将 abcd\0 拷贝过去
(7)A
做运算的时候数组名代表首元素的地址,所以 *char2 代表的是数组的首元素 a ,因为 a 是存放在栈上的,所以选A
(8)A
pchar3 虽然被const修饰,但 pchar3 是一个局部变量,故存在于栈上
(9)D
*pchar3 指向的是 "abcd" 这个常量字符串,所以 *pchar3 在代码段中
(10)A
ptr1 表示的是一个指针变量,所以仍然存放于栈中
(11)B
*ptr1 指向动态申请的空间,故在堆上
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2. C语言中的动态内存管理方式
我们在C语言中用 malloc / calloc / realloc /free 来实现动态内存的管理,我们来回顾一下:malloc / calloc / realloc 三者的区别是什么?
malloc 函数可以用来动态申请空间,但是动态申请的空间不会被初始化
calloc 函数也可以用来动态申请空间,而且可以将其初始化,其功能相当于 malloc + memset
realloc 函数是用来扩容的,如果原扩容的空间不足的时候将会重新开辟一块新的空间,并将原空间内的所有数据内容拷贝至新的空间中,返回新空间的地址
free 函数用来释放动态申请的空间
我们来看一个问题:
cpp
void Test ()
{
int* p2 = (int*)calloc(4, sizeof (int));
int* p3 = (int*)realloc(p2, sizeof(int)*10);
free(p3 );
}
我们 free 完了 p3 还需要 free 掉 p2 吗?
答案是:不需要,因为 p3 存放的是 p2 扩容后的地址,扩容分为原地扩容和异地扩容,原地扩容的地址不变,而异地扩容会返回扩容后的地址,原地址就会被自动销毁,所以不需要释放 p2
我们可以通过下面的链接来扩展了解一下 malloc 的实现原理,不作硬性的要求:https://www.bilibili.com/video/BV117411w7o2/?spm_id_from=333.788.videocard.0&vd_source=416ea8b68b7adbc90fbd084c6cf37138
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3. C++的内存管理方式
C++中可以继续使用C语言的内存管理方式,但是有些地方在使用C语言的内存管理方式的时候就会遇到一些问题,而且在使用的时候会比较麻烦,因此C++提出了自己的内存管理方式:通过new和delete操作符来实现动态内存的管理,下面我们就来介绍new和delete的使用方法
假设我们要申请一个类型为 int 的动态空间
如果使用 malloc 就需要传很多的数据,还需要强制类型转换,我们使用 new 就会方便很多,new 简化了 malloc 的操作步骤:
cpp
int* p1 = new int;
假设动态申请一个int类型的空间并初始化为10
cpp
int* p2 = new int(10);
假设动态申请10个int类型的空间
cpp
int* p3 = new int[10];
**************************************************************************************************************
如果我们需要释放动态申请的空间也很方便,但是我们需要注意,释放单个的空间和多个的空间的方法有些不同:
释放单个动态申请的空间只需要在 delete 后直接加上指向该空间的指针变量即可;而释放多个动态申请的空间需要在 delete 后面加上一个 [] 再写指针:
cpp
delete p1;
delete p2;
delete[] p3;
**************************************************************************************************************
我们来具体讲解一下 new 的初始化:
刚才我们讲了单个对象的初始化,假设要进行多个对象的初始化,就需要采取下面的语法:
cpp
int* p4 = new int[10] {0};
int* p5 = new int[10] {1, 2, 3, 4, 5};
如果像 p5 一样采取的是不完全的初始化,那么未初始化的部分会默认初始化为0
(还有部分更复杂的初始化在下面会提及)
**************************************************************************************************************
学习到这里,我们对于 new 和 delete 的用法就有了基本的了解,那么C++设计 new 和 delete 仅仅是为了更加方便吗?还有没有其他的用处?
答案是肯定的,我们先来创建一个类A
cpp
class A
{
public:
A(int a = 0)
: _a(a)
{
cout << "A():" << this << endl;
}
~A()
{
cout << "~A():" << this << endl;
}
private:
int _a;
};
我们再 new 一个类型为 A 的动态空间:
cpp
int main(void)
{
A* p1 = new A;
A* p2 = new A(1);
delete p1;
delete p2;
return 0;
}
若此时我们运行程序就会发现,我们在动态申请一个自定义类型的空间的时候,自动调用了它的构造函数;而当我们用 delete 释放动态申请的空间的时候会调用它的析构函数:
**************************************************************************************************************
.................................................................................................................................
当有了 new 和 delete 的时候,我们再去写链表等数据结构的时候就会非常的轻松:
cpp
struct ListNode
{
int val;
ListNode* next;
ListNode(int x)
:val(x)
,next(nullptr)
{}
};
cpp
int main(void)
{
ListNode* n1 = new ListNode(1);
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(3);
ListNode* n4 = new ListNode(4);
ListNode* n5 = new ListNode(5);
n1->next = n2;
n2->next = n3;
n3->next = n4;
n4->next = n5;
return 0;
}
.................................................................................................................................
我们现在接着上面的内容继续讲 new 的初始化:
刚才我们所讲的类中只含有一个参数,如果类A中含有两个参数,且含有默认构造函数:
cpp
class A
{
public:
A(int a1 = 0, int a2 = 0)
: _a1(a1)
, _a2(a2)
{
cout << "A(int a1 = 0, int a2 = 0):" << this << endl;
}
~A()
{
cout << "~A():" << this << endl;
}
private:
int _a1 = 1;
int _a2 = 1;
};
cpp
int main(void)
{
A* p1 = new A;
A* p2 = new A(2, 2);
A* p3 = new A[3];
return 0;
}
如上述代码,无论我们是给参数还是不给参数都能成功的完成初始化,而且 new n次,就会调用n次构造函数
但是假设没有默认构造函数呢?
我们来修改一下A类,将其改为没有默认构造函数的类:
cpp
class A
{
public:
A(int a1, int a2 = 0)
: _a1(a1)
, _a2(a2)
{
cout << "A(int a1 = 0, int a2 = 0):" << this << endl;
}
~A()
{
cout << "~A():" << this << endl;
}
private:
int _a1 = 1;
int _a2 = 1;
};
那么我们该修改下面的代码,让它能够成功定义并且初始化呢?
cpp
A* p3 = new A[3];
答案是:我们需要先定义三个对象,再把这三个对象作为参数传递给动态申请的空间:
cpp
int main(void)
{
A aa1(1, 1);
A aa2(2, 2);
A aa3(3, 3);
A* p3 = new A[3]{ aa1,aa2,aa3 };
return 0;
}
但是如果像这样定义对象来初始化就会比较麻烦,而且严格意义上来讲这里调用的就不是构造函数了,而是拷贝构造函数,因为 aa1、aa2、aa3 是已经存在的对象。所以此时我们就可以换一种写法,用匿名对象来初始化:
cpp
int main(void)
{
A* p4 = new A[3]{ A(1,1),A(2,2),A(3,3) };
return 0;
}
这种写法还会被编译器优化,就只需要调用构造函数就好了,而不用再调用拷贝构造函数了(详情见上一节的类和对象收尾)
除了这两种写法还有第三种写法:
cpp
int main(void)
{
A* p5 = new A[3]{ {1,1},{2,2},{3,3} };
return 0;
}
我们之前学习过了:单参数构造函数支持隐式类型转换,而多参数构造函数也支持隐式类型转换,第三种写法在本质上和第二种写法是一样的,这种写法也会被编译器优化,会转变为直接构造,而不去调用拷贝构造
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4.C++的抛异常(精简)
之前在学习C语言的时候,我们在 malloc 结束以后通常都会用 perror 检查申请空间是否成功。而我们在 new 一个动态空间的时候通常不用 perror 去检查,那么C++为什么不去检查呢?此时我们就要提前引入一个抛异常的概念(这里只作简单的说明,具体的细节后面会详细讲解)
在C语言中如果 malloc 失败了就会返回空指针,而在C++中 new 失败了并不会返回空指针,new 失败了会抛异常
抛异常由三个关键字组成:throw、try、catch
throw:发生异常了以后用 throw 抛出一个对象
try / catch:对异常进行处理
(面向对象的语言通常都会有抛异常的步骤)
我们先来看一下申请空间失败了是怎么抛异常的
因为在正常的情况下,我们动态申请的空间都很小很小,很难开辟空间失败抛异常,所以这里我们一次申请1GB的内存空间(32位下):
cpp
int main(void)
{
void* p1 = new char[1024 * 1024 * 1024];
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;
}
此时我们就需要用到 try、catch 就可以大致知道发生了什么错误(此部分仅作大致了解即可):
cpp
int main(void)
{
try
{
void* p1 = new char[1024 * 1024 * 1024];
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)
{
cout << e.what() << endl;
}
return 0;
}
bad allocation 的意思是内存申请失败了,没有足够的内存
若是像下面这种情况,当在函数中申请内存失败了以后就不会继续往函数下面走了,而是直接跳到 catch 的地方去:
cpp
void func()
{
void* p1 = new char[1024 * 1024 * 1024];
cout << p1 << endl;
void* p2 = new char[1024 * 1024 * 1024];
cout << p2 << endl;
void* p3 = new char[1024 * 1024 * 1024];
cout << p3 << endl;
}
int main(void)
{
try
{
func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
严格意义来讲,写C++的程序的时候都需要 try 和 catch 来对异常进行处理
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5. operator new 与 operator delete 函数
new 和 delete 是用户进行动态内存申请和释放的操作符,operator new 和 operator delete 是系统提供的全局函数,new 在底层调用 operator new 全局函数来申请空间,delete 在底层通过 operator delete 全局函数来释放空间
operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间 失败,尝试执行空间不足应对措施,如果改应对措施用户设置了,则继续申请,否则抛异常
我们先来看一下 operator new 的底层代码:
cpp
void* __CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
// try to allocate size bytes
void* p;
while ((p = malloc(size)) == 0)
if (_callnewh(size) == 0)
{
// report no memory
// 如果申请内存失败了,这里会抛出bad_alloc 类型异常
static const std::bad_alloc nomem;
_RAISE(nomem);
}
return (p);
}
由上述的代码可以知道,当申请内存空间失败的时候,就会抛出 bad_alloc 类型的异常。bad _alloc 是 exception 的子类
这里不做详细的讲解,我们需要学习过继承与多态以后才能知道,只需要了解即可
上述代码中的 RAISE 在底层是一个宏,RAISE 就是 throw 即当 malloc == 0 的时候就会抛异常。通过这些我们就可以知道,其实 new 的底层还是 malloc
下面我们来看一下 delete 的底层实现:
cpp
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_FINALLY
return;
}
delete 的这一大段代码我们暂时不需要去关注,我们只需要关注最重要的一行代码:
cpp
_free_dbg(pUserData, pHead->nBlockUse);
那么这里的 _free_dbg 和 free 的关系是什么呢?我们来看一下 free 的实现:
cpp
#define free(p) _free_dbg(p, _NORMAL_BLOCK)
由此我们知道,free 是一个宏函数,free 的底层也是调用的 _free_dbg ,所以我们也可以知道 delete 的底层是 free
学到这里我们可能会有疑问,new 不是一个函数调用,那又是如何转换成 operator new 的呢?其实是编译器在编译的时候直接把它生成对应的指令
我们先来看看 new 的反汇编
其中下面的代码表示的是申请空间,如果申请失败就抛异常:
而这里的代码则表示调用构造函数:
我们再来看看 delete 的反汇编:
我们在看 delete 函数的反汇编时,并没有直接的看到 _free_dbg ,而是调用了析构函数:
来调用完了析构函数再调用 operator delete
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6. new和delete的实现原理
内置类型:
如果申请的是内置类型的空间,new 和 malloc ,delete 和 free 基本类似,不同的地方是:new/delete 申请和释放的是单个元素的空间,new[] 和delete[] 申请的是连续空间,而且new在申 请空间失败时会抛异常,malloc 会返回 NULL 。
自定义类型:
new的原理:
-
调用operator new函数申请空间
-
在申请的空间上执行构造函数,完成对象的构造
delete的原理
-
在空间上执行析构函数,完成对象中资源的清理工作
-
调用operator delete函数释放对象的空间
new T[N]的原理:
-
调用 operator new[] 函数,在 operator new[] 中实际调用 operator new 函数完成N个对象空间的申请
-
在申请的空间上执行N次构造函数
delete[] 的原理
-
在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
-
调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间
在使用 delete[] 的时候我们不需要自己去传参数
cpp
int main(void)
{
A* p1 = new A(1);
delete p1;
A* p2 = new A[5];
delete[] p2;
return 0;
}
此时我们可能会有疑问:假设我们使用语法不匹配会发生什么?
假设我们用 free 来释放 new 出来的空间
cpp
int main(void)
{
int* p1 = new int;
free(p1);
return 0;
}
我们可以看到,这里并没有出现什么问题,也不存在内存的泄露,因为这里的代码是内置类型的数据,不涉及调用析构函数。那如果这里的数据类型使用一个自定义类型呢?
cpp
A* p2 = new A;
free(p2);
此时使用 free 相比较于 delete 少调用了析构函数,如果自定义类型的析构函数涉及释放动态开辟的空间的时候,就会出现内存泄漏
所以使用语法不匹配可能会出现内存泄漏的风险
那么如果我们是 delete 和 delete[] 使用不当呢?
此时我们再来创建一个类B
cpp
class B
{
private:
int _b1 = 1;
int _b2 = 2;
};
int main(void)
{
int* p1 = new int[10];
delete p1;
return 0;
}
对于上述代码中的内置类型 而言,这里仍然不会出现问题。因为 new 在底层就是调用了 malloc 函数,因为内置类型没有涉及调用构造和析构函数,所以这里不会出现问题。
但是如果是自定义类型
cpp
int main(void)
{
B* p2 = new B[10];
delete p2;
return 0;
}
当自定义类型为B的时候,我们发现这里还是没有出现问题,我们把类型改成A:
cpp
int main(void)
{
A* p3 = new A[10];
delete p3;
return 0;
}
此时我们就发现程序崩溃了。A和B都是自定义类型,但是为什么B不会崩溃而A就崩溃了呢?
这里我们就需要从底层进行分析:我们先看到 A 类型的对象和 B 类型对象都是8个字节
我们转到反汇编,这里 new 在底层调用的是 operator new ,因为一个B类型的对象是8个字节,所以这里需要申请80个字节
但是我们申请了10个A对象的空间发现此时的空间大小不是80个字节了,而是84个字节,这是为什么呢?
当编译器开辟多个自定义类型的对象的数组的时候,会在开辟的空间前面多开辟4个字节,这开辟出来的4个字节用于存储对象的个数
而且我们 new A 的时候返回来的地址不是 这个存放对象个数的4个字节的地址,而是向后偏移4个字节的地址。此时直接释放就会导致不完全释放,而B中指针指向的就是它的起始位置,它没有额外多申请那4个字节的空间,所以不会存在问题
那为什么A开辟了4个字节存储对象个数,而B没有多开辟4个字节去存储个数呢?严格意义上来说A和B都需要开空间去存储对象的个数,B没有开空间是因为编译器发现B没有写析构函数所以将其优化了
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7. 定位new表达式(placement-new)
通过刚才的学习我们知道,我们写 new 的时候,编译器会自动帮我们调用 operator new 并且调用构造函数,其实我们也可以自己手动去调用 operator new 但是它不会自动帮我们调用构造函数:
cpp
int main(void)
{
A* p1 = new A(1);
cout << "**********************************" << endl;
A* p2 = (A*)operator new(sizeof(A));
return 0;
}
可以看到:p2 只开了空间,而没有调用构造函数。假设我们现在需要对一块已经存在了的空间显示的调用构造函数,此时定位 new 就可以帮助我们完成这个功能。接下来我们来讲解一下定位 new 的使用方法:
假设 p2 是一个已经存在了的空间,我们要对 p2 调用构造函数只需要采取以下的语法:
cpp
int main(void)
{
A* p1 = new A(1);
cout << "**********************************" << endl;
A* p2 = (A*)operator new(sizeof(A));
new(p2)A(1);
return 0;
}
假设我们要调用析构函数呢?
这里我们需要注意,调用构造函数是只能通过定位 new 来调用的,但是调用析构函数,是可以直接显示调用的:
cpp
int main(void)
{
A* p1 = new A(1);
cout << "**********************************" << endl;
A* p2 = (A*)operator new(sizeof(A));
new(p2)A(1);
delete p1;
p2->~A();
return 0;
}
或者也可以写 operator delete(p2)
有人可能会觉得这个动作多此一举,直接用 new 和 delete 更加方便,为什么还要专门设计一个定位 new 呢?感觉这个语法有点多此一举。
其实这个定位 new 在我们日常生活中写代码的时候基本上不需要使用,但是某些特定的场景下使用定位 new 会非常好。后面我们会学习到STL中有一个内存池的概念
内存池:我们首先需要知道一个技术叫做池化技术,池化技术的意思是我们可以建造一个"池子",将某些资源存入这个池子中,使用的时候就会更加方便、更快。池化技术有利于提升性能,常见的池化技术有:内存池、线程池、连接池......
我们以内存池为例:假设我们需要高频的申请和释放内存块,此时我们就可以专门建造一个内存池,内存池里面的空间仍然是在堆中来的,但是内存池里面的空间是专供使用的,别的地方是使用不了的,此时效率就会更加高。但是此时就会有一个问题,内存池只能给空间而不能调用构造函数进行初始化,此时定位 new 就有它存在的意义了
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
结尾
C++的内存管理和C语言的内存管理模式基本一样,所以上手比较快,那么关于C++的内存管理的所有内容就到此结束了,下一节我们将学习C++模板,模板是真正让C++和C语言拉开差距的语法。希望本节的内存管理内容可以给你带来帮助,谢谢您的浏览!!!