【C++破局】C++内存管理之new与deleted剖析

作者主页

📚lovewold少个r博客主页

   ⚠️本文重点c++内存管理部分知识点梳理

👉【C-C++入门系列专栏】博客文章专栏传送门

😄每日一言:花有重开日,人无再少年!

目录

C/C++的内存分配机制

内存分区

1. 内核空间(Kernel Space):

2. 栈空间(Stack):

3. 内存映射段(Memory Mapping Segment):

4. 堆(Heap):

5. 数据段(Data Segment):

6. 代码段(Code Segment):

C与C++的动态内存管理方法

malloc,calloc,realloc的内存开辟函数

内存泄露  

C++的内存管理方式

new/delete操作内置类型

 new的基本用法

delete的基本用法

malloc/free和new/delete的区别

operator new与operator delete函数

重载operator new和operator delete(了解)

new和delete的底层原理

       new的底层原理:

       delete的底层原理:

定位new表达式

总结

前言

        在c语言的学习过程中,我们学习了c语言的动态管理内容。本章的内容主要是从C/C++的内存分配区域入手,深入浅出的讲解C++的内存分配机制和new与delete。

C/C++的内存分配机制

内存分区

1. 内核空间(Kernel Space)

   位置: 在内存的顶部,通常是最高的地址空间。
   用途:内核空间用于存放操作系统的内核代码和数据结构。这部分内存通常是受保护的,只有内核态的代码可以访问。

2. 栈空间(Stack)

   栈是向下增长的:对于栈来说,栈内存的地址是从高地址向低地址增长的。新的栈帧会被放置在当前栈顶的上方。栈帧的释放会导致栈顶向低地址移动

    位置:通常位于用户空间和内核空间之间。
    用途:栈用于存储函数的局部变量、函数参数、返回地址和函数调用的上下文信息。栈是一个后进先出(LIFO)的数据结构。
    自动分配和释放:栈内存是由系统自动分配和释放的,函数调用时分配,函数返回时释放。

3. 内存映射段(Memory Mapping Segment)

    位置:通常位于用户空间和内核空间之间。
    用途:内存映射段包括可执行文件的映射、共享库的映射以及动态链接器的映射。这些映射将磁盘上的二进制文件映射到内存中,以便执行。

4. 堆(Heap)

   堆是向上增长:堆的内存地址是由低地址向高地址增长的,新分配的内存位于已经分配的内存的上方。

   位置:通常位于内核空间和栈空间之间。
   用途:堆用于动态分配内存,程序员可以通过函数如 malloc、calloc、realloc 来在堆上分配内存。堆上的内存需要手动释放,否则可能导致内存泄漏。
   动态大小:堆的大小不是固定的,可以根据需要动态分配和释放。

5. 数据段(Data Segment)

   位置:通常位于内核空间和栈空间之间。
   用途:数据段包括已初始化的全局变量和静态变量。这些变量在程序启动时就被初始化,并在整个程序的执行周期内保持不变。

6. 代码段(Code Segment)

   位置:通常位于内核空间和栈空间之间。
   用途:代码段包含程序的机器代码,即可执行指令。这部分内存是只读的,用于存储程序的执行代码。

只读数据段中,包含了两种常量:

1. 字符串常量:
   例如,C/C++中的字符串字面值(如”Hello, World!”)。
   这些字符串常量存储在只读数据段中,因为它们在程序执行期间是不可修改的。
2. 其他常量:
   例如,C/C++中的全局常量(使用 const 关键字声明的常量)。
   这些常量也通常存储在只读数据段中。

C与C++的动态内存管理方法

malloc,calloc,realloc的内存开辟函数

malloc, calloc, 和 realloc 是在C语言中用于分配内存的三个常见函数。

malloc(Memory Allocation):

示例:

void Test()
{
	int* p1 = (int*)malloc(sizeof(int));
	if (p1 == NULL)
	{
		perror("malloc");
		exit(EXIT_FAILURE);
	}
	free(p1);
}
  • 原型:void* malloc(size_t size);

  • 功能:用于动态分配指定大小的内存块。

  • 参数:size 表示要分配的字节数。

  • 返回值:如果分配成功,返回指向分配内存的指针;如果失败,返回 NULL

