C/C++内存管理详解

C/C++内存管理详解_第1张图片

在这里插入图片描述

去年的今日,博主第一次发文,那时初出茅庐,没什么经验。时隔一年,更加优质的博文献上,希望可以帮助到更多的人❤️❤️❤️

文章目录

  • 前言
  • 一、C/C++内存分布
  • 二、C语言中动态内存管理方式
  • 三、C++内存管理方式
    • 1、new/delete操作内置类型
    • 2、new/delete操作自定义类型
  • 四、operator new与operator delete函数【⭐】
    • 1、汇编查看编译器底层调用
    • 2、透过源码分析两个全局函数
  • 五、new和delete的实现原理
    • 1、 内置类型
    • 2、 自定义类型
  • 六、定位new表达式 ※
    • 1、使用格式与使用场景介绍
    • 2、池化技术原理分析【高并发内存池雏形】
  • 七、常见面试题
    • 1、malloc/free和new/delete的区别
    • 2、内存泄漏
      • ① 什么是内存泄漏,内存泄漏的危害
      • ② 内存泄漏分类
      • ③ 如何检测内存泄漏
      • ④ 如何避免内存泄漏
  • 八、常见笔试题
  • 总结与提炼

前言

Hello,大家好,本文要为大家带来的是C/C++中的内存管理,也将为您解答什么是【栈区】、【堆区】、【静态区】等等,更好地认识数据在内存中的分布到底是怎样的

一、C/C++内存分布

首先我们要先来了解一下内存中的五大区域划分,总共是有【栈区】、【堆区】、【共享段库】、【静态区/数据段】、【代码段】这些

C/C++内存管理详解_第2张图片

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

C/C++内存管理详解_第3张图片

接下去我们来看下面的一段代码和相关问题

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

C/C++内存管理详解_第4张图片
【答案分析】:

  1. 选择题(从左往右):CCCAA
    • 很明显前三个globalVarstaticGlobalVarstaticVar都是存放在数据段(静态区)的,其生命周期是从程序开始到结束为止
  2. 选择题(从左往右):AAADAB
    • 下面的六个其实我们可以先看左侧的三个,对于它们而言其实都是在栈区开辟出栈帧来进行存放,所以他们都在【栈区】中
    • 然后对于*char2来说,很多同学就会认为它是在【常量区】中的,还记得我们在C语言的数组章节所谈到字符数组吗,其数组名为首元素地址,那我们对首元素地址去进行解引用的话就拿到了首字符的地址,那么这只是一个字符而已,并不是一个字符串,所以是存放在【栈区】中的

C/C++内存管理详解_第5张图片

  • 那对于*pChar3呢,很明显它是pChar3是一个指针,其指向的是【常量区】中的一个常量字符串,此时对这个指针去进行解引用也就找到了这个字符串,那么*pChar3即存放在【常量区】中
  • 最后就是*ptr1,它指向的是堆区中的一块空间,*解引用即存放在【堆区】中
  1. 填空题(从左往右):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 个字节
  2. sizeof 和 strlen 的区别?

sizeof() 是操作符,不是函数,它是用来计算对象或者类型创建的对象所占内存空间的大小

strlen() 是函数,它是用来求字符串长度的,计算的是字符串之前 ‘\0’ 出现的字符个数,如果没有看到 ‘\0’ 会继续往后找

看完了上面的这些题后,我们再来在通过画图来进行一个对照,就可以看得非常清晰了

C/C++内存管理详解_第6张图片

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

这一块读者可以直接看此篇文章 C生万物 | 细说动态内存管理,此处不再做赘述

【面试题】

  1. malloc/calloc/realloc的区别?
    • malloc用于分配指定大小的未初始化内存块,其不会对申请出来的内存块做初始化工作
    • calloc用于分配指定数量和大小的连续内存块,并将其初始化为0
    • realloc用于重新分配内存块的大小,并尽可能保留原有数据。其有两种扩容机制,分别为【本地扩容】和【异地扩容】,具体的扩容机制细述可以到上面的文章中进行查看
  2. malloc的实现原理?
    • 这一块属于拓展知识,感兴趣的同学可以了解一下,glibc中malloc实现原理

