C++内存管理(一)

前天对内存底层机制十分感兴趣,又碰巧在B站刷到侯捷老师的C++内存管理机制,陆陆续续花了三天,终于啃完了。正如视频标题所称——从平地到万丈高楼,着实让我收获颇丰。写了以下的观看笔记,分享一些老我的理解和心得。

第一讲Primitives

在这里插入图片描述

这幅图说明了C++应用程序申请内存的四个途径,每个途径都可以实现内存的申请调用而且具有层次关系。比如在我们利用C++标准库STL容器创建对象的时候,容器向allocator提出内存申请,再由allocator向下一级去申请内存等等。这个过程经过封装的,在我们眼里我们只是去创建一个容器对象,系统向我们分配一块内存来使用,但我们并不知道其中的原理。这也是这么课存在的意义,告诉我们这些内存是如何高效分配的。回归话题,在allocator向下一级申请内存的时候,它实际上会去调用new、new[]、::opeartor new()等等,这些primitives我们都知道是C++标准语法中申请内存的关键字。于是申请内存的任务就迭代到new、new[]、::opeartor new()等primitives的身上,再往下剖析,观察源码,我们就会发现在new中,实际上就是调用了malloc去分配内存,再往下就是由操作系统提供的内存分配函数。其实我们并不需要一级一级往下,从中间任意步骤开始都可以去申请内存,那我们为什么不直接在代码中调用最底层由操作系统提供的内存申请函数呢?其实答案显而易见,我们将底层函数层层封装,封装带来的好处让程序员高效便利的去使用从而达到我们的目的。

在这里插入图片描述
这张图显示了我们调用new函数去申请内存的实际操作,他首先会去调用operator new函数,这个函数我们是可以重载的,就是去重载::operator new函数(全局),由我们自己手动去写内存分配函数。operator new()中我们可以清楚看到实际就是调用malloc函数去分配内存,还有一个关键步骤就是while调用if(_callnewh(size)==0),这一步骤提供了策略就是万一由于某种原因malloc失败,不会首先抛出bad_alloc异常,而是去调用一个_callnewh(size)(可重写call new hander)函数尝试去释放不必要的内存从而成功申请内存。分配完内存,会去进行对对象的构造函数,完成内存申请。这就是new内部第一层原理。
在这里插入图片描述
delete内部类似,我们从源码可以看到,先析构对象,再调用operator delete,实质上就是去用free去释放内存。
在这里插入图片描述
我们都知道如果要动态分配一组对象时需要加[],这时候就会唤起多次构造函数对对应个对象构造,在释放内存时也需要加[],这时由dtor唤起的this指针指向不同的obj并分别析构在释放内存。若是在释放内存是不加[]会产生什么影响呢?图中左下角告诉了我们申请的对象数组的数据结构是有cookie的,cookie记录了动态申请内存的大小,所以在释放时我们只用知道psa基地址加上cookie记录的大小便可以直接释放申请的内存,那么这说明我们没必要加[],让dtor只调用一次栈顶对象的析构函数即可。但是若是我们申请的对象中含有指针成员,这时只调用一次析构函数我们没办法释放所有成员对象的指针空间,这时便会造成内存泄漏。所以这也是为什么delete[]对标准库中的int char对象的释放无影响而对自定义对象的释放会存在内存泄漏问题,这就是指针作怪的原因。注意析构对象时次序逆反。
在这里插入图片描述
placement new很有趣。placement new是operator new的一个重载版本,只是我们很少用到它。如果你想在已经分配的内存中创建一个对象,使用new是不行的。也就是说placement new允许你在一个已经分配好的内存中(栈或堆中)构造一个新的对象。上图中第二句语句就是利用placement new在buf内存中建立一个Complex指针对象,我们看源码可以发现它实际上并没有调用malloc分配内存而是直接返回buf,这时Complex对象指针pc便指向buf首地址并存放对象,从而达到设计的目的。
在这里插入图片描述
这张图清晰的说明了内存管理的方法。首先看图,如前文所说,调用new时会进入operator new函数,若这时我们没有重写全局的::operator new函数,那么便在进入::operator new函数后进行malloc操作,而若我们在类的内部重写operator new函数时,这时我们重写的函数优先级会高,首先运行。这说明我们可以在类的内部对operator new函数重写从而实现内存管理,比如在下一讲中中我们写一个_pool_memory的数据结构去管理内存,实现对内存的高效分配。
我们该如何实现高效的内存管理呢?

