【C++】内存管理 —— new 和 delete

发布于:2025-05-11 ⋅ 阅读:(28) ⋅ 点赞:(0)

一、C/C++ 内存分布

在内存管理中,我们每一个运行起来的程序都会有一个叫进程地址空间的东西,为了更好地管理内存,这里的进程地址空间又进一步地划分为几个区域,分别是:栈、堆、静态区、常量区,用来存储我们的数据。在 C/C++ 中我们所学习到的指针其实就是这样的空间从低地址到高地址以字节为单位的编号。而空指针就是最底下那一个字节 (第 0 个字节) 的地址

请添加图片描述

我们所定义的不同类型的数据会存储在不同的区域,下面我们通过一个例题来感受一下。

int globalVar = 1;
static int staticGlobalVar = 1;
void Test()
{
	static int staticVar = 1;
	int localVar = 1;
    const int localVar2 = 2;

	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
staticGlobalVar
staticVar
localVar
localVar2
num1
char2
*char2
pChar3
*pchar3
ptr1
*ptr1

globalVar 是全局变量,而全局变量存储在静态区,因此选 C。

staticGlobalVar,它是一个全局的静态变量,依旧存储在静态区,选 C。它和普通全局变量的区别就是连接属性不同普通全局变量所有文件可见,而静态的全局变量只在当前文件可见

staticVar 是一个局部的静态变量,依旧存储在静态区,选 C。它和全局的静态变量的声明周期都是全局的,但是区别是局部的静态变量初始化要比全局的晚,是第一次执行到该位置的时候才初始化。

localVar 是一个局部变量,存储在 Test 函数的栈帧中,即存储在中,选 A。

localVar2 虽然有一个 const 修饰,但是严格来说它是属于一种常变量,一些特殊情况下也是可以修改的,所以它并不存储在常量区,而是存储在中,选 A。

nums1 是数组名,数组名又两种含义,一种是代表整个数组,一种是首元素的地址。如果我们对数组进行大小计算 sizeof 的时候,nums1 就代表的是整个数组。如果我们对数组进行一些运算、解引用的时候,数组名就代表的是首元素的地址。无论从哪个角度出发,它都属于一个局部的变量,存储在中,选 A。

char2 是一个数组,它是把一个常量的字符串拷贝给了一个数组,char2 本身不是一个常量,因此它和 nums1 一样是一个数组,是一个局部变量,存储在中,选 A。

请添加图片描述

*char2 这里的 char2 代表的是首元素的地址,而再解引用之后就是数组的首元素,即 a 。由上图可知它是也是存储在中的,因此选 A。

pchar3 虽然有一个 const 修饰,但是它不属于常量区,并且这里的 const* 的左边,修饰的是指向的内容而不是指针本身,就算是的话那么也不存储在常量区。它依旧是一个局部变量,存储在中,选 A。

*pchar3 才存储在常量区,因为这里的 pchar3 本质上是一个指针,这个指针在栈中,只不过它指向了一个常量字符串,这个常量字符串存储在常量区。所以通过解引用之后的内容存储在常量区,选 D。

请添加图片描述

ptr1 同样也是一个指针,属于局部变量,存储在中,选 A。

*ptr1 则存储在中,因为 ptr1 所指向的内容是 malloc 动态开辟出来的,malloc 出来的数据存储在中,选 B。

简单总结一下:

  1. 又叫堆栈,存储非静态局部变量、函数参数、返回值等。并且栈是向下生长的,也就是说后定义的变量的地址要比先定义的变量的地址小。
  2. 堆用于程序运行时动态内存分配,堆是向上生长的。
  3. 静态区 (数据段),存储全局数据和静态数据
  4. 常量区 (代码段),存储可执行的代码和只读常量

在这几个区域中,我们需要重点学习区域的管理,因为其他区域都是自动管理的,只有堆区域是需要我们手动进行管理的,比如我们要自己开辟空间 (malloc) 和自己释放空间 (free)。


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

1. malloc / calloc / realloc / free

在 C 语言中我们学习了相关的动态内存管理的方式,其中包括 malloccallocrealloc 三种申请空间的方式。

malloc:申请一段空间。
calloc:申请一段空间并且将这些空间按比特位初始化为 0
realloc:对 malloccalloc 申请的这些空间进行扩容。扩容分为两种:原地扩容异地扩容。如果对原来的数组进行扩容,后面的空间没有分配给其他人使用,那么就是原地扩容。如果要扩容的区域分配给其他人使用了,那么就异地扩容:在堆中找一块扩容后大小的没有分配给别人的一块空间,然后把原来的数据拷贝过来,并且把原来的空间 free 掉

所以以下代码如果是异地扩容那么是不需要释放 ptr2 的,就是因为 realloc 内部已经把这段空间释放掉了。

void Test()
{
    // 假设此处 realloc 是异地扩容
	int* ptr2 = (int*)calloc(4, sizeof(int));
	int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);

	free(ptr3); // 不需要 free(ptr2)
}

