【C++修炼之路】内存管理

作者主页:@安 度 因
学习社区:StackFrame
专栏链接:C++修炼之路

文章目录

  • 一、C/C++ 内存分布
  • 二、考题
  • 三、C语言动态内存管理方式
  • 四、C++内存管理方式
    • 1、对内置类型
    • 2、对自定义类型
  • 五、C++对动态管理的升级
  • 六、operator new/operator delete函数
  • 七、new/delete 的实现原理
    • 1、内置类型
    • 2、自定义类型
  • 八、定位new表达式(placement-new)
  • 九、内存泄漏
    • 1、内存泄漏分类
    • 2、如何检测内存泄漏
    • 3、如何避免内存泄漏
    • 4、补充

如果无聊的话,就来逛逛 我的博客栈 吧!

一、C/C++ 内存分布

划分是为了更加高效的管理

【C++修炼之路】内存管理_第1张图片

说明

  1. 栈又叫堆栈,函数调用建立的栈帧,非静态局部变量、函数参数、返回值等,栈是向下增长的
  2. 内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享内存,做进程间通信。(Linux课程如果没学到这块,现在只需要了解一下)
  3. 堆用于程序运行时动态内存分配,堆是可以上增长的;程序运行过程中按需求申请和释放空间,比如我们实现的数据结果都是在堆开空间
  4. 数据段(静态区)–存储全局数据和静态数据
  5. 代码段(常量区)–可执行的代码/只读常量(常量字符串);代码段是从操作系统/程序角度说的,常量区是从语法角度命名说的

补充

  1. 栈不大,Linux 32位下 8M;静态区和常量区不大,堆很大,32位下约 2G
  2. 函数编译完成后为指令,都在代码段
  3. 对于栈、数据、代码段,是自动控制的;堆是手动控制的
  4. 它们属于进程虚拟地址空间
  5. C++ 继承了这些内存分布

二、考题

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"; // char2 在栈区;*char2 ;*char2 是栈上的字符串
	const char* pChar3 = "abcd"; // pChar3 是栈上的指针;*pChar3 是常量区的常量字符串
	int* ptr1 = (int*)malloc(sizeof(int) * 4); // ptr1 是栈上的指针,*ptr1 就是指针指向的堆上的空间
	int* ptr2 = (int*)calloc(4, sizeof(int));
	int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
	free(ptr1);
	free(ptr3);
}

1)选择题

选项: A.栈 B.堆 C.数据段(静态区) D.代码段(常量区)

  1. globalVar在哪里?(C) 2. staticGlobalVar在哪里?(C)

  2. staticVar在哪里?(C) 4. localVar在哪里?(A)

  3. num1 在哪里?(A)

  4. char2在哪里?(A) 7. *char2在哪里?(A)

  5. pChar3在哪里?(A) 9. *pChar3在哪里?(D)

  6. ptr1在哪里?(A) 11. *ptr1在哪里?(B)

2)填空题

  1. sizeof(num1) = (40) 2. sizeof(char2) = (5)

  2. strlen(char2) = (4) 4. sizeof(pChar3) = (4)

  3. strlen(pChar3) = (4) 6. sizeof(ptr1) = (4)

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

malloc / calloc / realloc / free 都是库函数:

void Test ()
{
    int* p1 = (int*) malloc(sizeof(int));
    free(p1);
    // 1.malloc/calloc/realloc的区别是什么?
    int* p2 = (int*)calloc(4, sizeof (int));
    int* p3 = (int*)realloc(p2, sizeof(int)*10);
    // 这里需要free(p2)吗?
    free(p3);
}

calloc 和 malloc 区别:

calloc 会按字节初始化,空间每个字节初始化为 0,相当于 malloc + memset 。calloc 开辟的空间也需要释放。

这里不需要 free(p2):

因为 realloc 对 p2 指向的空间进行了扩容,此刻无论是空间充足还是不足并扩容成功的情况下,之后对于申请内存的释放只要针对 p3 即可。

如果你能正确回答以上两个问题,请阅读:深度剖析动态内存管理

