【做引擎开发,更需要深入 C++ 内存管理】【转载】

引言

说到 C++ 的内存管理,我们可能会想到栈空间的本地变量、堆上通过 new 动态分配的变量以及全局命名空间的变量等,这些变量的分配位置都是由系统来控制管理的,而调用者只需要考虑变量的生命周期相关内容即可,而无需关心变量的具体布局。这对于普通软件的开发已经足够,但对于引擎开发而言,我们必须对内存有着更为精细的管理。

基础概念

在文章的开篇,先对一些基础概念进行简单的介绍,以便能够更好地理解后续的内容。

内存布局

内存分布(可执行映像)

如图,描述了C++程序的内存分布。

Code Segment(代码区)

也称Text Segment,存放可执行程序的机器码。

Data Segment (数据区)

存放已初始化的全局和静态变量, 常量数据(如字符串常量)。

BSS(Block started by symbol)

存放未初始化的全局和静态变量。(默认设为0)

Heap(堆)

从低地址向高地址增长。容量大于栈,程序中动态分配的内存在此区域。

Stack(栈)

从高地址向低地址增长。由编译器自动管理分配。程序中的局部变量、函数参数值、返回变量等存在此区域。

函数栈

如上图所示,可执行程序的文件包含BSS,Data Segment和Code Segment,当可执行程序载入内存后,系统会保留一些空间,即堆区和栈区。堆区主要是动态分配的内存(默认情况下),而栈区主要是函数以及局部变量等(包括main函数)。一般而言,栈的空间小于堆的空间。

当调用函数时,一块连续内存(堆栈帧)压入栈;函数返回时,堆栈帧弹出。

堆栈帧包含如下数据:

① 函数返回地址

② 局部变量/CPU寄存器数据备份

函数压栈

全局变量

当全局/静态变量(如下代码中的x和y变量)未初始化的时候,它们记录在BSS段。

int x;
int z = 5;
void func()
{
     static int y;
}
int main()
{
    return 0;
}

处于BSS段的变量的值默认为0,考虑到这一点,BSS段内部无需存储大量的零值,而只需记录字节个数即可。

系统载入可执行程序后,将BSS段的数据载入数据段(Data Segment) ,并将内存初始化为0,再调用程序入口(main函数)。

而对于已经初始化了的全局/静态变量而言,如以上代码中的z变量,则一直存储于数据段(Data Segment)。

内存对齐

对于基础类型,如float, double, int, char等,它们的大小和内存占用是一致的。而对于结构体而言,如果我们取得其sizeof的结果,会发现这个值有可能会大于结构体内所有成员大小的总和,这是由于结构体内部成员进行了内存对齐。

为什么要进行内存对齐

① 内存对齐使数据读取更高效

在硬件设计上,数据读取的处理器只能从地址为k的倍数的内存处开始读取数据。这种读取方式相当于将内存分为了多个"块“,假设内存可以从任意位置开始存放的话,数据很可能会被分散到多个“块”中,处理分散在多个块中的数据需要移除首尾不需要的字节,再进行合并,非常耗时。

为了提高数据读取的效率,程序分配的内存并不是连续存储的,而是按首地址为k的倍数的方式存储;这样就可以一次性读取数据,而不需要额外的操作。

读取非对齐内存的过程示例

② 在某些平台下,不进行内存对齐会崩溃

内存对齐的规则

定义有效对齐值(alignment)为结构体中 最宽成员 和 编译器/用户指定对齐值 中较小的那个。

(1) 结构体起始地址为有效对齐值的整数倍

(2) 结构体总大小为有效对齐值的整数倍

(3) 结构体第一个成员偏移值为0,之后成员的偏移值为 min(有效对齐值, 自身大小) 的整数倍

相当于每个成员要进行对齐,并且整个结构体也需要进行对齐。

示例

struct A
{
    int i;
    char c1;
    char c2;
};

int main()
{
    cout << sizeof(A) << endl; // 有效对齐值为4, output : 8
    return 0;
}

内存排布示例

内存碎片

程序的内存往往不是紧凑连续排布的,而是存在着许多碎片。我们根据碎片产生的原因把碎片分为内部碎片和外部碎片两种类型:

(1) 内部碎片:系统分配的内存大于实际所需的内存(由于对齐机制);

(2) 外部碎片:不断分配回收不同大小的内存,由于内存分布散乱,较大内存无法分配;

内部碎片和外部碎片

为了提高内存的利用率,我们有必要减少内存碎片,具体的方案将在后文重点介绍。

继承类布局

继承

如果一个类继承自另一个类,那么它自身的数据位于父类之后。

含虚函数的类

如果当前类包含虚函数,则会在类的最前端占用4个字节,用于存储虚表指针(vpointer),它指向一个虚函数表(vtable)。

vtable中包含当前类的所有虚函数指针。

字节序(endianness)

大于一个字节的值被称为多字节量,多字节量存在高位有效字节和低位有效字节 (关于高位和低位,我们以十进制的数字来举例,对于数字482来说,4是高位,2是低位),微处理器有两种不同的顺序处理高位和低位字节的顺序:

● 小端(little_endian):低位有效字节存储于较低的内存位置

● 大端(big_endian):高位有效字节存储于较低的内存位置

我们使用的PC开发机默认是小端存储。

大小端排布

一般情况下,多字节量的排列顺序对编码没有影响。但如果要考虑跨平台的一些操作,就有必要考虑到大小端的问题。如下图,ue4引擎使用了PLATFORM_LITTLE_ENDIAN这一宏,在不同平台下对数据做特殊处理(内存排布交换,确保存储时的结果一致)。

ue4针对大小端对数据做特殊处理(ByteSwap.h)

操作系统

