C++内存管理~

内存如何分配,c++内存布局,内存池,C++内存溢出、内存泄漏,如何防止
————————————————————————————————————
虚拟地址空间(虚拟内存)
虚拟地址空间(Virtual Address Space)是每一个程序被加载运行起来后,操作系统为进程分配的虚拟内存。虚拟内存=用户进程空间+系统内核空间,内核空间在上面。除了用户进程,操作系统也会独占一部分虚拟内存空间,用户进程只能使用操作系统分配给进程的地址空间,如果用户进程访问未经允许的地址空间,则会被操作系统判为非法请求,结果就是程序被操作系统强制结束。

每个进程所能访问的最大的虚拟地址空间由计算机 CPU 的位数决定的。32位分配的是4G,大约高1G为内核空间,低3G的为用户空间。但是对于X64 CPU,Linux 限制了虚拟地址中可用的比特数为 48 位,(使用40位来表示物理地址空间,)Windows 做出了进一步的限制,将其削减为 44 位。(可能的原因:并不需要2^64那么大的寻址空间,过大的空间只会导致资源的浪费。)

对于X64系统的虚拟内存,内核空间占一半(同样是高地址区域),普通进程空间使用另外一半,也就是 Linux下用户可用的虚拟地址空间大小为128 TB,Windows下为8TB。

————————————————————————————————————————————————————————
内存布局

澄清一个概念:自由存储区=堆(《C++ Primer》)
C/ C++程序运行时,会拥有虚拟地址空间中的用户进程空间,这些空间中的地址会被分段。C和C++的内存布局大致上是相同的,但略有区别。

C程序内存布局

主要分为以下几部分组成(低地址向高地址): 

  • 代码段(Text):代码段是由程序中的机器代码组成。在C语言中,程序经过编译后,形成机器代码。在执行的过程中,CPU的程序计数器指向代码段中的一条指令,依次执行。
  • 初始化数据段(数据段Data):可分为只读数据段和读写数据段,其中只读数据段是程序使用的一些不会被更改的数据,比如字面值常量,由于这些变量不需要修改,因此只需放置在只读寄存器中即可。读写数据段放置的是在程序中声明的,具有初始值的变量,比如已初始化的全局变量和静态变量。
  • 未初始化数据段(BSS段):存放的数据都是未被初始化的全局变量和静态变量,并在编译时默认会把这部分数据初始化为0或者空指针。
  • 堆(Heap):动态内存分配区,向高地址增长。
  • 栈(Stack):栈由系统自动进行内存管理,保存的是非静态局部变量和函数参数、函数返回值等信息。栈一般比较小(通常默认大小只有M级别,但可以由用户自行设定),一般和堆相邻,但沿着相反方向增长,当栈指针和堆指针相等就说明堆栈内存耗尽。

C++程序内存布局

也分为5个部分(低地址向高地址): 

  • 代码段:这部分与C程序是大致相同的;
  • 常量存储区:这部分存储的内容与C语言中初始化数据段中的只读数据段是一样的,用来存储C++常量;
  • 全局/静态存储区:在C++中,不再区分数据段和BSS段,未初始化和初始化的全局/静态变量都会存储在这里,并且初始化为0或者空指针;
  • 堆:动态内存分配区,和C类似,但是C只能由malloc(以及calloc、realloc)和free进行动态内存的申请和释放,C++在此基础上增加了new和delete;
  • 栈:与C程序的栈相同。

C++引进了对象,C++对象中的成员函数存储在代码段中,数据成员才会存储在栈中,同样静态变量会存储在在全局/静态存储区,并且必须初始化。

——————————————————————————————————————————————————————————————————————————
为什么堆是向上增长而栈向下增长?

个人觉得比较有说服力的解释:

这样设计可以使得堆和栈能够充分利用空闲的地址空间。如果栈向上涨的话,我们就必须得指定栈和堆的一个严格分界线,但这个分界线不好确定。

有的程序使用的堆空间比较多,而有的程序使用的栈空间比较多。所以就可能出现这种情况:一个程序因为栈溢出而崩溃的时候,其实它还有大量闲置的堆空间呢,但是我们却无法使用这些闲置的堆空间。

所以呢,最好的办法就是让堆和栈一个向上涨,一个向下涨,这样它们就可以最大程度地共用这块剩余的地址空间,达到利用率的最大化!!
——————————————————————————————————————————————————————————————————————————
为什么分成这么多个区域?

主要基于以下考虑:

一个进程在运行过程中,代码是根据流程依次执行的,只需要访问一次,当然跳转和递归有可能使代码执行多次,而数据一般都需要访问多次,因此单独开辟空间以方便访问和节约空间。
临时数据及需要再次使用的代码在运行时放入栈区中,生命周期短。
全局数据和静态数据有可能在整个程序执行过程中都需要访问,因此单独存储管理。
堆区由用户自由分配,以便管理。
——————————————————————————————————————————————————————
堆和栈的区别

1.分配和管理方式不同

堆是动态分配的,其空间的分配和释放都由程序员手工控制。容易产生memory leak内存泄漏。

栈由编译器自动管理,无需我们手工控制。栈有两种分配方式:静态分配和动态分配。其中静态分配是编译器完成的,比如局部变量的分配;动态分配alloc函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器进行释放,无须手工控制。

2.生长方向不同
堆是向着内存地址增加的方向增长的,从内存的低地址向高地址方向增长。栈的生长方向与之相反,是向着内存地址减小的方向增长,由内存的高地址向低地址方向增长。

3.申请大小的限制不同
能从栈获得的空间较小,能从堆获得的空间比较大。

4.空间连续性不同

在使用过程中,栈是一块连续的内存的区域,堆一般是不连续的内存区域。

5.能否产生碎片不同
对堆来说,频繁的new/delete或者malloc/free势必会造成内存空间的不连续,可能产生大量不可用的内存碎片,使程序效率降低。(内存对齐)
对栈而言,则不存在碎片问题,因为栈是先进后出的,永远不可能有一个内存块从栈中间弹出。

6.分配空间的效率上不同
栈(stack):栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。 
堆(heap):是C/C++函数库提供的,由new或malloc分配的内存,一般速度比较慢,而且容易产生内存碎片。它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加内存空间,引发用户态和核心态的切换,因此效率较低。

  

可以看到,堆和栈相比,由于大量 new/delete 的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。

所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP 和局部变量都采用栈的方式存放。所以推荐大家尽量用栈,而不是用堆。

虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