四、C++内存管理方式

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

new 和 delete 是操作符,不是函数。

1、对内置类型

int main()
{
	int* p1 = new int; // 动态申请 1 个 int 类型的空间
	int* p2 = new int[5]; // 申请 5 个 int 类型的空间

	delete p1; // 释放 p1
	delete[] p2; // 释放 p2
    p1 = nullptr;
    p2 = nullptr;

	return 0;
}

显而易见比C语言的更加简洁,而 new 申请的空间,和 malloc 一样的:

【C++修炼之路】内存管理_第2张图片

总结:malloc/free 和 new/delete 对于内置类型没有本质区别,只有用法上的区别;而 delete 完的指针最好置空,更加安全,不置也没关系,但是要注意不能使用,因为此刻为野指针。

区分:

int* p1 = new int(5); // 申请一个 int 的空间,空间初始化为 5
int* p2 = new int[5]; // 申请 5 个 int 类型的空间

C++98不支持初始化 new 的数组;C++11 可以通过如下方式进行初始化:

int main()
{
	int* p3 = new int[5] {1, 2};
}

申请五个 int ,按照 {} 中顺序依次对空间进行初始化,其他的初始化为 0 :

【C++修炼之路】内存管理_第3张图片

2、对自定义类型

对于自定义类型来说,malloc 不会调用构造函数,free 不会调用析构函数初始化:

【C++修炼之路】内存管理_第4张图片

而 new 会调用构造函数,对于数组来说,则会调用数组元素个数的次;同理对于 delete 会调用析构函数,也会析构数组元素个数的次:

【C++修炼之路】内存管理_第5张图片

(delete[] ,这个 [] 是为了告诉编译器它是一个数组,数组有多少个元素,就要调用对应次数的析构函数,之前 [] 会把对象个数存起来,方便之后调用)

总结

  • new 在堆上申请对象空间(指针指向的) + 调用构造函数初始化对象

  • delete 先调用指针类型的析构函数(清理资源) + delete 释放空间申请的空间给堆

若没有默认构造会报错:

【C++修炼之路】内存管理_第6张图片

但是可以解决报错:

【C++修炼之路】内存管理_第7张图片

一定要 malloc / free 和 new / delete 和 new[] / delete[] 匹配使用,否则可能会造成死循环或程序奔溃等情况,总之后果自负

例如 new 和 delete[] 造成死循环 :

【C++修炼之路】内存管理_第8张图片

new[] 和 delete 报错:

之前说过 new 和 delete 开辟多个空间的方式是把对象个数存起来,以此知道大小。对于 vs ,这块空间会在头部有一块空间,存储个数,这时指针指向的位置是个数的下一个位置:

【C++修炼之路】内存管理_第9张图片

delete[] 就会往前减去四个字节,取到空间里的值,然后根据值来决定调用多少次析构函数,然后从存放数值的空间的地址开始释放空间。

new[] 和 delete 崩溃的原因:释放空间的指针位置不对。

但是如果把析构函数屏蔽,就不会崩溃:

因为对于自己的析构函数,调用与否也无所谓,这时,不会在头部开这一块空间。

对于初始化,也会进行隐式类型转换:

A* p = new A[4]{1, 2, 3, 4}; // 将 1 进行隐式类型转换为 A 对象
A* p = new A[4]{ A(1), A(2), A(3), A(4) }; // 匿名对象进行调用也可以
// 多参构造函数
A* p = new A[4]{ A(1, 2), A(2, 2), A(3, 2), A(4, 2) }; // 会优化,优化为一次构造
A* p = new A[4]{ {1, 2}, {1, 2}, {1, 2}, {1, 2} }; // 隐式类型转换能成功

五、C++对动态管理的升级

两个方面:

第一个升级的地方自定义类型对象自动申请的时候,初始化和销毁清理的问题,new/delete会调用构造函数和析构函数。

第二个升级则是new失败了以后要求抛异常,这样才符合面向对象语言的出错处理机制。