对一些基础概念有所了解后,我们可以来关注操作系统底层的一些设计。在掌握了这些特性后,我们才能更好地针对性地编写高性能代码。

SIMD

SIMD,即Single Instruction Multiple Data,用一个指令并行地对多个数据进行运算,是CPU基本指令集的扩展。

例一

处理器的寄存器通常是32位或者64位的,而图像的一个像素点可能只有8bit,如果一次只能处理一个数据比较浪费空间;此时可以将64位寄存器拆成8个8位寄存器,就可以并行完成8个操作,提升效率。

例二

SSE指令采用128位寄存器,我们通常将4个32位浮点值打包到128位寄存器中,单个指令可完成4对浮点数的计算,这对于矩阵/向量操作非常友好(除此之外,还有Neon/FPU等寄存器)

SIMD并行计算

高速缓存

一般来说CPU以超高速运行,而内存速度慢于CPU,硬盘速度慢于内存。

当我们把数据加载内存后,要对数据进行一定操作时,会将数据从内存载入CPU寄存器。考虑到CPU读/写主内存速度较慢,处理器使用了高速的缓存(Cache),作为内存到CPU中间的媒介。

L1缓存和L2缓存

引入L1和L2缓存后,CPU和内存之间的将无法进行直接的数据交互,而是需要经过两级缓存(目前也已出现L3缓存)。

① CPU请求数据:如果数据已经在缓存中,则直接从缓存载入寄存器;如果数据不在缓存中(缓存命中失败),则需要从内存读取,并将内存载入缓存中。

② CPU写入数据:有两种方案,(1) 写入到缓存时同步写入内存(write through cache) (2) 仅写入到缓存中,有必要时再写入内存(write-back)

为了提高程序性能,则需要尽可能避免缓存命中失败。一般而言,遵循尽可能地集中连续访问内存,减少”跳变“访问的原则(locality of reference)。这里其实隐含了两个意思,一个是内存空间上要尽可能连续,另外一个是访问时序上要尽可能连续。像节点式的数据结构的遍历就会差于内存连续性的容器。

虚拟内存

虚拟内存,也就是把不连续的物理内存块映射到虚拟地址空间(virtual address space)。使内存页对于应用程序来说看起来是连续的。一般而言,出于程序安全性和物理内存可能不足的考虑,我们的程序都会运行在虚拟内存上。

这意味着,每个程序都有自己的地址空间,我们使用的内存存在一个虚拟地址和一个物理地址,两者之间需要进行地址翻译。

缺页

在虚拟内存中,每个程序的地址空间被划分为多个块,每个内存块被称作页,每个页的包含了连续的地址,并且被映射到物理内存。并非所有页都在物理内存中,当我们访问了不在物理内存中的页时,这一现象称为缺页,操作系统会从磁盘将对应内容装载到物理内存;当内存不足,部分页也会写回磁盘。

在这里,我们将CPU,高速缓存和主存视为一个整体,统称为DRAM。由于DRAM与磁盘之间的读写也比较耗时,为了提高程序性能,我们依然需要确保自己的程序具有良好的“局部性”——在任意时刻都在一个较小的活动页面上工作。

分页

当使用虚拟内存时,会通过MMU将虚拟地址映射到物理内存,虚拟内存的内存块称为页,而物理内存中的内存块称为页框,两者大小一致,DRAM和磁盘之间以页为单位进行交换。

简单来说,如果想要从虚拟内存翻译到物理地址,首先会从一个TLB(Translation Lookaside Buffer)的设备中查找,如果找不到,在虚拟地址中也记录了虚拟页号和偏移量,可以先通过虚拟页号找到页框号,再通过偏移量在对应页框进行偏移,得到物理地址。为了加速这个翻译过程,有时候还会使用多级页表,倒排页表等结构。

置换算法

到目前为止,我们已经接触了不少和“置换”有关的内容:例如寄存器和高速缓存之间,DRAM和磁盘之间,以及TLB的缓存等。这个问题的本质是,我们在有限的空间内存储了一些快速查询的结构,但是我们无法存储所有的数据,所以当查询未命中时,就需要花更大的代价,而所谓置换,也就是我们的快速查询结构是在不断更新的,会随着我们的操作,使得一部分数据被装在到快速查询结构中,又有另一部分数据被卸载,相当于完成了数据的置换。

常见的置换有如下几种:

● 最近未使用置换(NRU)

出现未命中现象时,置换最近一个周期未使用的数据。

● 先入先出置换(FIFO)

出现未命中现象时,置换最早进入的数据。

● 最近最少使用置换(LRU)

出现未命中现象时,置换未使用时间最长的数据。

C++语法

位域(Bit Fields)

表示结构体位域的定义,指定变量所占位数。它通常位于成员变量后,用 声明符:常量表达式 表示。(参考资料)

声明符是可选的,匿名字段可用于填充。

以下是ue4中Float16的定义:

struct
{
#if PLATFORM_LITTLE_ENDIAN
    uint16 Mantissa : 10;
    uint16 Exponent : 5;
    uint16 Sign : 1;
#else
    uint16 Sign : 1;
    uint16 Exponent : 5;
    uint16 Mantissa : 10;   
#endif
} Components;

new和placement new

new是C++中用于动态内存分配的运算符,它主要完成了以下两个操作:

① 调用operator new()函数,动态分配内存。

② 在分配的动态内存块上调用构造函数,以初始化相应类型的对象,并返回首地址。

当我们调用new时,会在堆中查找一个足够大的剩余空间,分配并返回;当我们调用delete时,则会将该内存标记为不再使用,而指针仍然执行原来的内存。

new的语法

::(optional) new (placement_params)(optional) ( type&nb
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值