无论是堆还是栈,都要防止越界现象的发生(除非故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果。就算在程序运行过程中没有发生上面的问题,也还是要小心,崩掉之后 debug 是相当困难的 。

——————————————————————————————————————————————————————
//main.cpp
int a = 0; //a在全局已初始化数据区
char *p1; //p1在BSS区(未初始化全局变量)
main()
{
int b; //b在栈区
char s[] = "abc"; //s为数组变量,存储在栈区
//"abc"为字符串常量,存储在常量存储区
char *p1,*p2; //p1、p2在栈区
char *p3 = "123456"; //123456在常量存储区,p3在栈区
static int c =0; //C为全局(静态)数据,存在于静态存储区
//另外,静态数据会自动初始化
p1 = (char *)malloc(10);//分配得来的10个字节的区域在堆区
p2 = (char *)malloc(20);//分配得来的20个字节的区域在堆区
strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。 
free(p1);
free(p2);
}
————————————————————————————————————————————————————————
内存池

一种优化的内存管理方法,主要是为了:1、减少内存碎片(不能完全消除),提高内存的利用率;2、加快内存分配的速度,提升系统性能。(3、可根据业务需求设计专用内存管理器,便于针对特定使用场合的内存管理)

使用系统默认的内存分配函数(new/delete或malloc/free)来分配和释放堆上的内存,效率不高,同时还可能产生大量的内存碎片,导致长时间运行后性能愈发下降。

效率不高的原因:传统的内存分配函数属于系统调用,需要向操作系统内核请求分配和释放内存,导致CPU在内核态和用户态之间不断切换,这种操作效率比较慢,如果频繁的进行会影响系统性能。

常用 / 开源的内存池:ptmalloc(glibc malloc)/ tcmalloc(谷歌)/ jemalloc(facebook)

内存池的原理:在使用内存之前,预先申请分配一块内存留作备用,这块内存由用户管理而非系统管理。当有新的内存需求时,如果内存池的内存大小能够满足需求就从内存池中分出一部分内存块,(内存块不够再继续申请新的内存,)当内存释放后就回归到内存池留作后续的复用。

内存池按内存块存储方式可以分为堆内存池、链表内存池两种,按内存池块的长度是否固定可以分为定长内存池和非定长内存池。还有自适应变长块内存池。

内存池-内存块-内存单元(从大到小)。

为了减少内存碎片和系统调用的开销,malloc采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。当用户申请内存时,直接从堆区分配一块合适的空闲块。Malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;同时malloc采用显式链表结构来管理所有的空闲块,即使用一个双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。
当进行内存分配时,Malloc会通过隐式链表遍历所有的空闲块,选择满足要求的块进行分配;当进行内存合并时,malloc采用边界标记法,根据每个块的前后块是否已经分配来决定是否进行块合并。

STL内存池的原理

SGI STL 中的内存分配器( allocator )——目前设计最优秀的 C++ 内存分配器之一(stl默认的allocater)
STL内存管理使用二级内存配置器。
1、第一级配置器
第一级配置器以malloc(),free(),realloc()等C函数执行实际的内存配置、释放、重新配置等操作,一级空间配置器分配的是大于128字节(可以调整)的空间。
2、第二级配置器
在STL的第二级配置器中多了一些机制,避免太多小区块造成的内存碎片,小额区块带来的不仅是内存碎片,配置时还有额外的负担。区块越小,额外负担所占比例就越大。

内存池管理(memory pool),又称之次层配置(sub-allocation):每次配置一大块内存,并维护对应的16个空闲链表(free-list)。
这里的16个空闲链表分别管理大小为8、16、24......120、128B的数据块。空闲链表节点用了一个联合体,既可以表示空闲链表中下一个空闲数据块的地址,也可以表示已经被用户使用的数据块(不在空闲链表中)的地址。

当用户申请的空间小于128字节时,将字节数扩展到8的倍数,然后在自由链表中查找对应大小的子链表。
分配原则

版本1:

如果要分配的区块大于128bytes,则移交给第一级配置器处理。
如果要分配的区块小于128bytes,则在自由链表中查找。如果在自由链表查找不到或者块数不够,则向内存池进行申请,一般一次申请20块。如果内存池空间足够,则取出内存。

下次若有相同大小的内存需求,则直接从自由链表中取。如果有小额区块被释放,则由配置器回收到自由链表中。
如果不够分配20块,则分配最多的块数给自由链表,并且更新每次申请的块数
如果一块都无法提供,则把剩余的内存挂到自由链表,然后向系统heap申请空间,如果申请失败,则看看自由链表还有没有可用的块,如果也没有,则最后调用一级空间配置器

版本2:
1、空间配置函数allocate
首先先要检查申请空间的大小,如果大于128字节就调用第一级配置器,小于128字节就检查对应的空闲链表,如果该空闲链表中有可用数据块,则直接拿来用(拿取空闲链表中的第一个可用数据块,然后把该空闲链表的地址设置为该数据块指向的下一个地址),如果没有可用数据块,则调用refill重新填充空间。
2、空间释放函数deallocate
首先先要检查释放数据块的大小,如果大于128字节就调用第一级配置器,小于128字节则根据数据块的大小来判断回收后的空间会被插入到哪个空闲链表。
3、重新填充空闲链表refill
在用allocate配置空间时,如果空闲链表中没有可用数据块,就会调用refill来重新填充空间,新的空间取自内存池。缺省取20个数据块,如果内存池空间不足,那么能取多少个节点就取多少个。
从内存池取空间给空闲链表用是chunk_alloc的工作,首先判断内存池中的剩余空间是否足以调出nobjs个大小为size的数据块出去,如果内存连一个数据块的空间都无法供应,需要用malloc去堆中申请内存。
假如山穷水尽,整个系统的堆空间都不够用了,malloc失败,那么chunk_alloc会从空闲链表中找是否有大的数据块,然后将该数据块的空间分给内存池(这个数据块会从链表中去除)。

版本3:
1. 使用allocate向内存池请求内存空间,如果需要请求的内存大小大于128bytes,直接使用malloc。
2. 如果需要的内存大小小于128bytes,allocate根据size找到最适合的空闲链表。
  a. 如果链表不为空,返回第一个node,链表头改为第二个node。
  b. 如果链表为空,使用blockAlloc向内存池请求分配node。
    x. 如果内存池中有大于一个node的空间,分配尽可能多的node(默认最多20个),将一个node返回,其他的node添加到链表中。
    y. 如果内存池只有一个node的空间,直接返回给用户。
    z. 若果如果连一个node都没有,则把剩余的内存挂到空闲链表,用malloc去堆中申请内存。
      ①分配成功,再次进行b过程。
      ②分配失败,整个系统的堆空间都不够用,则只能循环各个空闲链表,寻找空间。
        I. 找到空间,将空间分给内存池(这个数据块会从链表中去除),再次进行过程b。
        II. 找不到空间,抛出异常。
3. 用户调用deallocate释放内存空间,如果要求释放的内存空间大于128bytes,直接调用free; 否则按照其大小找到合适的空闲链表,并将其插入。
——————————————————————————————————————————————————————

malloc、calloc、realloc、alloca

malloc:申请指定字节数的内存。申请到的内存中的初始值不确定。
calloc:为指定长度的对象,分配能容纳其指定个数的内存。申请到的内存的每一位(bit)都初始化为 0。如果你是为字符类型或整数类型的元素分配内存,那麽这些元素将保证会被初始化为0;如果你是为指针类型的元素分配内存,那麽这些元素通常会被初始化为空指针;如果你为实型数据分配内存,则这些元素会被初始化为浮点型的零。
realloc:更改以前分配的内存长度(增加或减少)。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,而新增区域内的初始值则不确定。
alloca:在栈上申请内存。程序在出栈的时候,会自动释放内存。但是需要注意的是,alloca 不具可移植性, 而且在没有传统堆栈的机器上很难实现。alloca 不宜使用在必须广泛移植的程序中。C99 中支持变长数组 (VLA),可以用来替代 alloca。

realloc扩容的原理:void *realloc(void *mem_address, unsigned int newsize)
(1)若新内存空间 newsize =0,则释放原有内存,返回NULL;
(2)若 newsize 不超过原有内存空间,则分配成功,按 newsize 分配新内存,返回原内存的首地址(返回类型为void*,且等于mem_address,原内存不释放??);
(3)若 newsize 超过原有内存空间,则判断原来的内存后面是否有足够的连续空间进行扩容:
  (a)如果有,扩容成功,扩大原来的内存,并且返回原来内存的首地址(返回类型为void*,且等于mem_address,原内存不释放);
  (b)如果原有空间不够扩容,则重新申请一块足够长的连续内存空间,将原有数据拷贝到新分配的内存区域,返回新分配内存区域的首地址,并释放原来的内存(返回类型为void*,但不等于mem_address);
  (c)若找不到足够长的连续内存空间来进行扩容,则扩容失败,返回 NULL(原来的内存不改变,不会释放也不会移动)。

注意:
(1)情况(1)和(b)会释放原来的内存,要注意是否需要将原指针置为NULL,避免出现野指针。
(2)由于情况(c)的存在,最好不要用 p = realloc ( p , newsize ) 这种用法,防止 p 变为 NULL,原内存空间的地址丢失,导致无法释放原内存空间而出现内存泄漏。
(3)释放内存后,原内存的内容可能会发生改变,处于不确定状态。

在c++中,NULL、'\0'和0的值是一样的,都是0。
———————————————————————————————————————————————————————
malloc/new和free/delete的区别

 (1)malloc与free是C++/C语言的标准库函数(#include<stdlib.h>),要库文件支持,new/delete是C++的运算符,不要库文件支持。

(2)new可以分为两步:先调用 operator new ,然后调用构造函数。其中 operator new 对应于 malloc,但operator new可以重载从而自定义内存分配策略,甚至不做内存分配,甚至分配到非内存设备上,而 malloc 不行。同样,delete 可以分为两步:先调用析构函数,然后调用 operator delete. 其中 operator delete 对应于 free,但operator delete 可以重载从而自定义内存回收策略,而 free 不行。
(3)new可以自动调用构造函数,malloc不能自动调用构造函数,也不能手动调用构造函数。若使用malloc创建对象,只能机械地分配一块内存而无法调用构造函数,需要配合placement new来真正地创建对象。(严格说来用malloc不能算是新建了一个对象,只能说是分配了一块与该类对象匹配的内存而已)
(4)new是类型安全的,而malloc不是。new返回的是直接带类型信息的指针,而malloc返回的都是void*指针,需要进行强制类型转换。
(5)new 在申请内存时会按照数据类型自动计算所需字节数,而 malloc 则需我们手动确定申请内存空间的字节数。
(6)new 如果分配失败默认会抛出 bad_malloc 的异常,而 malloc 失败了会返回NULL指针。

注意:

1)malloc分配的内存空间大小可以用 realloc 函数改变,new也可以通过realloc改变内存大小。

2)new 内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new 在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new 的语句也可以有多种形式。

