C/C++内存管理

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

本期我们来学习C/C++内存管理的相关知识

目录

1.内存划分

2.C语言中动态内存管理方式

3.C++内存管理方式

4.operator new与operator delete函数

5.new和delete的实现原理

6. 定位new表达式(placement-new)

7.常见面试题

7.1 malloc/free和new/delete的区别

7.2 内存泄漏

7.2.1内存泄漏的危害

7.2.2内存泄漏分类

7.2.3如何检测内存泄漏 

7.2.4如何避免内存泄漏


1.内存划分

我们的程序中是需要存储数据的而数据的分类有局部数据静态数据全局数据常量数据动态申请数据

全局数据和静态数据的生命周期在全局只是全局哪里都可以用而静态是只在局部用或者当前文件使用

我们平时写程序局部数据使用的最多局部数据通常跟着函数走本质存储在这个函数调用的函数栈帧里

我们平时写的程序是存储在磁盘上的因为它是一个文件然后把程序进行编译转换为汇编代码最后变成可执行程序exe程序程序运行起来的本质是一个进程

包括我们的QQ微信等等

程序运行起来以后我们要对数据的存储区域进行划分

 划分的整体区域叫做进程地址空间全名叫做虚拟进程地址空间最上面是高地址我们熟知的区域有栈堆静态区常量区这是从语言的角度进行划分从操作系统来看常量区叫代码段静态区叫做数据段栈是向下生长的堆是向上生长的

局部数据存储在栈里需要建立栈帧栈帧即用即销毁建立栈帧最本质的目的就是存储局部数据栈存储的一般是一次性的就像我们一次性杯子碗筷一样短期使用而且栈还能帮助我们进行递归调用

静态数据和全局我们也经常使用比如我们之前的一道例题不使用加减乘除和条件语句等等完成从1加到n那里我们就使用的静态数据静态数据就像我们正常吃饭的碗筷一样静态数据不能存储在栈里面栈里面的数据函数结束就销毁了所以我们找了一个新的区域就是静态区

比如这个n函数结束n也不会销毁 

常量数据就存储在常量区里我们动态申请的数据由我们主动控制存储在堆上

说明

1. 又叫堆栈 -- 非静态局部变量 / 函数参数 / 返回值等等栈是向下增长的。
2. 内存映射段 是高效的 I/O 映射方式用于装载一个共享的动态内存库。用户可使用系统接口
创建共享共享内存做进程间通信。后续讲解现在只需要了解即可
3. 用于程序运行时动态内存分配堆是可以上增长的。
4. 数据段 -- 存储全局数据和静态数据。
5. 代码段 -- 可执行的代码 / 只读常量。

下面我们来看一些例题

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);
}
1. 选择题
  选项 : A .   B .   C . 数据段 ( 静态区 )   D . 代码段 ( 常量区 )
  globalVar 在哪里 ____   staticGlobalVar 在哪里 ____
  staticVar 在哪里 ____   localVar 在哪里 ____
  num1 在哪里 ____
  char2 在哪里 ____   * char2 在哪里 ___
  pChar3 在哪里 ____       * pChar3 在哪里 ____
  ptr1 在哪里 ____         * ptr1 在哪里 ____
2. 填空题
  sizeof ( num1 ) = ____ ;  
  sizeof ( char2 ) = ____ ;       strlen ( char2 ) = ____ ;
  sizeof ( pChar3 ) = ____ ;     strlen ( pChar3 ) = ____ ;
  sizeof ( ptr1 ) = ____ ;

大家先试着写一下下面会提供答案

 我们先看选择题的上半部分答案是CCCAA上半部分没什么难度我们来看下半部分char2在栈这里可能会有人选D我们知道后面的字符串是常量但是char2是一个数组它会在Test函数开5个字节的空间然后把abcd\0拷贝过来所以选Achar2是数组名代表整个数组对他解引用就是首元素地址首元素地址在拷贝的abcd\0的a位置处所以还是选A

