【c/c++】深入探秘:C++内存管理的机制

C/C++
93
0
0
2024-08-20
目录
  • 1.C/C++内存分布
  • 2.C语言中动态内存管理方式:malloc/calloc/realloc/free
  • 3.c++内存管理方式
  • 3.1new/delete对内置类型的操作
  • 3.1.1抛异常
  • 3.2new/delete对自定义类型的操作
  • 4.operator new与operator delete函数
  • 5.new和delete的实现原理
  • 6.简单了解定位new表达式(placement-new)
  • 7.概念辨析
  • 7.1 malloc/free和new/delete的区别
  • 7.2 内存泄漏

1.C/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";
  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.代码段(常量区)
   globalVar在哪里?__1__   staticGlobalVar在哪里?__2__
   staticVar在哪里?__3__   localVar在哪里?__4__
   num1 在哪里?__5__
   
   char2在哪里?__6__   *char2在哪里?_7__
   pChar3在哪里?__8__      *pChar3在哪里?__9__
   ptr1在哪里?_10___        *ptr1在哪里?__11__

我们来依次讨论:

  1. globalVar 是全局变量,不是静态的,所以它存储在数据段(静态区)
  2. staticGlobalVar 也是全局变量,但它是静态的,因此它同样存储在数据段(静态区)
  3. staticVar 是函数内的静态变量,所以它存储在数据段(静态区),因为它的生命周期贯穿程序的整个执行期
  4. localVar 是局部变量,存储在栈上
  5. num1 是局部变量,它是数组,存储在栈上
  6. char2 是局部变量,它是数组首元素的地址,存储在栈上
  7. *char2(即char2数组的内容)存储在栈上,因为char2本身就在栈上
  8. pChar3 是局部指针变量,存储在
  9. *pChar3 指向的内容(即字符串"abcd")存储在代码段(常量区)
  10. ptr1 是局部指针变量,存储在
  11. *ptr1 指向的内容(即通过malloc分配的内存)存储在
  • *char2(局部字符数组) 当你声明一个局部字符数组并用一个字符串字面量初始化它,如char char2[] = "abcd";时,编译器在栈上为数组分配内存,然后将字符串字面量的内容(包括结尾的\0字符)复制到这块内存中。因此,char2和它的内容(*char2指向的内容)都存储在栈上
  • *pChar3(字符串字面量指针) 另一方面,当你使用指针指向一个字符串字面量,如const char* pChar3 = "abcd";时,这个字符串字面量存储在程序的只读数据段(或称为代码段、常量区)中。pChar3本身作为一个局部指针变量存储在栈上,但它指向的字符串(“abcd”)实际上存储在常量区。这是因为字符串字面量被视为常量数据,编译器会将它们放在程序的常量区域内,这个区域通常是只读的,以防止程序意外修改它的内容。因此,尽管pChar3是一个指针,存储在栈上,但它指向的字符串内容存储在常量区

总结

  • *char2不在常量区,因为char2是局部字符数组,其内容直接存储在栈上。
  • *pChar3在常量区,因为它指向的是一个字符串字面量,字符串字面量被存储在程序的常量区域,这部分内存是只读的。

当我们讨论变量存储在哪里时,通常涉及到几个关键区域:栈(Stack)、堆(Heap)、数据段(Data Segment,又称静态区)、和代码段(Code Segment,又称常量区)。每种类型的变量根据其特性和声明周期被存储在这些区域中的相应位置

  • 是用于存储局部变量、函数参数等的内存区域。当一个函数被调用时,其局部变量和一些书keeping信息被推入栈中;当函数执行完成,这些信息被从栈上弹出。栈是自动管理的,开发者无需手动分配或释放内存。
  • 是用于动态内存分配的内存区域。不同于栈,开发者需要显式地从堆上分配内存(如使用mallocnew),并在不再需要时释放这些内存(如使用freedelete)。
  • 数据段,又称为静态区,用于存储全局变量、静态变量等。这些变量的生命周期贯穿整个程序执行期,因此它们被存储在一个特定的、持久的内存区域中。
  • 代码段,又称为常量区,用于存储程序的执行代码和常量数据,如字符串字面量。这部分内存是只读的,用来保证程序代码的安全性

2.C语言中动态内存管理方式:malloc/calloc/realloc/free