3)如果用new 创建对象数组,那么只能使用对象的无参数构造函数。例如 
Obj *objects = new Obj[100]; // 创建100 个动态对象 
不能写成 
Obj *objects = new Obj[100](1);// 创建100 个动态对象的同时赋初值1 
在用delete 释放对象数组时,留意不要丢了符号‘[]’。例如 
delete []objects; // 正确的用法 
delete objects; // 错误的用法 
后者相当于delete objects[0],漏掉了另外99 个对象。

4)对于非内部数据类型的对象而言,光用malloc/free无法满足动态对象的要求。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。而new能完成动态内存分配和初始化工作,delete能完成清理与释放内存工作。

———————————————————————————————————————————————————————
free/delete只是把指针所指的内存给释放掉,但并没有把指针本身干掉。指针p被free以后其地址仍然不变(非NULL),只是该地址对应的内存是垃圾,p成了“野指针”。如果此时不把p设置为NULL,会让人误以为p是个合法的指针。

【野指针】:不是NULL指针,是指向“垃圾”内存的指针。
(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针。
(2)指针p被free或者delete之后,没有置为NULL。
(3)指针操作超越了变量的作用范围。

placement new(定位 new):在已分配的特定内存区域(这块内存可以由任意方式分配)创建对象。

placement new与new的区别:
(1)new是新申请内存,placement new是利用已分配的内存。
(2)语法形式不同。
(3)placement new既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象。new只能在堆上生成。
(4)由palcement new构造的对象在析构时,如果有析构函数则需要在释放时显式调用析构函数(不需要调用delete来释放内存)。new需要delete。

placement new的好处:
1)在已分配好的内存上进行对象的构建,构建速度快。
2)已分配好的内存可以反复利用,有效的避免内存碎片问题。
——————————————————————————————————————————————————————