三、C++内存管理方式

C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过newdelete操作符进行动态内存管理。

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/C++内存管理详解_第7张图片

此时我们就要来所说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);
  • 通过调试我们可以观察到这里为n1n2n3开出了空间并进行了一个初始化的工作

C/C++内存管理详解_第8张图片

所以经过上面的观察我们可以知道在C++中使用new是会去自动调用构造函数并完成初始化的

  • 那一个类中可不仅仅有【构造函数】,还有【析构函数】呢,而对于delete而言,就会去调用这个析构函数,我们通过调试再来看看

C/C++内存管理详解_第9张图片

那如果我们操作的是多个对象呢,会去调用几次【构造】和【析构】?

  • 通过观察可以发现,构造了几个对象就会去调用几次析构,相同的也会去调用几次析构

C/C++内存管理详解_第10张图片

  • 那请问像上面这样会去做一个初始化吗?这是类A的构造函数
A(int a = 0)
	: _a(a)
{
	cout << "A():" << this << endl;
}
  • 很明显可以看到,是可以去做一个初始化工作的,原因就在于构造函数中我给到了一个缺省值

C/C++内存管理详解_第11张图片

  • 但是,若我将构造函数中的缺省值给去掉的话,就会出现没有合适的默认构造函数可用这个问题

C/C++内存管理详解_第12张图片

  • 这个我们在上面有学习到过,只需要在后面加上{},然后在里面给到初始化的值即可
A* p3 = new A[4]{ 1,2,3,4 };
  • 或者呢,你也可以像下面这样去写,通过构造出一些匿名对象来进行初始化,不过呢,这里编译器也会去做一个优化,将原本的拷贝构造给优化掉
A* p3 = new A[4]{A(1), A(2), A(3), A(4)};

最后,还有一点要切记,malloc出来的一定要用free,而new出来的一定要用delete,千万不可混用了!!!

  • 首先看到,如果是对于单个对象而言的,还不会发生什么严重的问题

C/C++内存管理详解_第13张图片

  • 但如果是多个对象的话,就会出现一些很严重的问题,这还要去考虑一些底层的实现

C/C++内存管理详解_第14张图片

好,最后我们来小结一下上面的内容

  1. 动态申请【内置】类型的数据
    • new/malloc除了用法上面,没有什么本质区别
  2. 动态申请【自定义】类型的数据
    • new/malloc除了用法上面,还有一个重大的区别,即new/delete会去调用构造函数并初始化,析构函数清理
  3. malloc出来的就要用free释放,new出来的就要用delete释放,不要混淆了

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

上面我们讲到了,newdelete是用户进行动态内存申请和释放的操作符,而本小节我们则要来讲有关operator newoperator delete 这两个系统提供的全局函数

1、汇编查看编译器底层调用

  • 上面呢我们在讲到了C++中会使用new去开空间并进行初始化,那在编译器底层究竟是如何去实现这一块逻辑的呢?这我们需要通过汇编来进行查看
A* a1 = new A(1);
delete a1;

new在底层调用operator new全局函数来申请空间

  • 这里我们需要关注的点有两个,即这两个call指令的调用,分别是调用【operator new】从堆区去开空间和调用【A::A】这个构造函数去进行初始化工作

C/C++内存管理详解_第15张图片

delete在底层通过operator delete全局函数来释放空间

  • 这里我们需要关注的点也有两个,即这两个call指令的调用,分别是调用【A::~A】去析构函数释放资源和调用【operator delete】这个函数去释放从堆区申请的空间。不过呢,它们这两个部分被编译器做了一个封装,在外层我们还需用通过一个call指令和jmp指令去做一个跳转,才能看到底层的这块实现

C/C++内存管理详解_第16张图片

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 newoperator delete来进行替代呢?

  • 准确来说是这样的。例如看下面的这段代码,我将所有使用到【malloc】和【free】的地方都换成了operator newoperator 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 newoperator delete的效果就等同于【malloc】和【free】,因为从上面的源码我们观察到了其内部是包含了这两个内存函数的

