C++内存管理

一、C/C++内存分布

首先我们要先来了解一下内存中的五大区域划分,总共是有【栈区】、【堆区】、【共享段库】、【静态区/数据段】、【代码段】这些
在这里插入图片描述
我们在【Linux进程地址空间】中也提到过这个,像这个虚拟进程地址空间的最上层是个高地址,它是给Linux的内核空间(Kernal)使用的,接下去的 栈区 建立出栈帧存储局部数据,即用即销毁,例如我们在构造二叉树的时候递归调用完当前父节点的左子树时,其右子树其实使用的也是使用的同一块空间
那我们使用的数据不止是有局部的一些临时数据,还有一些需要从程序运行开始直到程序结束都存在的,它们即为静态数据和全局数据,是存放在 静态区/数据段 中的
当前还有一些常量数据或者是函数在编译完后的指令,它们都是存放在 常量区/代码段 的,这块空间是不可以修改的哦
其实用到最多的还是我们从 堆区 中申请的动态数据,例如我们在使用C语言实现数据结构的时候会用到malloc去堆区中申请空间,本文我们会大量地讲到有关动态内存的申请这一块的内容
在这里插入图片描述
接下去我们来看下面的一段代码和相关问题

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);
}

在这里插入图片描述
【答案分析】:
选择题(从左往右):CCCAA
很明显前三个globalVar、staticGlobalVar、staticVar都是存放在数据段(静态区)的,其生命周期是从程序开始到结束为止
选择题(从左往右):AAADAB
下面的六个其实我们可以先看左侧的三个,对于它们而言其实都是在栈区开辟出栈帧来进行存放,所以他们都在【栈区】中
然后对于char2来说,很多同学就会认为它是在【常量区】中的,还记得我们在C语言的数组章节所谈到字符数组吗,其数组名为首元素地址,那我们对首元素地址去进行解引用的话就拿到了首字符的地址,那么这只是一个字符而已,并不是一个字符串,所以是存放在【栈区】中的
在这里插入图片描述
那对于
pChar3呢,很明显它是pChar3是一个指针,其指向的是【常量区】中的一个常量字符串,此时对这个指针去进行解引用也就找到了这个字符串,那么pChar3即存放在【常量区】中
最后就是
ptr1,它指向的是堆区中的一块空间,*解引用即存放在【堆区】中
填空题(从左往右):40、5、4、4/8、4、4/8
首先num1是一个具有10个空间的整型数组,初始化了前4个数据为1、2、3、4,那sizeof(num)即为40
char2这个字符数组里面存放着一个字符串,那使用【sizeof()】去进行求解的话会去统计加上\0之后一共有多少个字符,那很明显就是5。【strlen()】的话是请求从字符串首到\0为止的字符个数,不计算\0,那么就一共有4个字符
接下去是sizeof(pChar3),要知道它可是个指针,那对于指针来说均为 4/8 取决于当前的运行环境是32位还是64位的,那么strlen(pChar3)即是在求解这个字符串的长度,即为4
最后则是sizeof(ptr1),它也是一个指针,所以大小为 4/8 个字节
sizeof 和 strlen 的区别?
sizeof() 是操作符,不是函数,它是用来计算对象或者类型创建的对象所占内存空间的大小
strlen() 是函数,它是用来求字符串长度的,计算的是字符串之前 ‘\0’ 出现的字符个数,如果没有看到 ‘\0’ 会继续往后找
看完了上面的这些题后,我们再来在通过画图来进行一个对照,就可以看得非常清晰了
在这里插入图片描述

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

C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。
1、new/delete操作内置类型
接下去就让我们来看在C++中如何使用new这个关键字来动态申请空间

// 动态申请一个int类型的空间
int* p1 = new int;

// 动态申请一个int类型的空间并初始化为10
int* p2 = new int(10);

// 动态申请10个int类型的空间
int* p3 = new int[10];