三、C++ 内存管理方式

C 语言中的内存管理方式在 C++ 中可以继续使用,但是有些地方就用不了了,并且使用起来比较麻烦,因此在 C++ 中又提出了自己的内存管理方式。

1. new / delete

在 C++ 中我们可以用 newdelete 两个操作符来进行进行动态内存管理。

void Test()
{
	int* ptr1 = new int;  // 动态申请 1 个 int 类型的空间
	int* ptr2 = new int[10];  // 动态申请 10 个 int 类型的空间

	delete ptr1;  // 释放单个
	delete[] ptr2;  // 释放多个

	int* ptr3 = new int(10);  // 动态申请一个 int 类型的空间并初始化为 10
	int* ptr4 = new int[10] {1, 2, 3, 4};  // 动态申请 10 个 int 类型的空间并初始化前 4 位

	delete ptr3;
	delete[] ptr4;
}

new 和 malloc 的区别就是 malloc 必须要指定你开多少空间,而 new 默认就是开 1 个对应类型的空间,开多个就加一个方括号 [] 即可。并且 new 不需要对返回类型强转,new 的是 int 类型那么自动就返回的是 int*

当然这些区别都是次要的,一个主要的区别体现在它们为某个类对象开空间时的不同。

new 和 delete 对于自定义类型除了开空间还会调用构造函数和析构函数

class A
{
public:
	A(int a = 1)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

	~A()
	{
		cout << "~A()" << endl;
	}

private:
	int _a;
};

int main()
{
	A* p1 = (A*)malloc(sizeof(A));  // 用 malloc 就不好初始化, 它不会调用构造函数
	A* p2 = new A; // 用 new 就很方便, 因为会调用构造函数
	A* p3 = new A(10);

	free(p1);  // 不会调用析构函数
	cout << "--------" << endl;
	delete p2;  // 这里还会调用析构函数
	delete p3;

	return 0;
}

请添加图片描述

除了以上几点之外,new 和 malloc 它们对于申请空间失败情况的处理也不同。

对于 malloc,我们不断申请空间,打印对应的地址并且记录申请的总空间。

int main()
{
	size_t x = 0;
	int* ptr1 = nullptr;
	do {
		ptr1 = (int*)malloc(10 * 1024 * 1024);
		if (ptr1)
			x += 10 * 1024 * 1024; 

		cout << ptr1 << endl;
	} while (ptr1);

	cout << x << endl;
	cout << x / (1024 * 1024) << endl;

	return 0;
}

请添加图片描述

可以看到 malloc 不成功的话它会打印一个空地址。

我们再来看看 new 的处理情况。

int main()
{
	size_t x = 0;
	int* ptr1 = nullptr;
	do {
		ptr1 = new int[10 * 1024 * 1024];
		if (ptr1)
			x += 10 * 1024 * 1024 * 4;  // 一次 new 四个字节,所以乘以 4

		cout << ptr1 << endl;
	} while (ptr1);

	cout << x << endl;
	cout << x / (1024 * 1024) << endl;

	return 0;
}

请添加图片描述

可以看到 new 申请失败了它并不会打印出空地址,而是转为报错。实际上它的机制是抛异常,如果抛出了异常,我们需要对其进行捕获,如果到 main 函数结束都没有被捕获,就会报错。关于异常的详细内容,我们这里不展开


2. operator new 与 operator delete 函数

上面我们讲的 new 和 delete 是用户进行动态内存申请和释放的操作符,而 operator new 和 operator delete 是系统提供的全局函数,注意它不是运算符重载函数。new 在底层调用 operator new 全局函数来申请空间,delete 在底层通过 operator delete 全局函数来释放空间

对于 operator new 函数,它实际上是通过 malloc 来申请空间的,只不过除了 malloc,它还有更多的机制。如果 malloc 申请空间成功了就直接返回。如果申请空间失败,那么就尝试执行空间不足的应对措施,如果用户设置了相应的应对措施,则继续申请,否则抛出异常。

下面是某版本 operator new 的源码:

void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
 // try to allocate size byte
 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);
}

我们从汇编的角度是可以看到 new 它的底层是调用了 operator new 和析构函数的。

请添加图片描述


对于 operator delete 函数,它最终是通过free来释放空间的

下面是某版本 operator delete 的源码:

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 );  // 这里其实就是 free()
     __FINALLY

         _munlock(_HEAP_LOCK);  /* release other threads */

     __END_TRY_FINALLY

     return;
}

// free的实现
#define   free(p)               _free_dbg(p, _NORMAL_BLOCK)

3. new 和 delete 的实现原理

(1) new 的原理

  1. 调用 operator new 函数申请空间。
  2. 再在申请的空间上调用构造函数,完成对象的构造。

(2) delete 的原理

  1. 先在空间上执行析构函数,完成对象中资源的清理。
  2. 再调用 operator delete 函数释放对象的空间。