C/C++内存管理详解_第17张图片

五、new和delete的实现原理

在上一小节中,我们学习到了两个全局函数, 分别是【operator new】和【operator delete】,通过分析可以得出它们的底层都是基于【malloc】和【free】来进行实现的。本小结呢,我们继续回归C++中的newdelete,来讲它们的底层实现原理

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为空了,那也就说明空间申请失败了

C/C++内存管理详解_第18张图片

  • 我们也可以通过【任务管理器】中的Vistual Studio Debugger来进行查看,一般我们启动程序后,这个进程就会开始跑了,此时我们可以观察到这个内存的占比是很快地飚了上去,但是在1900M左右就停了下来,为什么呢?本身进程的地址空间就只有4个G,那在这里我估计分配给VS的就只有2个G,但是呢又不是实打实的2个G,所以呢将内存申请完了之后就返回了NULL

C/C++内存管理详解_第19张图片

  • 上面的这种申请失败所返回的结果,在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++对于某些问题喜欢用的方式

C/C++内存管理详解_第20张图片

  • 然后我们在运行程序碰到异常后便会去走catch部分的内容,通过w.what()输出打印出了【bad allocation】这个问题,意思就是申请失败被错误分配
  • 这一块要讲起来其实需要涉及C++中的异常处理和多态,之后专门出文章做讲解

C/C++内存管理详解_第21张图片

2、 自定义类型

上面主要是做一个铺垫,带读者进一步地了解C++程序如何通过new与内存打交到,接下去呢我们来讲讲更加复杂一些的自定义类型

首先来看看【new】和【delete】的真正执行原理吧,学习了operator newoperator delete之后相信你对这些一定会产生共鸣

  • new的原理
    1. 调用operator new函数申请空间
    2. 在申请的空间上执行构造函数,完成对象的构造
  • delete的原理
    1. 在空间上执行析构函数,完成对象中资源的清理工作
    2. 调用operator delete函数释放对象的空间

下面是具体的原理实现图,对照着看更好一些
C/C++内存管理详解_第22张图片

  • 有了理论基础后,接下去我们就通过代码来进行一个加深理解。可以看到这里是有一个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来做 ,当空间开好之后,我们知道还会去调用构造函数来完成一个初始化的工作,对于内置类型的话不做处理,但是对于自定义类型的话会去调用它的默认构造函数,不过我们这里写了构造函数的话就会去调用我们写过的,将其他两个内置类型也去做一个初始化

C/C++内存管理详解_第23张图片

  • 看完new之后,我们再来讲讲delete,那我么直接通过原理来进行描述,在这里我们首先要去做的就是调用【析构函数】,那有同学问:为何没有像new那样先去释放空间呢,而是先去调用了析构函数?
  • 这一块就要重点讲一讲了,若是我们直接去释放掉这块空间的话,即这个对象在【堆区】中的空间就找不到了,那么这个_array就变成了一个野指针,此时若再去调用【析构函数】的话就会出现大问题,所以说我们要先去调用析构函数释放掉_array所指向的这块堆区中的空间,然后再使用operator delete去释放掉这块空间,这即是[delete]的调用原理

C/C++内存管理详解_第24张图片


看完了【new】和【delete】的调用原理之后,我们再来看看【new T[N]】和【delete[]】的原理

其实原理是差不多的,只不过在上面是对单个对象进行操作,这里是对多个对象进行操作,读者可以试着自己去模拟一下,理解理解,这里便不做过多展开

  • new T[N]的原理
    1. 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请
    2. 在申请的空间上执行N次构造函数
  • delete[]的原理
    1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
    2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间

六、定位new表达式 ※

讲了这么多,本小节我们讲点拓展知识,这一块可能比较难一些,不做要求

1、使用格式与使用场景介绍

【概念】:

  • 定位new表达式是C++中的一种内存分配方式,它允许在特定的内存位置上创建对象。它与常规的new表达式不同,常规的new表达式会自动分配内存,并返回指向新分配内存的指针