在C语言中,动态内存管理是通过一组标准库函数完成的,包括malloc, calloc, realloc, 和 free。这些函数允许程序在运行时动态地分配、调整和释放堆内存,这是对于管理变化的数据量和大小特别有用的能力。下面是这些函数的基本用法和它们之间的区别:

malloc

  • 用法void* malloc(size_t size);
  • 功能:分配指定字节数的未初始化内存。它返回一个指向分配的内存的指针。如果分配失败,返回NULL
  • 示例int* ptr = (int*)malloc(sizeof(int) * 4); 这行代码为4个整数分配了内存

calloc

  • 用法void* calloc(size_t num, size_t size);
  • 功能:为指定数量的元素分配内存,每个元素的大小也在参数中指定,并自动初始化所有位为0。如果分配失败,返回NULL
  • 示例int* ptr = (int*)calloc(4, sizeof(int)); 这行代码为4个整数分配了内存,并将它们初始化为0。

realloc

  • 用法void* realloc(void* ptr, size_t size);
  • 功能调整之前调用malloccalloc分配的内存块的大小。如果新的大小大于原始大小,可能会移动内存块到新的位置以提供足够的连续空间。如果realloc的第一个参数是NULL,它的行为就像malloc
  • 示例ptr = (int*)realloc(ptr, sizeof(int) * 8); 这行代码将之前分配的内存大小调整为8个整数的大小。

free

  • 用法void free(void* ptr);
  • 功能:释放之前通过malloc, calloc, 或 realloc分配的内存。一旦内存被释放,那块内存就不能再被访问了。
  • 注意:尝试释放未经分配的内存块或多次释放同一个内存块是不安全的,可能导致未定义行为

注意

  • 在使用这些函数时,确保正确处理内存分配失败的情况,并在内存不再需要时使用free来避免内存泄露。
  • 当使用realloc时,如果分配失败,原始内存不会被释放。因此,建议先将realloc的返回值赋给一个临时指针,以检查是否分配成功,再重新赋值给原始指针,以避免内存泄漏。
  • 始终确保只对通过malloc, calloc, 或 realloc分配的指针使用free,并且每个分配的内存块只被free一次

3.c++内存管理方式

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

3.1new/delete对内置类型的操作

new的基本用法

Type* variable = new Type(arguments);
  • Type:要分配的对象类型
  • variable:指向分配的内存的指针
  • arguments:传递给构造函数的参数(如果需要的话)

示例

int* ptr1 = new int; 

在堆上分配了一个int大小的内存

int* ptr2 = new int[10]; 

加上方括号[ ]表示分配了十个int大小的内存

释放:

对于ptr,我们直接delete

delete ptr1;

释放数组对象的内存ptr2,我们需要加上方括号:

delete [] ptr2;

我们也可以分配内存的同时直接初始化:

int* ptr5 = new int(5);

动态申请一个int类型的空间并初始化为5

在这里插入图片描述

我们也可以同时开辟多个空间完成初始化:

int* ptr6 = new int[10] {1,2,3,4,5};

在这里插入图片描述

后面的空间默认初始化为零

  • 尽管newdelete提供了对象构造和析构的自动管理,但程序员仍然需要负责确保每个用new分配的内存都被对应的delete释放,以避免内存泄露
  • mallocfree一样,试图delete一个未经new分配的指针,或者对同一个指针执行多次delete,都是未定义行为,并且可能导致程序崩溃
  • 当使用new[]分配数组时,必须使用对应的delete[]来释放内存。使用错误的delete形式也是未定义行为

来看下面的代码:

struct ListNode
{
	ListNode* _next;
	int _val;

	ListNode(int val)
		:_next(nullptr)
		,_val(val)
	{}
};