那既然申请了,我们就要去释放这些空间,C语言中使用free,但是在C++中呢,我们使用delete,对于普通的空间我们直接delete即可,但是对于数组来说,我们要使用delete[],这点要牢记了

delete p1;
delete p2;
delete[] p3;

要知道,在C语言中我们使用malloc在开辟出空间的时候无法去做到初始化,那C++中的new呢,可以吗?通过调试我们可以观察到除了p2所指向的那块空间初始化了,其余都没有,那就可以说明它是可以去一个初始化工作的
在这里插入图片描述
此时我们就要来所说C++在通过new开辟出一块空间的时候,如何去做一个初始化的工作
可以看到,对于单块的内存区域,只需要使用new 数据类型(初始化数值)的方式即可;而对于像数组这样的空间,我们要使用new int[5]{初始化数值}的形式去进行,此时才可以做到一个初始化

int* p2 = new int(10);
int* p3 = new int[5]{ 1,2,3,4,5 };

2、new/delete操作自定义类型
看完了使用new/delete如何去操作C++中的【内置类型】,接下去我们来看看我们要如何去操作一个自定义类型
首先我们来看看C语言中我们是如何去操作自定义类型的,下面有一个单链表的结构体,此时我们若是要构建出一个个链表结点的话,还需要去调用下面这个BuyListNode()函数,很是麻烦

struct ListNode {
	int val;
	struct ListNode* next;
};

struct ListNode* BuyListNode(int x)
{
	struct ListNode* node = (struct ListNode*)malloc(sizeof(struct ListNode));
	if (NULL == node)
	{
		perror("fail malloc");
		exit(-1);
	}
	node->val = x;
	node->next = NULL;

	return node;
}
struct ListNode* n1 = BuyListNode(1);
struct ListNode* n2 = BuyListNode(2);
struct ListNode* n3 = BuyListNode(3);

但如果用C++的话就不一样了,我们可以使用之前所学习过的构造函数初始化列表在开辟出空间的时候就做一个初始化的工作,做到事半而功倍

struct ListNode {
	int val;
	struct ListNode* next;
	ListNode(int x)
		: val(x)
		, next(NULL)
	{}
};
ListNode* n4 = new ListNode(1);
ListNode* n5 = new ListNode(2);
ListNode* n6 = new ListNode(3);

通过调试我们可以观察到这里为n1、n2、n3开出了空间并进行了一个初始化的工作
在这里插入图片描述
所以经过上面的观察我们可以知道在C++中使用new是会去自动调用构造函数并完成初始化的
那一个类中可不仅仅有【构造函数】,还有【析构函数】呢,而对于delete而言,就会去调用这个析构函数,我们通过调试再来看看
通过观察可以发现,构造了几个对象就会去调用几次析构,相同的也会去调用几次析构
在这里插入图片描述
那请问像上面这样会去做一个初始化吗?这是类A的构造函数

A(int a = 0)
	: _a(a)
{
	cout << "A():" << this << endl;
}

很明显可以看到,是可以去做一个初始化工作的,原因就在于构造函数中我给到了一个缺省值
在这里插入图片描述
但是,若我将构造函数中的缺省值给去掉的话,就会出现没有合适的默认构造函数可用这个问题
在这里插入图片描述
这个我们在上面有学习到过,只需要在后面加上{},然后在里面给到初始化的值即可

A* p3 = new A[4]{ 1,2,3,4 };

最后,还有一点要切记,malloc出来的一定要用free,而new出来的一定要用delete,千万不可混用了!!!
首先看到,如果是对于单个对象而言的,还不会发生什么严重的问题
在这里插入图片描述
但如果是多个对象的话,就会出现一些很严重的问题,这还要去考虑一些底层的实现
在这里插入图片描述
好,最后我们来小结一下上面的内容
动态申请【内置】类型的数据
new/malloc除了用法上面,没有什么本质区别
动态申请【自定义】类型的数据
new/malloc除了用法上面,还有一个重大的区别,即new/delete会去调用构造函数并初始化,析构函数清理
malloc出来的就要用free释放,new出来的就要用delete释放,不要混淆了