calloc(Contiguous Allocation):

示例:

#include<stdlib.h>
void Test()
{
	int* p1 = (int*)calloc(4,sizeof(int));
	if (p1 == NULL)
	{
		perror("calloc");
		exit(EXIT_FAILURE);
	}
	free(p1);
}
  • 原型:void *calloc(size_t num_elements, size_t element_size);

  • 功能:用于动态分配指定数量和大小的内存块,并将每个字节初始化为零。

  • 参数:num_elements表示要分配的元素数量,element_size表示每个元素的大小(字节数)。

  • 返回值:如果分配成功,返回指向分配内存的指针;如果失败,返回 NULL

realloc(Re-allocation):

示例:

#include<stdlib.h>
void Test()
{
	int* p1 = (int*)calloc(4,sizeof(int));
	if (p1 == NULL)
	{
		perror("calloc");
		exit(EXIT_FAILURE);
	}
	int* p2 = (int*)calloc(p1, sizeof(int)*10);
	if (p1 == NULL)
	{
		perror("malloc");
		exit(EXIT_FAILURE);
	}
	free(p2);
}
  • 原型:void *realloc(void *ptr, size_t new_size);

  • 功能:用于更改先前分配的内存块的大小。

  • 参数:ptr是之前由 malloc, calloc  realloc 返回的指针;new_size 是新的内存块大小。

  • 返回值:如果分配成功,返回指向重新分配内存的指针;如果失败,返回 NULL。如果 ptr  NULL,则其行为等同于 malloc(new_size)

        以上函数主要要从函数功能进行区分,要注意的是无论是哪一种函数开辟或者扩容的空间,后面都需要手动free进行释放。同时对于realloc我们扩容的空间释放只需要释放后者,他的作用是根据需要重新分配内存,可能会将现有的内存块的内容移动到新的位置,然后释放原来的内存块。如果realloc内部决定在现有块的末尾或相邻位置分配新的内存,它将返回原始块的指针,而不是分配新的块。因此,如果新的内存块被分配在了原来的内存块上,释放原来的内存块也就释放了新的内存块,因为它们实际上是同一块内存。对于用户来说,只需保留realloc返回的指针即可,而不需要显式释放原来的内存块,需要避免多次释放。

内存泄露  

        内存泄漏是指在程序运行时,动态分配的内存没有被释放,导致系统无法再次使用这些内存块。当一个程序在运行过程中分配了内存,但在不再需要这些内存时没有释放,就会发生内存泄漏。这对一个操作系统或者服务器的话影响是非常巨大的。

        内存泄漏可能导致程序运行时占用的内存不断增加,最终耗尽系统的可用内存,从而导致程序性能下降或崩溃。内存泄漏是一种常见的编程错误,因此在开发过程中应该特别注意及时释放不再需要的内存。就比如我们经常使用一个软件的时候,长时间运转我们会发现程序消耗的内存越来越大,手机或者电脑越来越卡顿,这可能就存在内存泄露的问题,内存不能及时释放,只能新开辟空间用于后续进程,反复叠加下对性能的消耗会越来越大。合理的掌握内存分配和管理,能极大的提高程序运行的效率进而提高用户使用的体验。

        因此无论我们是使用方式的什么开辟的空间,动态内存开辟的空间需要程序员自己去掌控,要及时的free开辟的空间。

C++的内存管理方式

        在C++中,由于C++向下兼容,虽然你仍然可以使用malloc和free,但是推荐使用newdelete。首先我们知道,C++是一门面向对象的语言,和C面向过程不同,我们要体现C++的优越性就一定要对底层问题的处理上进行封装调用。

        malloc很显然能完成对于内存开辟问题,但是在C++中,我们通常是构建一个自定义的类,而构建一个类后我们需要完成对类的初始化。在使用malloc的时候我们能清晰的感觉到,首先对void*的返回值我们要进行强制类型转换,并且还需要传递空间内存大小完成开辟的工作。然后我们还需要手动调用构造函数进行构造么,这也太麻烦了。

        因此对于这些地方使用极为不便和无能无力我们就需要使用新的操作方式。