struct ListNode* CreateListNode(int val)
{
	struct ListNode* newnode = (struct ListNode*)malloc(sizeof(struct ListNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		return NULL;
	}

	newnode->_next = NULL;
	newnode->_val = val;
	return newnode;
}

这是c语言构造一个节点并完成初始化的过程,我们来看c++的实现:

int main()
{
	ListNode* node1 = new ListNode(1);
	return 0;
}

这行代码自动为ListNode对象分配了内存,并调用了其构造函数进行初始化。这种方式更简洁,也更安全,因为它保证了对象在使用前被正确初始化,注意这里ListNode是自定义类型,除了开空间还会调用构造函数

在这里插入图片描述

在这里插入图片描述

只要我们写好构造函数,我们发现new的使用是十分方便的

我们来构建一个链表:

ListNode* CreateList(int n)
{
	ListNode head(-1);  // 哨兵位
	ListNode* tail = &head;
	int val;
	printf("请依次输入%d个节点的值:>", n);
	for (size_t i = 0; i < n; i++)
	{
		cin >> val;
		tail->_next = new ListNode(val);
		tail = tail->_next;
	}
	return head._next;
}

我们输入五个值,1 2 3 4 5

在这里插入图片描述

哨兵节点:ListNode head(-1);这行代码创建了一个局部的哨兵节点,它的值被设为-1(这个值通常是任意的,因为哨兵节点本身不存储任何有意义的数据)。哨兵节点的主要目的是简化在链表头部的插入和删除操作,因为你总是有一个非空的节点作为链表的起始点,从而避免了处理空链表的特殊情况

最后,函数通过return head._next;返回新构建链表的头节点。由于head是一个哨兵节点,它的_next成员实际上指向链表的第一个真实节点(如果有的话)或者是nullptr(如果n为0或用户没有输入任何有效数据)

3.1.1抛异常

我们不用手动检查new是否开辟成功,new失败了会抛出异常

void func()
{
	int n = 1;
	while (1)
	{
		int* p = new int[1024 * 1024*100];
		cout <<n<<"->"<< p << endl;
		++n;
	}
}

我们一次申请400M的空间大小

在这里插入图片描述

再看c语言版本

void func()
{
	int n = 1;
	while (1)
	{
		//int* p = new int[1024 * 1024 * 100];
		int* p = (int*)malloc(1024 * 1024 * 400);
		cout << n << "->" << p << endl;
		++n;
	}
}

在这里插入图片描述

开辟失败,程序无限循环并返回空

c++中的抛异常:

try
	{
		func();
	}
	catch (const exception& e)
	{
		cout << e.what() << endl;
	}

这段代码是C++中的一个示例,展示了如何使用try-catch语句来处理异常。这里的重点是捕获并处理func()函数中可能抛出的异常。如果func()函数执行中出现了问题,它将抛出一个异常,这个异常会被catch块捕获。捕获到的异常类型为const std::exception&,这是C++标准异常类型的一个基类。在catch块中,通过e.what()调用来获取并打印出异常的具体信息

  • try块:在try块中的代码执行时,如果发生了异常(即代码抛出了异常),那么try块中的剩余代码将不会继续执行,而是跳转到相应的catch块中处理异常
  • catch块:此代码段用于捕获类型为const std::exception&的异常。这意味着它能够捕获任何是std::exception实例或其派生类的异常。通过常量引用捕获异常是一种最佳实践,因为这样可以避免异常对象的切片问题,并且可以最小化性能开销
  • const exception& e:这里声明了一个名为e的引用,它引用了被捕获的异常。const限定符表明在catch块中,e是不会被修改的
  • e.what()std::exception及其派生类有一个名为what()的成员函数,它返回一个描述异常的空终止字符序列(C风格字符串)。cout << e.what() << endl;语句将这个消息打印到标准输出中

后续我们还会遇到这个函数,再详细讲解

来看抛异常的结果:

在这里插入图片描述

3.2new/delete对自定义类型的操作

class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};
int main()
{
	A* p1 = new A(1);
	delete p1;
	return 0;
}

new/deletemalloc/free最大区别是 new/delete对于【自定义类型】除了开空间,还会调用构造函数和析构函数

	A* p1 = new A(1);
00007FF798AA260B  mov         ecx,4  
00007FF798AA2610  call        operator new (07FF798AA104Bh)  
00007FF798AA2615  mov         qword ptr [rbp+108h],rax  
00007FF798AA261C  cmp         qword ptr [rbp+108h],0  
00007FF798AA2624  je          main+50h (07FF798AA2640h)  
00007FF798AA2626  mov         edx,1  
00007FF798AA262B  mov         rcx,qword ptr [rbp+108h]  
00007FF798AA2632  call        A::A (07FF798AA1343h)  
00007FF798AA2637  mov         qword ptr [rbp+138h],rax  
00007FF798AA263E  jmp         main+5Bh (07FF798AA264Bh)  
00007FF798AA2640  mov         qword ptr [rbp+138h],0  
00007FF798AA264B  mov         rax,qword ptr [rbp+138h]  
00007FF798AA2652  mov         qword ptr [rbp+0E8h],rax  
00007FF798AA2659  mov         rax,qword ptr [rbp+0E8h]  
00007FF798AA2660  mov         qword ptr [p1],rax