四、operator new与operator delete函数【⭐】

上面我们讲到了,new和delete是用户进行动态内存申请和释放的操作符,而本小节我们则要来讲有关operator new 和 operator delete 这两个系统提供的全局函数
1、汇编查看编译器底层调用
上面呢我们在讲到了C++中会使用new去开空间并进行初始化,那在编译器底层究竟是如何去实现这一块逻辑的呢?这我们需要通过汇编来进行查看

A* a1 = new A(1);
delete a1;

new在底层调用operator new全局函数来申请空间
这里我们需要关注的点有两个,即这两个call指令的调用,分别是调用【operator new】从堆区去开空间和调用【A::A】这个构造函数去进行初始化工作
在这里插入图片描述
delete在底层通过operator delete全局函数来释放空间
这里我们需要关注的点也有两个,即这两个call指令的调用,分别是调用【A::~A】去析构函数释放资源和调用【operator delete】这个函数去释放从堆区申请的空间。不过呢,它们这两个部分被编译器做了一个封装,在外层我们还需用通过一个call指令和jmp指令去做一个跳转,才能看到底层的这块实现
在这里插入图片描述
2、透过源码分析两个全局函数
那有些同学一定会很好奇这个【operator new】和【operator delete】到底是个什么东西,现在我们就来讲讲这两个全局函数
首先的话是operator new,通过查看它的源代码我们可以发现其内部还是使用【malloc】去堆中申请空间的

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);
}

接下去的话是是operator delete,仔细去观察的话可以看出这段代码有调用到一个_free_dbg()这个函数,它其实就是我们在C语言中所写的free()函数,那么就可以得出其实这个函数底层也和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_FINALLY
	return;
}

那既然这两个全局函数的底层实现用的都是【malloc】和【free】的话,是不是我们在使用的时候就可以直接用operator new和operator delete来进行替代呢?
准确来说是这样的。例如看下面的这段代码,我将所有使用到【malloc】和【free】的地方都换成了operator new和operator delete,然后再去运行看看,会发生什么

int main(void)
{
	int* p1 = (int*)operator new(sizeof(int));
	int* p2 = new int;

	operator delete(p1);
	delete p2;

	A* a1 = (A*)operator new(sizeof(A));
	A* a2 = new A(1);

	operator delete(a1);
	delete a2;
	return 0;
}

通过调试我们可以观察到,这个operator new和operator delete的效果就等同于【malloc】和【free】,因为从上面的源码我们观察到了其内部是包含了这两个内存函数的
在这里插入图片描述

五、new和delete的实现原理

在上一小节中,我们学习到了两个全局函数, 分别是【operator new】和【operator delete】,通过分析可以得出它们的底层都是基于【malloc】和【free】来进行实现的。本小结呢,我们继续回归C++中的new和delete,来讲它们的底层实现原理
1、 内置类型
首先要来看的就是内置类型的,现在我去堆上申请1024 * 1024个字节的空间,我们之前在使用【malloc】的时候一般都都会去检查一下,因为VS2019的编译器这块检查得过于严格了,其实对于【malloc】来说一般是不会申请失败的,但是对于下面这种,却会出现类似的问题,我们一起来瞧瞧

int main(void)
{
	int* p1 = nullptr;
	do
	{
		p1 = (int*)malloc(1024 * 1024);
		cout << p1 << endl;
	} while (p1);

	return 0;
}

