C++内存管理

C++内存管理

在这里插入图片描述

C/C++内存分布

我们的内存区域是要进行划分的。

电脑上有哪些核心资源?CPU(GPU也是一种特殊的CPU)、内存、磁盘。

程序运行本质是把写好的程序编译成指令。操作系统就像一个工厂,进程就像里面的机器人或工人,需要干活,我们的代码以进程的角度运行。我们需要给进程分配资源和原料。

在电脑的任务管理器就可以看到进程。

C/C++是以什么样的方式给它们分配内存的呢?

程序运行本质上就是处理各种数据,执行各种指令,得到一个结果。我们写的程序中不同的数据要存储在不同的阶段。局部数据如函数调用建立栈帧,用一会就销毁了;或者需要长期允许的,全局数据和静态数据;还有不修改的常量数据;还有需要动态申请的数据。

每个进程都有虚拟进程地址空间,然后要通过页表,跟物理内存进行映射。

进程执行其实就是从代码段上依次去取指令放到CPU上执行。

……

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

题目

现在我们可以通过题目来巩固一下内存管理的知识:

这就是我们平时会遇到的一些数据,清楚它们存放的段是很重要的。

第一组:

第一个是全局变量,所以在数据段(静态区)。

第二个是全局静态变量,放在数据段。

第三个是局部静态,也在数据段。

第四个是静态变量,在栈。

第五个,数组名代表整个数组,是局部数组,所以在栈。

第二组:

第一个char2是一个数组,在栈。

第二个*char2代表首元素。我们分析一下char char2[]="abcd";,意思是在栈上开了5字节的数组,然后把a b c d \0字符拷贝过来放到内存中(严格来说存的是ASCII码)。所以首元素是’a’。所以char在栈上。

数组名,sizeof(数组名)的时候,是代表整个数组。进行运算的时候,是首元素地址。

第三个,pChar3是一个字符指针,本身也是局部变量,在栈上。

第四个,pChar3指向的是常量字符串,常量字符串放在常量区也就是代码段,所以*pChar3是首元素,也就在代码段上。

第五个,ptr1也是栈上一个指针变量,占4个字节,指向堆上开的一块空间 。

第六个,*ptr1就在堆上。

char2是一个数组,pChar3是一个指针,一定要注意区分。

局部变量都是在栈上的,因为函数调用会建立栈帧,局部变量都存在这个栈帧里,函数结束也就跟着销毁。

其实我们自己要管的是堆上的数据。

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

void Test ()
{
 // 1.malloc/calloc/realloc的区别是什么?
 int* p2 = (int*)calloc(4, sizeof (int));
 int* p3 = (int*)realloc(p2, sizeof(int)*10);
 
 // 这里需要free(p2)吗?
 free(p3 );
}
//答案:这里不需要free(p2)。realloc是原地或者异地扩容,如果空间足够原地扩容,那么释放p3也就把p2释放了;如果是异地扩容,它会把p2先释放掉。也不需要管。
  1. malloc/calloc/realloc的区别?

    calloc相当于malloc+memset,也就是要初始化。realloc主要是扩容。

  2. malloc的实现原理?这里不说了。

C++内存管理方式

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

通过new和delete操作符进行动态内存管理。

new是一个关键字,不是一个函数,new后面直接跟类型就可以申请对象。

//申请一个int类型的对象:
int main()
{
    int* p1 = new int;//不需要类型强转、计算大小
    int* p2 = new int[10];
    
    delete p1;
    delete[] p2;//要匹配
    
    //申请对象+初始化
    int* p3 = new int(1);
    int* p4 = new int[10]{0};//全部初始化为0
    int* p5 = new int[10]{1,2,3,4,5};//初始化前5个
    
    delete p3;
    delete[] p4;
    delete[] p5;
    
    return 0;
}
C++弄出new,只是为了使用更方便吗?

回答这个问题之前,这前面我们看的都是内置类型,现在我们来看自定义类型

int main()
{
	A* p1 = new A;
	A* p2 = new A(1);

	delete p1;
	delete p2;

	return 0;
}