画成图大概就是这个样子 

pChar3是一个指针所以选ApChar3里存储的是常量字符串的地址对它解引用就到了常量区所以*pChar3选D

ptr1也是指针所以选A但它是我们动态申请的空间指向堆所以解引用选B

 下面我们再看填空题这里就比较简单了答案是40544或者8根据32位或者64位44或者8根据32位或者64位

2.C语言中动态内存管理方式

我们来看一段代码

void Test()
{
	int* p1 = (int*)malloc(sizeof(int));
	free(p1);
	int* p2 = (int*)calloc(4, sizeof(int));
	int* p3 = (int*)realloc(p2, sizeof(int) * 10);
	// 这里需要free(p2)吗
	free(p3);
}

这里需要free(p2)吗答案是不需要因为realloc的扩容分为原地扩容和异地扩容不清楚这三个函数的小伙伴可以看我往期内容

(2条消息) 动态内容管理_KLZUQ的博客-CSDN博客

 最后p3指向这块空间唯一的问题是如果是异地扩容那么p2就会变成野指针安全起见我们可以在realloc后把p2置空

3.C++内存管理方式

我们上面代码里有一个p1这是C的玩法在C++里我们更喜欢使用newnew是一个操作符

使用方法是new后面跟类型即可如果我们要释放空间就使用delete是配套使用的

 我们可以发现C++的玩法简洁一点我们不用去算是多少字节也不需要进行强转

如果我们要申请多个空间

在C++里只需在后边加上方括号即可当然释放的时候也要加上 

另外free和deletenew和malloc不要交叉使用比如new的空间用free释放我们要严格配套使用否则在某些情况下是会出现问题的

C++在申请空间时还可以初始化

注意这里是圆括号和方括号是不一样的

C++在用法上和C语言有区别但是其他地方并没有区别比如都不会去主动初始化

 另外数组的初始化是使用大括号而不是小括号我们这里只给了一部分值剩下的就初始化为0这和C语言是一样的

那为什么要有new呢只是为了方便吗

当然不是

我们在C语言时写一个列表非常麻烦创建新节点还需要一个Buy函数

而我们有了构造函数后使用new就非常方便 

 不仅开空间还能调用构造函数进行初始化

对于内置类型new和malloc除了用法上面其他没什么区别还有动态申请时可以加圆括号初始化

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

我们这里有一个类A

我们new一个A然后delete它会发现构造函数和析构函数被调用

对于自定义类型new/delete 和 malloc/free最大区别是 new/delete对于【自定义类型】除了开空间
还会调用构造函数和析构函数
所以我们以后使用new更好从此之后百分之99的情况我们都会使用new而不是malloc

如果创建多个对象也会多次调用

而且因为有默认构造函数p6还被初始化了

当然如果没有默认构造会报错

不过我们可以通过大括号来初始化10个太多了我修改为4 这里是隐式类型转换

可以这样写给匿名对象

这里还会被直接优化直接构造而不是构造加拷贝构造

如果有默认构造我们这里可以只写3个非常灵活 

我们前面说mallocfreenewdelete不要交叉使用我们来看看交叉使用后会发生什么

这里是没有事的

不过我们把鼠标指到上面会有这样的提示

 

我们再看这个就直接崩了

所以说最好不要交叉使用并且在不同的编译器下结果可能不同否则会出现很多问题

4.operator newoperator delete函数

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

 看到operator大家可能认为这是重载但其实不是直接的运算符重载而是一个全局的函数是库里面的函数

/*
operator new该函数实际通过malloc来申请空间当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来释放空间的
*/
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;
}
/*
free的实现
*/
#define   free(p)               _free_dbg(p, _NORMAL_BLOCK)

这是底层的实现我们发现new其实是使用了malloc的delete还有free都调用了_free_dbg