new/delete

        在C++中,new和delete是用于动态内存管理操作符,它们分别用于在堆上分配和释放内存。这两个操作符提供了对动态内存的灵活控制,特别适用于需要在运行时确定内存大小的情况。

 new的基本用法

        new用于在堆上动态分配内存,并返回指向新分配内存的指针。它同时会调用对象的构造函数(如果有的话),对新分配的内存进行初始化。(一个对象就轻松被new出来咯!!!)

void Test()
{
    //动态申请一个int类型的空间
	int* ptr1 = new int;
    //动态申请一个int类型的空间并初始化为10
    int* ptr2 = new int(10);
    //动态申请10个int类型的空间
	int* ptr3 = new int[10];
}

delete的基本用法

delete用于释放通过new分配的内存。在释放内存之前,delete会调用对象的析构函数(如果有的话),对内存中的对象进行清理工作。 (一个对象的终点)

void Test()
{
    //动态申请一个int类型的空间
    int* ptr1 = new int;
    //动态申请一个int类型的空间并初始化为10
    int* ptr2 = new int(10);
    //动态申请10个int类型的空间
    int* ptr3 = new int[10];

    delete ptr1;//释放单个对象的空间
    delete ptr2;
    delete[] ptr3;//释放数组空间
}

注意:要时刻注意一一对应

  • 对于每个new,应该有一个相应的delete,对于每个new[],应该有一个相应的delete[]
  • 不要混合使用newdeletemallocfree,也不要混合使用new[]和delete[]mallocfree
  • 在使用newnew[]时,应该使用相应的deletedelete[]来释放内存,以确保正确调用析构函数。

malloc/free和new/delete的区别

        我们知道,malloc并不会去调用构造函数,free也不会去调用析构函数。对于自定义类型的空间处理上new和delete不仅能完成空间的开辟还能完成调用构造函数和析构函数。对于自定义类来讲显然new和delete更甚一筹,而对于非自定义类型的时候,两者并无太大区别,不过new和delete在使用方式上也相对更加简略了。

class A
{
public:
	A(int date = 0)
	{
		_date = date;
		printf("a()  ");
		printf("_date=%d\n", _date);
	}
	~A()
	{
		printf("~a()\n");
	}
private:
	int _date;
};
int main()
{
	printf("____________a__________________\n");
	A* a = new A;
	delete a;

	printf("____________b__________________\n");
	A* b = new A(10);
	delete b;

	printf("____________c__________________\n");
	A* c = new A[10];
	delete[] c;
	printf("____________d__________________\n");
	A* d = new A[5]{ 1,2,3,4 };
	delete[] d;
	return 0;
}

在内存管理上来讲当分配内存失败的时候两者也有区别

  • 在分配内存失败时,malloc返回 NULL,需要手动检查。
  • new运算符在分配内存失败时抛出异常,可以通过异常处理机制来处理。

operator new与operator delete函数

     在 C++ 中,new是一个关键字,它用于动态分配内存并构造对象,而operator new是与new相关的一个全局函数,用于执行实际的内存分配。delete同理。我们这里进一步剖析new和delete的底层原理。

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

        operator new与operator delete是两个全局函数,在上述代码中是其函数的实现原理,我们不难发现,其函数实际上也是通过malloc来申请空间的,成功申请后就直接返回,否则执行用户提供的空间应对不足的措施,如果用户继续申请就会抛异常。operator最终也是通过调用free来释放空间的。

        抛异常是c++中一种在程序执行过程中遇到错误或异常情况时,通过特殊的语句将程序的控制权传递给异常处理机制。这个过程通常称为”抛出异常”。异常是一种表示程序错误或不正常情况的机制。当某个异常条件发生时,程序会停止当前执行路径,然后查找能够处理这个异常的代码块。如果找到了匹配的异常处理代码块,程序会跳转到该处进行处理。如果找不到对应的处理代码,程序可能会终止运行。(后面会详细讲解)

        在main函数中,我们使用try块来包裹可能抛出异常的代码,然后使用catch块来捕获并处理异常。如果异常被抛出,控制流将跳转到匹配的catch块。

        这里我们演示一下抛异常和捕获异常:

#include <iostream>
using namespace std;

int main(void)
{
    char* p2 = nullptr;
    try 
    {
        char* p2 = new char[1024u * 1024u * 1024u * 2u - 1];
    }
    catch (const exception& e) 
    {
        cout << e.what() << endl;
    }
    printf("%p\n", p2);

    return 0;
}

        在上述演示中,我们申请了大面积的内存空间,程序并没有出现C语言的崩溃现象,而是以抛异常的方式优雅的退出并展示了错误原因。

重载operator new和operator delete(了解)

        一般情况下,不对这两个函数进行重载,但是在一些特定的情况下,我们可以完成一些特殊的请求。比如在申请和释放空间的时候有一些特殊的需求,打印一些日志信息,帮助用户检测是否存在内存泄露的情况之类的场景需求。

void* operator new(std::size_t size) 
{
    void* ptr = std::malloc(size);
    std::cout << "自定义new分配了 " << size << " 字节的内存,其地址空间为 " << ptr << std::endl;
    return ptr;
}

void operator delete(void* ptr) noexcept 
{
    std::cout << "自定义 delete:释放了地址为 " << ptr << " 的内存" << std::endl;
    std::free(ptr);
}

int main() 
{
    int* p = new int;
    delete p;

    int* arr = new int[5];
    delete[] arr;

    return 0;
}

new和delete的底层原理

new的底层原理:

  1. 计算分配的总内存大小:new首先计算要分配的总内存大小,包括对象的大小以及额外的管理信息。

  2. 调用operator new分配内存:new调用operator new函数进行实际的内存分配。可以重载它以提供自定义的分配逻辑。

  3. 调用对象的构造函数: 如果new用于分配单个对象,它会调用对象的构造函数,完成对象的初始化。

  4. 返回分配的内存地址: 最后,new返回指向新分配对象。

delete的底层原理:

  1. 调用对象的析构函数: 在使用delete释放对象之前,会调用对象的析构函数,进行对象资源的清理。

  2. 调用operator delete释放内存:delete调用operator delete函数释放之前分配的内存。可以重载它以提供自定义的释放逻辑。

  3. 返回释放的内存: 最终,操作系统可以重新利用被释放的内存。

数组的原理即是调用相应的operator new[]对多个对象的空间申请和构造,销毁同理。

定位new表达式

定位new表达式是在一个已经分配了原始的内存空间中显示的调用构造函数完成对对象的初始化操作。

new (ptr) Type(init);

其中,prt是指向已分配内存的指针,Type是对象的类型,init是可选的初始化参数(可以带参也可以不带参)。

这种形式的new主要用于在预分配的内存区域中创建对象,配合内存池使用,因为内存池分配的内存没有初始化。而不是在堆上分配新的内存。这对于一些特殊的需求,如实现自定义内存池或在固定地址处构造对象等情况非常有用。

手动调用析构函数: 当你不再需要这个对象时,需要手动调用析构函数来释放资源

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

	A* p2 = (A*)operator new(sizeof(A));
	new(p2)A(10);
	p2->~A();//可以显示的调用析构函数,构造函数不可以。
	operator delete(p2);
	return 0;
}

总结

        本章主要从C/C++的不同内存管理机制入手,深入浅出的讲解new和delete的底层原理和一些扩展知识。对于文章中涉及到内存池和抛异常等机制我们会在后续单独讲解,本章点到为止。希望能对大家深入理解new和delete有所帮助!

    作者水平有限,如有错误欢迎指正!

    

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
扎眼的阳光的头像扎眼的阳光普通用户
上一篇 2023年12月11日
下一篇 2023年12月11日

相关推荐