【使用格式】:

new (place_address) type
new (place_address) type(initializer-list)

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

【使用场景】:

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

话不多说,概念了解后我们来举个例子说明一下

  • 对于这里的p1现在指向的只不过是与A对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行,那我们若要去调用这个类A的构造函数的话,就可以使用这个【定位new表达式】了,按照上面所给的使用格式来即可
A* p1 = (A*)malloc(sizeof(A));
new(p1)A; 	// 注意:如果A类的构造函数有参数时,此处需要传参
  • 那构造函数都可以显示调用了,【析构函数】我们也自然可以去进行显示调用,然后在free()释放空间即可,严格遵循上面newdelete的调用原理
p1->~A();
free(p1);
  • 同样的,无参的构造会调用了,有参的构造也不在话下,和我们调用普通的构造是一样的,传递参数进去即可
A* p2 = (A*)operator new(sizeof(A));
new(p2)A(10);

p2->~A();
operator delete(p2);

2、池化技术原理分析【高并发内存池雏形】

首先我们要了解一下什么是池化技术,它是一种常见的优化技术,用于提高资源利用效率和性能。其主要分为以下几种

  1. 连接池:是一种管理数据库连接的技术,减少连接建立和关闭的开销,提高数据库访问的性能
  2. 线程池:是一种管理和重用线程的技术,减少线程的创建和销毁开销,提高应用程序的性能和资源利用率
  3. 内存池:是一种管理内存分配的技术,减少内存分配的开销,提高内存分配的效率和性能
  • 本模块呢,我们重点要来讲讲的就是【内存池】这个东西,当然不会很详尽,主要是给读者普及一下相关的知识。我们看到下面有一座大山,山上呢有一座庙宇,庙宇里呢住着一群和尚。。。诶诶诶,可不能接着下去了,山上的这群和尚呢需要用水,但是山顶没有水,只有去山底的话才能找到水,于是它们想要【喝水】和【洗澡】都只能爬到山下才行,然后再顺着原路返回
  • 这样的生活他们受够了,不想再这样继续下去,于是其中一个和尚便想出了一个办法。。。

C/C++内存管理详解_第25张图片

  • 于是呢,它们便想到了这么一个办法,在山下的小溪旁呢造一个风车,通过这个风车去取水,把这个水顺着管道留到半山腰,那之后呢他们就不需要再下山去取水了,只需要去这个管道的地方找水即可,起到了事半而功倍的效果

C/C++内存管理详解_第26张图片

  • 那具体这个风车是如何进行蓄水的呢,呃呃。。差不多就像下面这样吧大家应该看到过

C/C++内存管理详解_第27张图片


好,通过上面这么一个案例,相信你对于古人的智慧一定是非常崇敬,接下去呢我们再来举一个例子

  • 你呢,还在上小学,因为学校离家比较近,所以每天不要爸妈接送,你自己就可以去了,不过呢你需要一天的零花钱,那只能找你爸妈要,每天上学比较早,你爸妈还在卧室睡觉呢,于是不经意间就被你吵醒了,今天要个10块,明天要个15块,每天你爸妈的好梦都给你吵醒了,于是呢他们就想出了一个办法

C/C++内存管理详解_第28张图片

  • 给你买了一个存钱罐,之后每天的前一天晚上,你妈妈都会把后一天的钱放到这个存钱罐里面,你呢在第二天的早上只需要去这个存钱罐里面拿即可,不用再每天打扰他们了,这就方便了许多

C/C++内存管理详解_第29张图片

  • 上面的这个案例不知读者理解了没有,爸爸妈妈呢因为嫌太麻烦,孩子每天早上需要钱的时候都来找它们要,此时呢为了方便起见,便搞了一个存钱罐,相当于是去做了一个代理,孩子无需直接去找它,而是找这个存钱罐要即可