了解面向过程和对象语言处理错误的方式:

  • 面向对象的语言,处理错误的方式一般是抛异常,C++中也要求错误抛异常 – try catch
  • 面向过程的语言,处理错误的方式是返回错误码 – perror

比如 C 语言在动态内存开辟时,堆空间是有限的,很有可能申请失败:

int main()
{
	char* p1 = (char*)malloc(1024u*1024u*1024u*2u); // u 表示为无符号正数
	if (p1 == nullptr)
	{
		printf("%d\n", errno); // 错误码
		perror("malloc fail"); // perror 报错
		exit(-1);
	}
	else
	{
		printf("%p\n", p1);
	}
}

【C++修炼之路】内存管理_第10张图片

对于 C++ 来说。则是抛出异常,这时再使用上次的检查就无效了,甚至还会奔溃:

int main()
{
	char* p1 = new char[1024u * 1024u * 1024u * 2u - 1]; // new 的大小不能超过 7fff ffff,所以要 -1 
	if (p1 == nullptr)
	{
		printf("%d\n", errno);
		perror("malloc fail");
		exit(-1);
	}
	else
	{
		printf("%p\n", p1);
	}
}

【C++修炼之路】内存管理_第11张图片

用 C++ 的方法就是 抛异常 ,但是异常其实很难,所以简单了解一下(之后会讲):

【C++修炼之路】内存管理_第12张图片

bad allocation 就是坏的申请,就是申请失败。

正常申请:

【C++修炼之路】内存管理_第13张图片

若不抛异常,则走完 try ,不走 catch ;否则会直接走 catch ;对于异常的捕获只会捕获在 try 内的。

抛异常可以跳过函数:

【C++修炼之路】内存管理_第14张图片

当在函数中捕获到异常后,函数之后的语句不再执行,直接跳转到 catch 处执行。

这样就不必在可能出错的下面检查,只要在 main 函数中捕获即可。不捕获异常就会弹出未经处理的异常的错误。

抛异常解决的是抛出来的错误,对于一些严重错误:内存错误、断言错误等,会终止程序,不会走异常。

ps:delete/free一般不会失败,如果失败了,都是释放空间存在越界或者释放指针的位置不对

六、operator new/operator delete函数

new 是要先申请空间,再调用构造函数;当一个对象被 new 时,是怎么做到开辟空间的?难道是舍弃了之前的 malloc 开辟空间的方式,另辟蹊径来开空间?我们试着探究。

当调试起来后,看到反汇编,一共调了两个函数 operator new 和 它的构造函数,可 operator new 到底是什么?

【C++修炼之路】内存管理_第15张图片

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

使用方法,例如开一个栈的空间:

image-20230208092258391

operator new 是不会调用构造函数的(如果会调用,就没有 new 什么事了),里面的仍然是随机值。

而 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 :