我们是可以直接使用这两个函数的

 大家仔细看的话会发现operator new 和operator delete的效果是和malloc和free是一样的

 但它的价值并不是让我们直接使用

5.new和delete的实现原理

new和malloc的区别是new会调用构造函数他们都可以开空间那new是如何开空间的

C语言有一个现成的malloc所以C++设计时就直接使用了malloc

不过malloc失败时是返回空而面向对象的语言处理失败时不希望返回更希望用抛异常抛出异常是需要捕获的

我们来演示一下失败的情况

我们用这段代码来看看我们能申请多少空间

 大概可以申请接近2G左右我们这里是32位的程序总共4G最多申请2G我们现在申请的是虚拟内存这些知识我们后续会讲

new失败了希望抛异常

 异常是需要被捕获的

使用try-catch来进行捕获以后会讲解我们看到最后出现了bad allocation 

 就是申请内存失败这里申请内存同样最多申请2G

new在开空间时并不是直接malloc而是使用operator newoperator new就是对malloc进行封装出现意外就抛异常

delete是先调用析构函数然后释放空间释放空间就是调用operator delete然后调用free_dbg

我们可以看到new的中间做了很多操作有两个操作是非常重要的一个是call了operator new另一个是call了A的构造函数

 再看delete这里call了一个A:: scalar deleting 什么什么的函数这是对析构和operator delete进行了封装

 我们再往下就就可以看到调用了析构和operator delete

所以operator new和operator delete并不是给我们直接用的而是给new和delete用的

总结

如果申请的是内置类型的空间 new malloc delete free 基本类似不同的地方是
new/delete 申请和释放的是单个元素的空间 new[] delete[] 申请的是连续空间而且 new 在申请空间失败时会抛异常malloc 会返回 NULL
new 的原理
1. 调用 operator new 函数申请空间
2. 在申请的空间上执行构造函数完成对象的构造
delete 的原理
1. 在空间上执行析构函数完成对象中资源的清理工作
2. 调用 operator 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 来释
放空间

下面来带大家感受一下这个顺序

typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		cout << "Stack(size_t capacity = 10)" << endl;
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		_size = 0;
		_capacity = capacity;
	}
	void Push(const DataType& data)
	{
		_array[_size] = data;
		_size++;
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};

 我们这里有一个栈构造函数和析构函数会进行打印

我们如果想在堆上申请一个栈对象

我们这两行代码是非常强大的

 p1是指针在栈里new会调用operator newoperator new会在堆上开12字节的空间3个成员变量32位平台下的话然后会调用构造函数会给栈开一个空间_array会指向这个空间

析构函数清理的是_array指向的数组然后会调用operator delete清理堆上3个成员变量占的12字节的空间所以delete是先调用析构再调用operator delete如果顺序反过来的话_array都没了那它指向的空间就找不到了

而且new失败是抛异常所以以后我们就不需要进行检查而是在外部try-catch

6. 定位new表达式(placement-new)

定位 new 表达式是在 已分配的原始内存空间中调用构造函数初始化一个对象
使用格式
new (place_address) type 或者 new (place_address) type(initializer-list)
place_address 必须是一个指针 initializer-list 是类型的初始化列表
使用场景
定位 new 表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化所以如果是自定义类型的对象需要使用new 的定义表达式进行显示调构造函数进行初始化。

 简单来说它的作用就是对一块已经有的空间调用构造函数

class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};
int main() {
	// p1现在指向的只不过是与A对象相同大小的一段空间还不能算是一个对象因为构造函数没有执行
	A* p1 = (A*)malloc(sizeof(A));
	new(p1)A;  // 注意如果A类的构造函数有参数时此处需要传参
	p1->~A();
	free(p1);
}

这里的new(p1)A就是显示调用构造函数这样模拟的就是new的功能

有需要的话我们还能给参数 

我们屏蔽掉显示调用析构函数发现它并不会主动调用析构所以是需要我们手动调用的 