可以看到,我们用new和delete的时候,C++对于自定义类型,自己去调用了构造函数和析构函数。这才是真正的区别。

那么,我们就会有一个疑问,自己去调用构造函数和析构函数,真的这么重要吗?

我们回忆一下链表结点,在C语言中,申请一个新的链表我们一般调用已经写好的BuyNode函数,而在C++中,struct升级成为了类,也就是说有了构造函数。

struct ListNode
{
	int val;
	ListNode* next;

	ListNode(int x)
		:val(x)
		,next(nullptr)
	{}
};

int main()
{
	
    
	return 0;
}

那么现在我们要得到一个新的结点,可以看到,这个构造函数干的事情基本上就是我们原来BuyNode干的事。

我们可以快速得到一个链表:

struct ListNode
{
	int val;
	ListNode* next;

	ListNode(int x)
		:val(x)
		,next(nullptr)
	{}
};

int main()
{
	ListNode* n1 = new ListNode(1);
	ListNode* n2 = new ListNode(1);
	ListNode* n3 = new ListNode(1);
	ListNode* n4 = new ListNode(1);
	n1->next = n2;
	n1->next = n3;
	n1->next = n4;

	return 0;
}

new一个结点的时候我们不仅申请了空间,还调用了构造函数进行初始化。而malloc只开空间无法做到开空间时同时初始化。

我们再看这个场景:

没有默认构造了
class A
{
public:
	A(int a1,int a2 = 0)//现在没有默认构造了
		:_a1(a1)
		,_a2(a2)
	{
		cout << "A(int a1 = 0,int a2 = 0)" << endl;
	}
    
private:
	int _a1 = 1;
	int _a2 = 1;
};

struct ListNode
{
	int val;
	ListNode* next;

	ListNode(int x)
		:val(x)
		,next(nullptr)
	{}
};


int main()
{
	A* p1 = new A(1);
	A* p2 = new A(2,2);

    //创建并初始化一个A类型数组——有名对象写法
	A aa1(1, 1);
	A aa2(2, 2);
	A aa3(3, 3);
	A* p3 = new A[3]{ aa1,aa2,aa3 };//严格来说调用的是拷贝构造而不是构造了,因为这三个有名对象是已存在的
    
    //匿名对象写法
	A* p4 = new A[3]{ A(1,1),A(2,2),A(3,3) };//构造匿名对象再去拷贝构造,编译器会进行优化。
	
	return 0;
}

在上一句有名对象的三次拷贝构造结束后,可以看到只进行了三次直接的构造,并没有再拷贝构造。

这只是第二种写法,其实还有第三种写法:

int main()
{
    A aa1(1, 1);
    A aa2(2, 2);
    A aa3(3, 3);	
	//有名对象
	A* p3 = new A[3]{ aa1,aa2,aa3 };
	//匿名对象
	A* p4 = new A[3]{ A(1,1),A(2,2),A(3,3) };
	//隐式类型转换写法
	A* p5 = new A[3]{ {1,1},{2,2},{3,3} };
	
	return 0;
}

我们之前就说过,单参数构造函数支持隐式类型转换,多参数构造函数在C++11之后也支持隐式类型转换,需要用一个花括号括起来。

我们可以再深入梳理一下,第一种就是构造有名对象去拷贝构造数组;第二种是构造匿名对象去拷贝构造数组,但是编译器优化成 ;第三种本质上其实和第二种还是一样的:先构造临时对象,再去拷贝构造数组。编译器优化后第三种也是直接构造。

同时,也再次体现了默认构造函数的重要性。

这些各种括号的不同使用场景,要理解。

从此以后,一般情况下,不管是内置类型还是自定义类型需要申请内存,我们不再使用malloc而是使用new和delete。

申请资源失败

还有一个问题,以前malloc失败了我们需要检查。现在怎么不检查了呢?

其实是因为我们改用抛异常了。

我们malloc失败,返回的是空,而new失败返回的不是空。所以检查返回值是没用的。