/*
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)

总结:

  • operator new 和 operator delete 就是对 malloc 和 free 的封装
  • operator new 和 operator delete 调用 malloc 申请内存,失败后,改为抛异常处理错误,这样才符合 C++ 面向对象语言处理错误的方式

如果没有 operator new ,那么 new Stack 之后,就会 call malloc + call Stack构造,而 malloc 不符合 C++ 处理错误方式(失败返回 0 ,而 new 失败也是返回 0)。

operator new 是给 new 的,我们直接使用 new 即可。

补充

operator new 会抛异常,并且调用 malloc 函数,如果 malloc 失败,则会抛异常;对于 delete 底层,也进行过了封装,先调用析构函数,再用 operator delete 进行释放。

例:

【C++修炼之路】内存管理_第16张图片

【C++修炼之路】内存管理_第17张图片

七、new/delete 的实现原理

1、内置类型

如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。

2、自定义类型

  • new的原理
    1. 调用operator new函数申请空间
    2. 在申请的空间上执行构造函数,完成对象的构造
  • delete的原理
    1. 在空间上执行析构函数,完成对象中资源的清理工作
    2. 调用operator delete函数释放对象的空间
  • new T[N]的原理
    1. 调用operator new[]函数(实际上是 operator new 的封装),在operator new[]中实际调用operator new函数完成N个对象空间的申请,类似于 Stack p1 = new Stack[10] ---> Stack* pst1 = (Stack*)operator new[](sizeof(Stack) * 10)
    2. 在申请的空间上执行N次构造函数
  • delete[]的原理
    1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
    2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间

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

能否调用构造函数?

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

int main()
{
	A* p = (A*)malloc(sizeof(A));

	return 0;
}

不能。

在之前构造函数不支持显示调用;而现在则可以使用 定位 new 表达式 来调用构造函数。

定位new表达式是在 已分配的原始内存空间中调用构造函数初始化一个对象 ,例如 malloc 开辟空间后初始化对象。

使用格式:

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

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

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

int main()
{
	A* p = (A*)malloc(sizeof(A));
	new(p)A; // 不带参初始化,显示调用构造函数
	new(p)A(1); // 带参初始化

	return 0;
}

【C++修炼之路】内存管理_第18张图片

模拟 new 行为:

int main()
{
	A* p2 = new A(2); // operator new + 构造函数
	// 等价于 
	A* p3 = (A*)operator new(sizeof(A)); // operator new
	new(p3)A(3); // 调用构造函数

	return 0;
}

析构函数可以显示调用:

int main()
{
	A* p3 = (A*)operator new(sizeof(A)); // operator new
	new(p3)A(2); // 调用构造函数

	p3->~A(); // 调用析构函数
	operator delete(p3); // 释放内存

	return 0;
}

使用场景

定义 new 表达式在平常作用不大,它的真正应用场景是对内存池(池化技术–内存池、线程池、连接池):

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

内存池解读:

【C++修炼之路】内存管理_第19张图片

平常都是在堆上拿,现在在内存池拿,如果内存池有,则直接拿(比较快),没有内存池就去堆上拿,拿一大块,慢慢用。

定位 new 的应用:链表中创建节点时,使用内存池来进行,下面两步为显示调用构造函数和显示调用析构函数

【C++修炼之路】内存管理_第20张图片

九、内存泄漏

动态申请的内存,不使用了,没有释放,就可以说成是内存泄漏。

好玩的:

image-20230208105835021

内存泄漏不是一定有危害,就比如:

int main()
{
	char* p = new char[1024u * 1024u * 1024u];
	printf("%p\n", p);

	return 0;
}

每次耗费一个 g 。虽然没有释放,但是当程序执行结束后,内存会被释放,还给OS.

而内存泄漏无非几种现象:

  1. 出现内存泄漏的进程正常结束,进程结束时内存会还给OS,不会有什么损害
  2. 出现内存泄漏的进程非正常结束,比如僵尸进程
  3. 需要长期运行的程序,出现内存泄漏,危害很大,OS会越来越慢,甚至卡死宕机,例如服务器程序:王者匹配机制、美团骑手匹配

2、3 两点会造成很大的危害。尤其是第三点,每天一点点,服务器崩坏一点点,不容易发现。

内存泄漏有两个典型现象:

void MemoryLeaks()
{
	// 1.内存申请了忘记释放
	int* p1 = (int*)malloc(sizeof(int));
	int* p2 = new int;
	// 2.异常安全问题
	int* p3 = new int[10];
	Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.
	delete[] p3;
}

若 Func 有异常,则直接跳转到 catch ,这时 delete[] p3 没有执行,就内存泄漏了。

java 也可能会有内存泄漏,虽然有回收机制,但是 java 后台虚拟机会有一定代价,但是比 C++ 更优。

1、内存泄漏分类

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

  • 堆内存泄漏(Heap leak)

    • 堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak 。
  • 系统资源泄漏

    • 指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。

2、如何检测内存泄漏

在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.

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

3、如何避免内存泄漏

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

4、补充

对于 32 位,申请 2g 的空间是失败的,因为对于 32 位的堆空间一共就 2g ,一下子申请显而不太可能,但是将程序改为 64 位就可以,因为此刻的堆空间就变大了很多:

【C++修炼之路】内存管理_第21张图片

image-20230208111959538

你可能感兴趣的:(C++修炼之路,c++,开发语言)