1. new array时,我们反复调用malloc去进行内存申请分配小内存,当数据量足够大时,如上百万次,反复调用malloc总是不好的,也就是说,减少malloc调用次数总是好的。那我们是不是可以先malloc一大块内存作为预备内存池,在后续需要分配时从内存池中直接切离出小内存进行利用。如图中左上角。(速度上)
2. 对于我们申请的内存,假设申请10字节内存且成功,但实际上操作系统分给我们的比10字节要多(下一讲会有内存结构图),这是因为每一次malloc中,我们申请到的内存中都带有两块cookie(根据编译器版本),而cookie一块占用4字节(32位系统),那么如果malloc100万次,那么就会有800万字节的cookie浪费。那么我们是不是可以通过一个特定且巧妙的数据结构在不影响cookie功能的情况下减少cookie的使用,做到高效的内存分配。(空间上)

在这里插入图片描述
在容器中亦是如此,多了一层allocator向下去申请内存,但原理与上图不变。

好了,现在我们知道内存管理的方法是上述两个思路,手段是重写operator new函数从而接管内存分配的实现。二话不说便开始实战

1.1 C++Primer中内存管理
在这里插入图片描述
在类Screen中我们先看主要数据成员,有一个int类型变量,一个指向自己的next指针,两个静态数据成员,其中freeStroe指向申请得到的一大块内存的头指针,screenChunk为静态常量24。此时sizeof(Screen)=sizeof(obj),即一个对象的大小大于等于所有非静态成员大小的总和,所以为8个字节。接着再看类的的成员函数,重写的operator new和delete实现对内存管理。operator new函数中我们定义一个chunk来作为想要得到的大内存字节数,接着利用new申请得到需要的大内存赋给freeStore指针,接着就是利用for循环将大内存切片操作得到小内存用于每次malloc分配。在operator delete中我们利用链表的头插法将回收的对象空间插回到链表中并移动freeStore指针。
在这里插入图片描述
从测试实例中,我们很可以看到重写operator new后的Screen类在创建对象时少了头尾两个cookie,每个对象少了8个字节,符合空间上的内存分配。这个模型也存在缺点,在设计的时候我们多设计了一个指针只想自己,使原来seziof大小变为原来的两倍,此例中虽为一个int数据成员,但实际中可能有多个变量,膨胀率的100%。由此我们虽然减少了cookie,但是却增加了数据本身的大小,于是便延伸出了下一个版本。

1.2 C++Primer中内存管理
在这里插入图片描述
还是一样,先看数据成员,定义了一个结构体和一个联合体,两个静态变量。对于类的结构体大小我很有疑问,若像途中设计的这样,未定义结构体变量的结构体和未定义联合体变量的联合体大小为0,用seziof应该输出为1,这是疑惑一。假设我们把结构体变量和联合体变量都定义变量,那么根据内存对齐原则,第一个结构体占8字节,联合体中所有的变量的起始偏移都是一致的,所以大小为联合体中大小最大的元素,也占8字节,那么共sizeof应该输出16,这是疑惑二。但是在侯捷老师的视频和后续测试用例中大小确为8字节,确实很疑惑,欢迎读者评论讲解。为了方便,我们假设它就是8字节。类中还有两个静态成员变量,和1.1版本类似,一个为常量用来获取一大块内存,指针用来指向开辟的内存链表。再看成员函数,主要还是重写operator new和delete。在operator new中其实与1.1版本工作原理也类似,不过是使用全局的::operator new来申请内存,operator delete也类似,不再过多讲解。

比较1.1和1.2版本我们发现最大的不同就是用union,借用union前四个字节来设置next指针,因为union内共享内存,所以在该块内存未分配时我们使用嵌入式指针的概念利用前四个字节化为next指针,分配后往该块内存中写数据覆盖该指针内容使指针失效即可,如此一来便可以实现优化,不仅将cookie数减少到头尾两块,而且也节约了指向自己的指针,实现空间上的极大优化。

在这里插入图片描述
查看测试样例确实如与其所料。

由于在每个函数中都这么重写operator new和delete十分麻烦,代码冗余,所以我们可以专门设计一个类去做内存分配,所以分配器allocator就出现了。
在这里插入图片描述
代码稍有改动,但是原理类似,所以不再多讲。这时候要在类中在做内存分配只需要定义一个静态的分配器为每个对象去分配内存。

最后是一个我认为十分有用的功能值得记下笔记,就是C++11中=delete和=default。添加=default说明符到函数声明的末尾,以将该函数声明为显示默认构造函数。使用=delete说明符禁用其使用的任何成员函数,对于我们重写的operator new和delete也有效,在某些特殊场合会派上用场。

至此,第一讲完毕,从平地到万丈高楼,也算是踏上了第一层。

猜你喜欢

转载自blog.csdn.net/GGGGG1233/article/details/114989004
今日推荐