然后我们把代码运行起来可以看到,这边的p1为空了,那也就说明空间申请失败了
在这里插入图片描述
我们也可以通过【任务管理器】中的Vistual Studio Debugger来进行查看,一般我们启动程序后,这个进程就会开始跑了,此时我们可以观察到这个内存的占比是很快地飚了上去,但是在1900M左右就停了下来,为什么呢?本身进程的地址空间就只有4个G,那在这里我估计分配给VS的就只有2个G,但是呢又不是实打实的2个G,所以呢将内存申请完了之后就返回了NULL
上面的这种申请失败所返回的结果,在C++中其实并不太喜欢使用,对于C++这门面相对象的语言,甚至是像Java、Python、C#这样的语言,更加喜欢使用[抛异常]的形式来返回失败的结果,那具体怎么抛呢,我们先将上述的代码改成C++的形式

int main(void)
{
	int* p1 = nullptr;
	do
	{
		p1 = new int[1024 * 1024];
		cout << p1 << endl;
	} while (p1);

	return 0;
}

然后去运行代码就可以发现在申请失败后这个指针p1并没有变为0x0000000,而是在引发了一个异常,这就是C++对于某些问题喜欢用的方式
在这里插入图片描述
然后我们在运行程序碰到异常后便会去走catch部分的内容,通过w.what()输出打印出了【bad allocation】这个问题,意思就是申请失败被错误分配
这一块要讲起来其实需要涉及C++中的异常处理和多态,之后专门出文章做讲解
在这里插入图片描述
2、 自定义类型
首先来看看【new】和【delete】的真正执行原理吧,学习了operator new和operator delete之后相信你对这些一定会产生共鸣
new的原理
调用operator new函数申请空间
在申请的空间上执行构造函数,完成对象的构造
delete的原理
在空间上执行析构函数,完成对象中资源的清理工作
调用operator delete函数释放对象的空间
下面是具体的原理实现图,对照着看更好一些
在这里插入图片描述
有了理论基础后,接下去我们就通过代码来进行一个加深理解。可以看到这里是有一个Stack类,我们要实现的就是在堆上去申请一个栈对象,那又涉及【堆】,又涉及【栈】,该如何去理解呢?

typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 3)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	// 其他方法...
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	int _capacity;
	int _size;
};

int main(void)
{
	// 需要申请一个堆上的栈对象
	Stack* s1 = new Stack();
	delete s1;
	return 0;
}

我们通过下面这幅图来进行一个理解,首先我们在栈区定义出来一个指针p1,接着我们使用到了new在堆区中为其开辟出了一块空间来存放这个对象中的成员变量,但是呢,这个对象中有一个array指针,它也需要一块空间来存放,于是我们又在堆区中开辟出了一块空间初始化了这个_array指针,让其也指向堆区中的一块空间
那我们遵循[new]的原理再来分析一遍:首先需要为这个栈对象在【堆区】开辟出一块空间,这件事情就需要交给operator new来做 ,当空间开好之后,我们知道还会去调用构造函数来完成一个初始化的工作,对于内置类型的话不做处理,但是对于自定义类型的话会去调用它的默认构造函数,不过我们这里写了构造函数的话就会去调用我们写过的,将其他两个内置类型也去做一个初始化
在这里插入图片描述
看完new之后,我们再来讲讲delete,那我么直接通过原理来进行描述,在这里我们首先要去做的就是调用【析构函数】,那有同学问:为何没有像new那样先去释放空间呢,而是先去调用了析构函数?
这一块就要重点讲一讲了,若是我们直接去释放掉这块空间的话,即这个对象在【堆区】中的空间就找不到了,那么这个_array就变成了一个野指针,此时若再去调用【析构函数】的话就会出现大问题,所以说我们要先去调用析构函数释放掉_array所指向的这块堆区中的空间,然后再使用operator delete去释放掉这块空间,这即是[delete]的调用原理
在这里插入图片描述
看完了【new】和【delete】的调用原理之后,我们再来看看【new T[N]】和【delete[]】的原理
其实原理是差不多的,只不过在上面是对单个对象进行操作,这里是对多个对象进行操作,读者可以试着自己去模拟一下,理解理解,这里便不做过多展开
new T[N]的原理
调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请
在申请的空间上执行N次构造函数
delete[]的原理
在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值