在这里插入图片描述

new过程跳转到构造函数

在这里插入图片描述

delete调用析构函数

打印结果如下:

A():000001DB79796B50
~A():000001DB79796B50

我们发现,汇编代码中有这一步:

00007FF798AA2610  call        operator new (07FF798AA104Bh)

operator new,接下来我们来讲解这一部分

4.operator new与operator delete函数

new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间
void* __CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
	void* p;
	while ((p = malloc(size)) == 0)
		if (_callnewh(size) == 0)
		{
			// 如果申请内存失败了,这里会抛出bad_alloc 类型异常
			static const std::bad_alloc nomem;
			_RAISE(nomem);
		}
	return (p);
}

operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间失败,尝试执行空间不足应对措施,如果改应对措施用户设置了,则继续申请,否则抛异常。

 static const std::bad_alloc nomem;

申请失败则会抛异常

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_dbg(pUserData, pHead->nBlockUse);

operator delete: 该函数最终是通过free来释放空间的

通过上述两个全局函数的实现知道,operator new 实际也是通过malloc来申请空间,如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供该措施就继续申请,否则就抛异常。operator delete 最终是通过free来释放空间的

我们只需要简单了解一下,并不需要深入理解

5.new和delete的实现原理

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

自定义类型:

  1. new的原理调用operator new函数申请空间
  2. 在申请的空间上执行构造函数,完成对象的构造
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};
int main()
{
	A* p1 = new A(1);
	delete p1;
	return 0;
}

在这里插入图片描述

  • delete的原理
  1. 在空间上执行析构函数,完成对象中资源的清理工作
  2. 调用operator delete函数释放对象的空间
class Stack
{
public:
	Stack()
	{
		_a = (int*)malloc(sizeof(int) * 4);
		_top = 0;
		_capacity = 4;
	}

	~Stack()
	{
		free(_a);
		_top = _capacity = 0;
	}
private:
	int* _a;
	int _top;
	int _capacity;
};
int main()
{
	Stack* pst = new Stack;
	delete pst;
	return 0;
}

这里进行了双层嵌套:

在这里插入图片描述

我们就很清楚的能看到,现需要调用析构函数再进行释放

  • new T[N]的原理
  1. 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请
  2. 在申请的空间上执行N次构造函数
  • delete[]的原理
  1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
  2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间
class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};
int main()
{
	A* p1 = new A;
	A* p2 = new A[10];
	delete p1;
	delete[]p2;
	return 0;
}

在这里插入图片描述

在这段代码中,p2 是指向由 new A[10] 分配的对象数组的指针。虽然你可能会认为 p2 只需要分配足够存储 10 个 A 类型对象的空间,即 10 * sizeof(A)实际上编译器通常会分配额外的空间来存储有关数组本身的信息,比如数组的大小。这是因为在执行 delete[] p2; 时,系统需要知道要调用多少次析构函数

在这里插入图片描述

让我们具体看一下为什么会这样:

  1. 对象数组的内存分配:当你创建一个对象数组时,例如 new A[10],C++ 需要知道在稍后释放数组时应该调用多少次析构函数。为此,它可能在分配给数组的内存块中存储一些额外的元数据,通常是数组的长度
  2. 析构函数调用:在使用 delete[] p2; 释放内存时,这个额外存储的信息就被用来确保为数组中的每个元素正确调用析构函数
  3. 内存布局:因此,分配给 p2 的内存实际上包含了更多比简单的 10 * sizeof(A) 字节。首先是数组长度的元数据(大小取决于系统和编译器),紧接着是 10 个 A 类型对象的存储空间
  4. 字节大小:如果 sizeof(A) 是 4(假设 int 类型是 4 字节,并且没有类对齐导致的额外空间),那么仅对象部分就占用了 40 字节。加上存储数组大小的额外空间,总大小就会超过 40 字节

在这里插入图片描述

我们再来看内置类型:

int* p1=new int[10];
00007FF7F031206B  mov         ecx,28h  

刚好开辟了四十个字节的空间,因为它不需要调用析构函数

6.简单了解定位new表达式(placement-new)

定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象

使用格式: 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()
{
	int* p1 = new int[10];
	return 0;
}
A* p1 = (A*)malloc(sizeof(A));
p1现在指向的只不过是与A对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行
new(p1)A;

显示调用构造函数对一块已经有的空间的初始化

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

7.概念辨析

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

7.2 内存泄漏

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

分类:

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