一些内存概念

内存碎片:主要是指由于小块内存的动态频繁分配而出现的不可用空闲内存,分为内部碎片和外部碎片。(不清楚具体原理,最好不要提)

  • 内部碎片:已经被分配出去(能明确指出属于哪个进程)却不能被利用的内存空间。
  • 外部碎片:指还没有被分配出去(不属于任何进程),但由于太小了无法分配给申请内存空间的新进程的内存空闲区域。这些存储块的总和可以满足当前申请的长度要求,但是由于它们的地址不连续或其他原因,使得系统无法满足当前申请。

内存泄漏(memory leak):指不再使用的内存未能及时回收。可能产生内存泄漏的情况:

(1)堆内存泄漏。动态申请的内存没有被正确释放。malloc/new后没有正确地free/delete。new动态申请的数组使用完后,没有将全部数组元素delete。
(2)没有将基类的析构函数定义为虚函数。如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确地释放,因此造成内存泄露。
(3)自定义的placement new必须有对应的placement delete,否则在构造函数抛出异常的情况下,给对象分配的内存将泄露。
(4)在调用析构函数之前抛出异常。
(5)多线程中终止线程,但线程内部资源并未回收。
(6)shared_ptr环形引用。
(7-待商榷)广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET, Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。

注意:

1)文件流未关闭一般不会导致内存泄漏;

2)内存池的主要作用不是防止内存泄漏,反而内存池不容易通过valgrind这种工具检查内存泄漏。

内存越界:访问了可正常访问内存的边界。
内存溢出(out of memory):可以理解为内存不足,系统的内存空间无法满足申请要求。(包括堆上的或者栈上的)
缓冲区溢出:写入缓冲区的数据超过了缓冲区的容量,导致数据溢出到了其他内存空间,并覆盖了其他内存空间的数据。包括堆溢出和栈溢出。

内存溢出和缓冲区溢出的区别:内存溢出只是空间不足,不会污染其他区段的内存。缓冲区溢出会污染其他内存区段的数据,是一种安全攻击手段。

内存碎片问题的应对方法:
(1)根据使用场景定制内存分配策略(池、页)。
(2)提高系统内存,使其足够充沛。
(3)分布式系统(部分节点重启不影响整体系统的运行)。
(4)不要动态分配内存,尽量使用栈。
(5)不手动管理内存,改用具有分代内存管理、增量垃圾收集和内存整理功能的垃圾收集器(如Java、C#)。

防止内存泄漏的方法:(智能指针、RAII、虚析构函数)
(1)善用智能指针。
(2)良好的编程习惯(C的goto清理分支,C++利用RAII来做简单的资源管理)。
(3)析构函数尽量虚函数。
(4)如无必要,勿用指针。
(5)利用valgrind等内存泄漏检测工具来追踪内存使用,确保没有泄漏。(valgrind - linux环境下的内存泄漏检查工具)
(6)写代码时可以添加内存申请和释放的统计功能,统计当前申请和释放的内存是否一致,以此来判断内存是否泄露。

posted @ 2020-06-13 05:00  zicmic  阅读(174)  评论(0编辑  收藏  举报