尽力全面的C++内存管理

前言

内存管理是C++的核心之一,也是其优势所在。无论深入学习,抑或备考企业面试,基于C++的内存管理都是绕不开的话题。

但是,对于初级学习者而言,掌握这个知识点是相对困难的,因为大部分主流初级C++教材没有对这一内容单独进行系统的编排,导致知识点如天女散花般散落于各个章节,较难提炼。

基于此,作这篇文字,意图以清晰的思路、简洁可懂的语言配合一定的实例代码来展现C++内存管理的核心内容。文章篇幅较长,尽可能覆盖了内存管理的初级内容,某些科普性质的内容读者可以选择性忽略。

0. 管理内存的理由

内存管理是计算机编程领域的重要领域之一。在众多脚本语言中,不必担心内存如何管理,但这并不影响内存管理的重要性。在实际编程的过程中,对内存管理器的理解力至关重要。大部分系统语言,尤其是本文讨论的C以及C++,必须进行内存管理

回顾计算机编程语言,从使用原始的机器语言编程,到汇编语言时代,内存管理都并不复杂,理由很明了,因为在使用这些语言编程,实际上是在运行整个系统。系统的内存有多少,可供使用的内存就有多少。

在使用简单计算机时,如果对于内存的需要比较固定,那么只需要选择一个内存范围并使用它即可。但是,当不知道程序的每个部分需要多少内存时,就需要解决以下问题:

  1. 确定是否有足够的内存来处理数据;
  2. 从可用的内存中获取一部分内存
  3. 向内存池返回部分内存

实现以上问题的程序就被称为分配器(Allocator),它们负责分配和回收内存。程序的动态性越强,内存管理就越重要。

那么,我们就将从常见的操作系统出发,熟悉和掌握C尤其是C++管理内存的方法,最后回归内存硬件,探索整个内存管理的知识。

1. Windows内存管理策略

要理解内存在程序中是如何分配的,首先需要解释如何将内存从操作系统分配给程序。

作为普通计算机使用者日常接触最多的操作系统,Windows历经多年的迭代,形成了下图所示的一套以此为基础的内存管理策略:
在这里插入图片描述
Windows系统通过 TLB 将物理内存(Physical Memory)映射为连续的虚拟内存(Virtual Memory),同时提供了管理虚拟内存所使用的相关 API,诸如VirtualAlloc,VirutualFree等等。

在虚拟内存API上构建了堆内存的API,我们熟悉的C语言的内存管理策略就是通过malloc以及free构建在堆上的,同时,本文重点讨论的C++也是同样如此,详情见后文。

在使用VirtualAlloc分配内存时,每次只能分配页面大小整数倍的连续虚拟内存,页面大小通常默认为4KB。

附:物理内存 与 虚拟内存

计算机中的每一个进程都认为自己可以访问所有的物理内存。但是很明显,各个进程不可能占有全部内存。实际上,这些进程使用的是虚拟内存

操作系统维持了一个虚拟地址到物理地址的转换表,以便计算机硬件响应地址请求。

一个程序正在访问某个地址的内存,实际上虚拟内存不需要将这个程序存储在该地址的物理内存上,如果物理内存已满,那么虚拟内存会将其转移到硬盘上。这种不必反映内存所在物理位置的这类地址,被称为虚拟地址

当然如果这个进程所需空间大于内存的话,那么地址(就是虚拟内存)将会被分配在硬盘上。操作系统会暂停这个进程,将其他内存转存到硬盘上,从硬盘加载被请求的内存,使得进程能够拥有可以使用的地址空间。虚拟内存就是这样使得进程可以访问比物理安装的内存更多的内存的。

2. Linux内存管理策略

Linux系统没有采用分页机制,因此逻辑地址与虚拟地址是一个概念。在Linux内核中,虚拟地址与物理地址大多只相差一个线性偏移量,在用户空间上使用多页表进行映射。

在x86结构中,Linux内核的虚拟地址空间划分0至3G为用户空间,3至4G为内核空间,因此内核可以使用的线性地址只有1G.。而内核虚拟空间又划分为三个类型的分区:
ZONE_DMA : 3G之后的起始的16MB
ZONE_NORMAL : 16MB 至 896MB
ZONE_HIGHMEM : 896MB 至 1G

以上简单介绍的是Linux系统中的内存映射机制,在此基础上利用伙伴算法以及 slab 分配器解决系统外部碎片问题,借助 brk 或者 mmap 函数从用户空间申请连续内存。

附:brk() 和 mmap()

当一个进程被加载时,它将获得一个系统中断决定的特定地址的初始内存分配。通俗地说就是在内存上作一个标记,证明这个进程被加载,如果进程运行过程中超出了这部分初始内存,它就必须请求申请更多内存。

brk()
将系统中断确定的内存边界向前或者向后移动,以此来向进程添加或者取走内存。

mmap()
顾名思义就是memory map,内存映射的意思。它可以映射任何位置的内存,不仅可以将虚拟内存映射到物理内存中,还可以将它们映射到文件和文件位置,对文件进行读写

3. 内存对齐

内存对齐是C++管理内存的一个代表性的实例,即使在日常编程与实际应用中会较少地留意,但是通过研究这一机制可以让我们领会到内存管理的重要性。

3.1 内存对齐3原则

  1. 结构体变量的对齐值为其最宽成员的大小,变量的起始地址要能被对齐值整除;
  2. 结构体的每个成员相对于起始地址的偏移量被其自身对齐值整除,若不能则在最后一个成员后补充字节;
  3. 结构体总体大小被对齐值整除,若不能则在后面补充字节。

选用以下简单结构体作为我们讲解的例子:

struct Exp0
{
   
	char a;		//align = 1, compensate = 3
	int b;		//align = 4, compensate = 0
	short c;	//align = 2, compensate = 2
}

C++使用 alignof 函数就可以获取类型的对齐值。
上述结构体 Exp0 中,char 类型的对齐值为1,int 对齐值为4,short 对齐值为2。其中最宽成员大小为4,所以结构体的对齐值为4。

假设上述结构体变量的起始地址已经对齐,则第一个变量 a 也已经对齐。此时需要将第二个变量 b 相对于起始地址对齐,而 a 的大小仅为1,b 此时相对于起始位置的偏移量为1,不能被其自身对齐值 4 整除。为了满足原则 2 ,我们在最后一个变量 a 后补充填充3个字节使得 b 相对于起始地址的偏移量为4,完成对齐。

此时再考虑第3个变量c,此时结构体的大小为(1+3)+ 4 + 2 = 10,不能被4整除,因此根据原则 3 在结构体最后填充2个字节,此时结构体的大小为12,至此整个结构体对齐完成。

接下来,我们基于上述结构体,将其中成员顺序进行交换,再尝试进行对齐:

struct Exp1		//align = 4
{
   
	int b;		//align = 4, compensate = 0
	char a;		//aligh = 1, compensate = 1
	short c;	//aligh = 2, compensate = 0
}

在上述结构体Exp1中,结构体对齐值为4。假设变量 b 起始地址已经对齐,b 的对齐值为4,则变量 a 相对于起始地址的偏移量为4,可以被自身对齐值1整除。

考虑变量 c,此时 c 相对于起始地址的偏移量为5,不能被其自身的对齐值2整除,因此根据原则 2,在最后一个成员 a 后填充一个字节,此时变量 c 相对于起始位置的偏移量为6,满足了原则 2。最后考虑整个结构体,此时结构体大小为4+(1+1)+2 = 8,能被对齐值4整除,满足原则 3,不需要进行调整。

综上,根据简单的排列,这三个变量 a,b,c(假定他们类型不变)一共有6种排列方式,有的如Exp0需要占据12个字节,而有的如Exp1则仅占据8个字节,通过有意识地利用内存对齐的方式对结构体变量进行合理的排列,结构体大小会产生变化,可以起到节省内存占用的效果。

3.2 内存对齐对数据读取效率的影响

在3.1中,关于内存对齐可以“节省内存占用”的介绍中,我们都默认了第一个成员变量的起始地址已经对齐,实际上在现实中并非容易达到这样理想的效果,因此我们考虑如下情况:

在这里插1述
考虑一个double类型的数组 Array,已知其对齐值为8,它在内存中的位置如上图所示。

数组的起始地址为2,不符合原则 1要求的起始地址能被其对齐值整除,数组没有对齐。考虑到系统以及CPU每次从内存中以8字节的整数倍的地址读入8字节的数据,可以想见,若 Array 没有对齐,则每次读取 Array 中的一个成员,都需要CPU对内存进行两次访问才可完成,而 Array 若对齐的话,则仅需一次。因此内存对齐也可以有效提升内存数据读取的效率。

内存对齐小结

内存对齐的优点:

  1. 减少内存占用;
  2. 提升数据读取效率。

4. C++内存管理精髓

要说明如何进行C++的内存管理,一定绕不开new与delete,而new与delete的内容又建立在C++的指针之上,因此我们需要从指针来简单谈起。

计算机程序对于存储数据时会着重考虑以下几点:

  1. 数据存储位置;
  2. 存储的值;
  3. 存储数据的形式

以上三点我们往往通过定义一个简单的变量就可以实现。定义变量之时,变量的类型,变量的值,以及计算机会指定一个位置来存储这个变量。

当然,我们还非常熟悉另一种存储数据的方法,那就是指针。指针是一种存储地址值的特殊变量,通过读取其存储的地址值可以指向内存的另一个位置,从那个位置获取具体的数值。

指针的使用很好地反映了C++这门语言面向对象(OOP)进行编程的特质。相比于直接定义一个数组,通过定义指针的方式来定义数组,可以实现在进程中整数组大小从而实现节省资源的目的。

以上,我们回顾了指针的基本概念和用法,而指针的核心价值体现在程序实时运行中指针通过分配空闲内存来存储数据,此时指针成为了唯一可以触及内存的方式。在C语言中,这种操作使用库函数 malloc()即可实现。malloc() 库函数的实现相对复杂,将在另一篇文章中详细讲述。在C++中,我们同样可以使用 malloc(),但是C++拥有一个更好的途径:new。

4.1 new 操作符 & delete 操作符

4.1.1 使用new分配内存

int *pn = new int;

以上为使用 new 操作符的一个典型例子,使用者利用 new 告知内存需要的数据类型( int ),new 找到并返回合适大小的数据块的地址,将这个地址指向指针( pn )。

new 操作符的声明格式如下:

typeName *pointer_name = new typeName;	//New operator usage

其中数据类型 typeName 使用了两次,一次用于标明请求内存的种类,另一次是声明合适的指针。

new 操作符与常见定义的变量使用了内存的不同区域,使用 new 分配的内存被称为 (heap) 或者 自由存储区(free store)。

4.1.2 内存溢出?

在使用 new 操作符时,可以预见有时系统可能无法提供 new 请求的可用的内存空间。此时,new 一般会通过返回一个空指针( null ptr )来抛出故障,而较旧的版本则会返回一个值 0。

总之,C++提供了内存分配错误的检测和报错机制,而这已经包含在了 new 中。

4.2 使用delete释放内存

以上介绍的使用 new 操作符来申请内存只是C++内

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值