通过上述的这两个案例,我们很好地认识到了通过某个第三方工具去帮助我们完成一些事情,可以做到高效地完成某事,现在就让我们来谈谈【内存池】这个东西

  • 例如现在有这么一个场景,我们需要频繁地去堆中申请内存,而且还是释放内存,之前我们在C语言的动态内存管理中有提到若是频繁地去堆中malloc或者是free的话,就会产生很多的内存碎片,虽然它们所占的空间并不小,但是随着这些碎片的慢慢增多,就会导致内存渐渐拥挤;对于newdelete也是同理,因此我们必须想出一些办法,可以不用那么频繁地去堆中多次地申请空间

C/C++内存管理详解_第30张图片

  • 此时便可以使用到我们本模块所要讲解的【池化技术】,我们可以去构造出一个内存池( Memory Pool ),先去堆中申请到一块很大的内存空间,将它们存放起来,之后我们若是需要空间的时候,无需再到堆中去申请,而是直接到这个内存池里面去拿就可以了,那要怎么拿呢?
  • 此时就可以使用到我们我们前面所使用到的【定位new表达式】,因为我们不可以直接使用new去申请空间,否则的话就是直接到堆上去申请了,而是通过内存池提供给我们的一个单独的接口,我们只需要通过调用这个接口,使用【定位new表达式】申请到里面的空间,然后再调用构造函数进行初始化即可

对于上面的池化技术,就讲这么多,因为用的场景不多,只是有时候C++为了追求极致的性能而所需要使用的一些手段

  • 之后我讲到C++STL模拟实现这一块就会去用到这个技术,从下面的代码来看,这个construct()的内部就是去调用【定位new】,而destroy()就是去显示地调用析构函数,从中涉及一些C++的模版相关知识,我们在下一文就会介绍

C/C++内存管理详解_第31张图片

七、常见面试题

1、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需要捕获异常

从底层来看

申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理

2、内存泄漏

接下去,我们再来讲讲与本文关联性比较大的一些面试题,例如:内存泄漏

① 什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:

  • 内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费

内存泄漏的危害:

  • 长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死

举个例子,就像是下面这样,我们在使用malloc或者new之后忘记去做一个释放了,此时就一定会出现内存泄漏的问题

// 1.内存申请了忘记释放
int* p1 = (int*)malloc(sizeof(int));
int* p2 = new int;

再举个例子,可以看到下面这里确实是在new之后进行了delete,但是呢中间有个Func()函数却出现了异常,便会导致程序不会再执行下去了,那么也就造成了内存的一个泄漏问题

// 2.异常安全问题
int* p3 = new int[10];

Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.

delete[] p3;

② 内存泄漏分类

C/C++程序中一般我们关心两种方面的内存泄漏:

  1. 堆内存泄漏(Heap leak)
    • 堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
  2. 系统资源泄漏
    • 指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
  • 其实对应内存泄漏来说,我们日常中所碰见的大致分为两种,一个是
    • 普通程序,内存泄漏,影响不大,进程正常结束会释放资源
    • 长期运行的程序,内存泄漏危害很大。-- 游戏服务,电商服务…
  • 重点来谈谈后面这种,要知道一个需要长期运行的服务器程序,需要的就是稳定性,若是经常宕机的话,客户的使用体验感就会很差,那如果一直运行的东西,比方说服务器,如果出现内存泄漏的话是一件很危险的是,比较明显一点的问题就是这个泄漏的内存大小测试是完全可以测出来的;但如果是一个每次只是泄漏50M左右这样大小的话,那可能就检测不出来了,慢慢地、慢慢地,你可能会觉得服务器越来越卡,此时事情就会变得越来越严重。。。

C/C++内存管理详解_第32张图片

③ 如何检测内存泄漏

在VS下,可以使用windows操作系统提供的_CrtDumpMemoryLeaks() 函数进行简单检测,该函数只报出了大概泄漏了多少个字节,没有其他更准确的位置信息

int main()
{
	int* p = new int[10];
	// 将该函数放在main函数之后,每次程序退出的时候就会检测是否存在内存泄漏
	_CrtDumpMemoryLeaks();
	return 0;
}