日常中,动态开辟内存基本不会失败,所以我们一般不去管。

1M(兆)约等于100wByte。1G约等于10亿Byte。

1 T B = 1024 G B 1TB=1024GB 1TB=1024GB

1 G = 1024 M B = 1024 ✕ 1024 K B = 1024 ✕ 1024 ✕ 1024 B y t e 1G=1024MB=1024✕1024KB=1024✕1024✕1024Byte 1G=1024MB=1024✕1024KB=1024✕1024✕1024Byte

int main()
{
	void* p1 = new char[1024 * 1024 * 1024];
	cout << p1 << endl;

	void* p2 = new char[1024 * 1024 * 1024];
	cout << p2 << endl;

	void* p3 = new char[1024 * 1024 * 1024];
	cout << p3 << endl;

	return 0;
}

可以看到,程序终止了。

我们这里其实是一次就申请了一个G,在申请第二个G的时候就出错了。

这是一种捕获异常的写法。

还有一种:

那么我们现在看一下能申请多少兆的空间:

32位下,最多能在堆上申请1897兆,不到2G内存。现在申请的是虚拟内存。32位的进程地址空间都是4G,而实际内存条是16G。这之间存在虚拟内存和物理内存的映射。

64位的虚拟空间非常大,大概是42亿乘4G,一百六十多亿G。 2 64 2^{64} 264 2 32 2^{32} 232是4G

回到我们刚才的程序,32位下4G的虚拟内存,堆就已经给了将近2G,已经很大了。

32位程序指的到底是什么?

我们平时说的指针,本质是一个编号。空指针也是有编号的,对应第0个字节。一个字节对应一个编号,有 2 32 2^{32} 232个字节,对应 2 32 2^{32} 232个编号。 2 30 2^{30} 230是1G,所以 2 32 2^{32} 232是4G。32位下的指针是4字节,因为4字节的编号就能从00000000存到FFFFFFFF

64位下的地址从0000000000000000到FFFFFFFFFFFFFFFF,需要8个字节来存这个编号。64位下内存的各个区域都会大很多。大概整个空间有160亿G。因为 2 64 2^{64} 264不是 2 32 2^{32} 232的两倍,而是 2 32 2^{32} 232倍。42亿乘4G。

那么我们把刚才写的程序切换到64位环境运行一下:

void func()
{
	int n = 1;
	while (1)
	{
		void* p1 = new char[1024*1024 * 1024];
		cout << p1 << "->" << n << endl;

		++n;
	}
}

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

	return 0;
}

申请了48G空间左右。

如果更深去了解,160多亿G其实留了一部分没有用,因为实在太大了。

申请的是虚拟内存。会分块进行映射。当然这都是简单的说法,只是提一嘴。

一般情况下,32位下的线程,栈只分配8M,800w字节;堆是1.8G左右。所以递归深度太深栈会溢出。所以数据量很大的时候要去堆上申请,而不能借助栈。除了递归不要太深外,在栈上也不要定义大数组。

new和delete的底层原理

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

operator new和operator delete与new和delete的关系是什么呢?

new由两部分构成,一部分是开空间,一部分是调用构造函数。

  • new的原理
  1. 调用operator new函数申请空间
  2. 在申请的空间上执行构造函数,完成对象的构造

为什么要弄出一个operator new(披着马甲的malloc)而不是直接用malloc呢?因为我们知道malloc申请失败直接就返回空了,而C++期望的是申请失败后走抛异常的机制,所以用operator new给malloc套了一个马甲。

对于内置类型,new直接调用operator new申请空间就行了,对于自定义类型,还会去执行构造函数。

我们看一下编译后生成的指令:

看看反汇编:

可以看到编译器做了两件事,申请空间和调用构造函数。

再看看delete:

int main()
{
	A* p1 = new A(1);

	delete p1;

	return 0;
}

  • 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来释放空间

看看这段代码:

int main()
{
	A* p1 = new A(1);
	delete p1;

	A* p2 = new A[5];
	delete[] p2;

	return 0;
}