先调用析构函数是因为像 Stack 这样的类,它的成员变量是有一个数组 int* _a 的,这个 _a 本身指向了一块空间,如果你先就调用 operator delete_a 释放掉了,但是 _a 所指向的那块空间并没有释放,就会造成内存泄漏。所以我们要先调用析构函数把 _a 所指向的空间释放掉,再释放 _a


(3) new T[N] 的原理

  1. 先调用 operator new[] 函数,实际上就是在 operator new[] 中实际调用多次 operator new 函数完成 N 个对象空间的申请。
  2. 再在申请的空间上执行 N 次构造函数。

(4) delete[] 的原理

  1. 在释放的对象空间上先执行 N 次析构函数,完成 N 个对象中资源的清理。
  2. 再调用 operator delete[] 函数释放空间,实际上就是在 operator delete[] 中调用多次 operator delete 来释放空间。

(5) 补充点与注意事项

我们先来看看下面这个程序。

class A
{
public:
	A(int a = 1)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

	~A()
	{
		_a = 0;
		cout << "~A()" << endl;
	}

private:
	int _a;
};

int main()
{
	A* ptr1 = new A[5];

	delete[] ptr1;
	return 0;
}

每个 A 对象的大小是 4 个字节,那么开 5 个 A 对象按理来说应该是 20 个字节。但是我们通过汇编的角度观察发现,它开的空间不是 20 个字节而是 24 个字节(16 进制的18对应10进制的24)。而且如果只开一个对象大小的空间就是正常的 4 个字节,不会多出 4 个字节。

请添加图片描述

为什么 new 多个对象会多开 4 个字节呢?实际上我们开多个空间的时候在开空间的时候会在前面多开 4 个字节来存储我们的数据个数。比如说我们开了 5 个对象大小的空间那么就会在最前面存一个 5,表示有多少个对象。虽然我们在前面多开了 4 个字节,但是实际上 ptr1 指向的是第一个对象的最开始的位置。

请添加图片描述

而这多开的 4 个字节实际上是给 delete[] 使用的,delete[] 底层是调用多次 delete 和析构函数,delete 的底层是 free,free自己会知道自己要释放多大的空间,那么到底调用多少次析构函数就是由这多存的那个空间中的数所决定的。ptr1 往前偏移 4 个字节把这个值取出来之后 delete[] 就知道要调用多少次析构函数了。

但是如果我们把我们自己写的析构函数屏蔽掉,你会发现它又开的是 20 个字节。

class A
{
public:
	A(int a = 1)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

private:
	int _a;
};

int main()
{
	A* ptr1 = new A[5];

	delete[] ptr1;
	return 0;
}

16进制的14对应10进制的20。

请添加图片描述

这是因为如果我们自己没有写析构函数,那么编译器就会自动生成一个析构函数,这个析构函数什么都不会做,那就可以不调用了,因此编译器就会认为前面多存的这个数存了也没什么用,所以就不存了,相当编译器把它优化掉了。

还需要补充讲解的是,如果这个时候我们使用 delete ptr1 这样的不加 [] 的类型的话,是不会报错的,也不会造成内存泄漏。

但是!如果把析构函数重新自己写出来的话用 delete ptr1 就会报错。因为这个时候 ptr1 实际上是指向第一个对象的起始位置,前面还有 4 个字节存储对象个数,相当于指向的是你开出来的空间的中间的一个位置。而释放一段空间是不能从中间开始释放的,释放的空间位置不对那么就会报错,并且它只会调用一次析构函数。

delete[] ptr1 才是正确的, ptr1 它会先往前偏移 4 个字节把对象个数取出来,再去释放空间和调用析构函数。

把析构函数删掉用 delete ptr1 不会报错是因为编译器不会开前面的 4 个字节的空间,释放空间的时候是从最开始开始释放的,但是也不要这样用。

所以在释放多个对象的时候,一定要delete[] 这样的加方括号的形式,以免出现意外。


四、定位 new 表达式 (replacement-new)

1. 定位 new 表达式的使用

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

在 C++ 中是不支持直接显式调用构造函数的。

int main()
{
	A* p1 = new A(1);
	
	// p1->A(1);  // C++ 不支持这样的显式调用构造函数

	p1->~A();  // 但是析构可以

	return 0;
}

但是在特殊条件下我们可以显式地调用构造函数,我们可以显式地调用 operator newoperator delete 函数,然后用定位 new 显式调用构造。

int main()
{
	A* p1 = (A*)operator new(sizeof(A));
	new(p1)A(10);  // 定位 new (replacement new) 显式调用构造函数
    // 上面两行就相当于 new 的功能

	p1->~A();
	operator delete(p1);
    // 上面两行就相当于 delete 的功能

	return 0;
}

2. 应用场景

当我们要高频地向系统申请空间的时候,我们的效率就会下降,因此为了提效,我们可以提前找系统申请一大块空间,存储起来,自己来管理,这块空间就是内存池

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


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