// 程序退出后,在输出窗口中可以检测到泄漏了多少字节,但是没有具体的位置
Detected memory leaks!
Dumping objects ->
{79} normal block at 0x00EC5FB8, 40 bytes long.
Data: <                > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
Object dump complete.
  • 因此写代码时一定要小心,尤其是动态内存操作时,一定要记着释放。但有些情况下总是防不胜防,简单的可以采用上述方式快速定位下。如果工程比较大,内存泄漏位置比较多,不太好查时一般都是借助第三方内存泄漏检测工具处理的。
    • 在linux下内存泄漏检测:linux下几款内存泄漏检测工具
    • 在windows下使用第三方工具:VLD工具说明
    • 其他工具:内存泄漏工具比较

④ 如何避免内存泄漏

  1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
  2. 采用RAII思想或者智能指针来管理资源。
  3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵

八、常见笔试题

  1. C++中关于堆和栈的说法,哪个是错误的:( C )

    A.堆的大小仅受操作系统的限制,栈的大小一般较小
    B.在堆上频繁的调用new/delete容易产生内存碎片,栈没有这个问题
    C.堆和栈都可以静态分配
    D.堆和栈都可以动态分配

【解析】:

A. 堆大小受限于操作系统,而栈空间一般由系统直接分配

B. 频繁的申请空间和释放空间,容易造成内存碎片,甚至内存泄漏,栈区由于是自动管理,不存在此问题

C. 堆无法静态分配,只能动态分配(malloc / new)

D. 栈可以通过函数 _alloca 进行动态分配,不过注意,所分配空间不能通过free或delete进行释放


  1. 使用 char* p = new char[100]申请一段内存,然后使用delete p释放,有什么问题?( B )

    A.会有内存泄露
    B.不会有内存泄露,但不建议用
    C.编译就会报错,必须使用delete []p
    D.编译没问题,运行会直接崩溃

【解析】:

A. 因为delete内部封装了free,所以对于内置类型而言,可以做到精确释放,不会造成内存泄漏

B. 正确。不会造成内存泄漏,应该用delete[]

C. 编译不会报错,建议针对数组释放使用delete[], 如果是自定义类型,不使用方括号就会运行时错误

D. 对于内置类型,程序不会崩溃,但不建议这样使用

总结与提炼

最后,我们来总结一下本文所学习的内容

  • 在一开始,先是介绍了C/C++的内存分布,分别有【栈区】、【堆区】、【共享区】、【静态区】、【代码段】,它们各自有各自的所需要存放的变量,每一块区域都有这它们不同的特点,理解这一块可以为下文的学习打上一个良好的基础
  • 接下去呢,我们开始谈到C语言的动态内存管理方式,其实就是我们在C语言中所介绍的malloccallocreallocfree这些内存函数,也当时做了一个回顾。看完它们之后我们就开始介绍C++中是如何实现动态内存管理,使用到的关键字为new/delete,其不仅可以去操作内置类型,也可以去操作自定义类型,其会去调用构造函数并初始化,调用析构函数清理空间
  • 在学习完new/delete之后,我们便开始拓展学习了两个全局函数,分别是operator newoperator delete,通过汇编的查看发现了new/delete在底层就会去调用二者,透过观察源码,了解到了原来其内部还调用了[malloc][free]这两个内存函数,这似乎增长了我们了我们的知识面
  • 当然上一小节的学习并不是徒然的,当我们在深入理解【new和delete的实现原理】时,起到了很大的帮助,通过案例很快地了解并掌握了它们的底层实现原理是这样的
  • 然后呢,我们又去拓展了一块新知识,叫做【定位new表达式】,在初步介绍了其语法之后,我介绍了一种它使用的场景 —— 池化技术,通过几个丰富的案例作为铺垫,理解了这种池化技术的本质
  • 最后,又聊了聊我们在内存这一块的常见面试题,对于这个我也要重点提一句:大家千万不要去死记硬背,一定在理解的基础上去进行记忆,这样才能达到事半而功倍的效果

以上就是本文要介绍的所有内容,感谢您的阅读

在这里插入图片描述

你可能感兴趣的:(C++,c语言,c++,内存管理)