可以看到,对于A* p2 = new A[5];我们调用了5次构造和5次析构。

错配
int main()
{
	int* p1 = new int;
	free(p1);

	return 0;
}

像这样,我们去错配,会发生什么呢?

并没有崩溃。(内存泄漏是不会报错的)

这里有内存泄漏吗?没有。这里是内置类型。

但是不要这样去乱写。

那现在如果是自定义类型呢?

也没有崩溃。但是比起delete,其实少调用了一个A的析构函数:

如果A的析构函数没做什么事还好,如果在里面释放一些资源,那么就会因为没有调用到析构函数而内存泄漏。

再看:

这里没有正确使用delete[],漏写[]

没有崩溃,其实也不会有内存泄漏。

这里内置类型没有构造函数析构函数,new去调用operator new,operator new调用malloc,最本质的还是malloc来一块空间。

delete去调用operator delete,然后调用free,本质也是free。

所以空间不涉及构造和析构时,还是malloc和free。空间的申请和释放最终还是malloc和free解决的。

自定义类型:

可以看到也没有问题。

但是这样,却崩溃了。

A和B根据内存对齐,都是8字节。

10个A对象是84字节,10个B对象却是80。

编译器遇到A这种类型,会在头上多开4个字节,用来存储对象个数。

这个程序在不同编译器下可能情况不同。

new A的时候我们返回的不是malloc起始的位置:

所以我们delete的时候要往前偏移4字节才对。释放空间不能在中间释放。

那么为什么A要多开4字节呢?严格来说都应该开4字节存个数。B没有开是因为编译器进行了优化。因为编译器看到B没有写析构函数,不需要知道要析构几次。编译器看到B没有析构函数也不需要析构,自动生成的也没做什么事情,所以编译器干脆不调用析构函数了。

总之,一定要匹配使用,不要错配。

定位new表达式(placement-new)

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

int main()
{
	A* p1 = new A(1);//开空间并初始化

	A* p2 = (A*)operator new(sizeof(A));//只开了空间,没有初始化

	return 0;
}

如果现在想要对这块已经开好的空间去显示调用构造函数呢?定位new可以帮我们做到这一点。

可以看到,这样就也调用构造了。

那现在如果想要调用析构呢?

int main()
{
	A* p1 = new A(1);//开空间并初始化

	A* p2 = (A*)operator new(sizeof(A));//只开了空间,没有初始化
	new(p2)A(1);
	//p2->A(1);构造函数不能这样显式写,想要显示调用构造要写成上面定位new形式

	delete p1;

	p2->~A();//析构函数可以这样显式写

	return 0;
}
int main()
{
	A* p1 = new A(1);//开空间并初始化
	delete p1;

	A* p2 = (A*)operator new(sizeof(A));//只开了空间,没有初始化
	new(p2)A(1);//定位new,显式构造
	p2->~A();//析构函数可以这样显式写
	operator delete(p2);

	return 0;
}

其实上面p1和p2的效果是一样的,失败了都是抛异常。

但是两行就写好了更方便,后面这种写法很冗余。

但是在很少数的情况下会需要后面这种写法:

这涉及到池化技术:内存池,线程池,连接池……。

当我们需要高频地申请释放内存块时,从堆里面搞出一块专供的内存池,效率就会比较高。(具体细节现在无法多说)

int main()
{
	Type* p2 = new Type;

	Type* p1 = pool.Alloc(sizeof(Type));
	new(p1)Type;

	return 0;
}

但是当我们向内存池申请空间时只有空间,没有初始化,但是又想达到和new一样的功能,所以就得去调用。

所以是这样的场景有需求。以后再说。

现在我们的系统都是多核的,CPU是多少核其实就是有多少个CPU,就能支持并发执行。每次执行的时候再去创建线程,消耗很大,所以就会提前创建比如10个线程,有任务来了就直接执行,执行完了让线程再回来。连接池也一样,去连接数据库的时候不用现场去连接,减少消耗。

本文到此结束=_=

  • 14
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值