因为自定义类型才会自动调用构造和析构但p1是指针任何类型的指针都是内置类型所以是不会自动调用的

计算机行业有一个池化技术用池子把东西装起来提高效率比如我们住在山上山的下面有一条河我们要喝水的话需要下山但是我们需要水时才去取水需要上山下山非常浪费时间如果我们在山上建一个水池或者有水缸我们把水放到池子里我们需要水时就可以直接去池子里取水而不是浪费大量时间在上下山所以以后我们会建立很多池比如内存池线程池连接池等等

堆就相当于河我们需要频繁的申请释放内存使用new就是直接去找堆所以我们可以建一个池子提前申请一大块内存然后需要内存去池子取就可以了这个池子里只有内存给我们提供内存的话是需要一个函数的比如get函数如果我们创建一个节点就可以Node* n1 = get(sizeof(Node)); 这里是没有调用构造函数初始化的所以我们就需要使用定位newnew(n1)Node(2); 

7.常见面试题

7.1 malloc/freenew/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 在释放空间前会调用析构函数完成空间中资源的清理

 这些东西是不推荐大家背的最好记忆+理解一定要去理解

比如上面这些前面5个是特性和用法第6个是底层为了弥补malloc和free才出现了new和delete

7.2 内存泄漏

7.2.1内存泄漏的危害

什么是内存泄漏内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失而是应用程序分配某段内存后因为设计错误失去了对该段内存的控制因而造成了内存的浪费。
内存泄漏的危害长期运行的程序出现内存泄漏影响很大如操作系统、后台服务等等出现内存泄漏会导致响应越来越慢最终卡死。

我们来看这段代码它会疯狂的打印屯屯屯屯屯

我们看进程这里确实申请了1024MB是1GB

有些电脑上可能会打印烫烫烫这些是乱码我们后续在编码表会讲解这里的原因本质是cout不能打印指针int*识别为指针char*会识别为字符串字符串遇到\0截至又没有初始化是随机值如果我们初始化的话就什么也不会打印

或者下面这样更清晰一点 

我们可以看出是按字符串打印那有什么办法可以让char*强制按指针打印吗

 

我们可以这样做

上面这些代码我们申请了1G的内存但是都没有释放不过这里大家应该都知道我们程序结束后它会自动释放就是害怕我们没有释放内存

对于普通程序内存泄漏影响不大进程正常结束会释放资源而长期运行的内存内存泄漏危险很大如游戏服务电商服务服务器都是7*24小时的只有在升级的时候比如游戏更新时才会暂停服务器

如果我们写了内存泄漏一次掉几个G这种其实影响不大我们可以测试出来最怕的是一次泄漏很少几MB几十MB这种服务器会越来越卡最后挂掉

7.2.2内存泄漏分类

C/C++ 程序中一般我们关心两种方面的内存泄漏
堆内存泄漏 (Heap leak)
堆内存指的是程序执行中依据须要分配通过 malloc / calloc / realloc / new 等从堆中分配的一
块内存用完后必须通过调用相应的 free 或者 delete 删掉。假设程序的设计错误导致这部分
内存没有被释放那么以后这部分空间将无法再被使用就会产生 Heap Leak
系统资源泄漏
指程序使用系统分配的资源比方套接字、文件描述符、管道等没有使用对应的函数释放
掉导致系统资源的浪费严重可导致系统效能减少系统执行不稳定。

7.2.3如何检测内存泄漏 

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.
因此写代码时一定要小心尤其是动态内存操作时一定要记着释放。但有些情况下总是防不胜
防简单的可以采用上述方式快速定位下。如果工程比较大内存泄漏位置比较多不太好查时
一般都是借助第三方内存泄漏检测工具处理的。

7.2.4如何避免内存泄漏

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

以上即为本期全部内容希望大家可以有所收获

如有错误还请指正

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++