程序员的自我修养十内存
内存是承载程序运行的介质,也是程序进行各种运算和表达的场所。
10.1 程序的内存布局
现代的应用程序都运行在一个内存空间里,在32位系统里,这个内存空间拥有4GB(2的32次方)的寻址能力。现在的应用程序可以直接使用32位地址进行寻址,这被称为平坦的内存模型。在平坦的内存模型中,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意的内存位置。
大多数操作系统都会将4GB内存空间中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。
应用程序使用内存空间有如下”默认”的区域:
- 栈:用于维护函数调用的上下文,离开了栈函数调用就没办法实现
- 堆:堆是用来容纳应用程序动态分配内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。
- 可执行文件映像:这里存储着可执行文件在内存里的映像,有装载器在装载时将可执行文件的内存读取或映射到这里。
- 保留区:不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域总称。
10.2 栈与调用惯例
10.2.1 什么是栈
栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但栈这个容器必须遵守一条规格:先入栈的数据后出栈。
栈是一个具有上面属性的动态内存区域。压栈操作使得栈增大,而弹出操作使栈减小。
在经典操作系统中,栈总使向下增长的。
栈保存一个函数调用所需要的维护信息,这常常被称为堆栈帧或活动记录。
堆栈帧一般包括如下几方面:
- 函数返回地址和参数
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
在一个i386下的函数总是这样调用的:
- 把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
- 把当前指令的下一条指令的地址压入栈中。
- 跳转到函数体执行
一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也就是指向当前函数的活动记录的顶部。ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针。
ebp固定在图中所示的位置,不随这个函数的执行而变化。esp始终指向栈顶,因此随这函数的执行,esp会不断变化。固定不变的ebp可以用来定位函数活动记录中的各个数据。
10.2.2 调用惯例
函数的调用方和被调用方对于函数如何调用须要有一个明确的约定,这样的约定就是调用惯例。
一个调用惯例有如下几方面:
- 函数参数的传递顺序和方式:最常见的一种是通过栈传递。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序:是从左至右,还是从右至左。
- 栈的维护方式:在函数将参数压栈之后,函数体会被调用,此后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。
- 名字修饰:为了链接的时候堆调用惯例进行区分,调用管理要对函数本身的名字进行修饰。
多级调用栈布局:
10.2.3 函数返回值传递
除了参数传递之外,函数与调用方的交互好友一个渠道就是返回值。eax是传递返回值的通道。函数将返回值存储在eax中,返回后函数的调用方在读取eax。对于大于4字节的返回值,采用eax和edx联合返回的方式进行。
如果返回值太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。
10.3 堆和内存管理
堆这片内存面临一个复杂的行为模式:在任意时刻程序可能发出请求,申请一段内存或者释放一段已经申请了的内存,而且申请的大小从几个字节到数GB都可能,我们不能假设程序会一次申请多少空间,所以,堆的管理比较复杂
10.3.1 什么是堆
栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。全局变量没有办法动态产生数据,只能在编译器的时候定义。这这种情况下,堆是唯一选择。
堆是一块巨大的内存空间,常常占据了整个虚拟空间的绝大部分。
10.3.2 Linux进程堆管理
Linux下进程堆管理有两种分配方式,即两个系统调用:
- brk()系统调用:实际上就是设置进程数据段的结束地址,它可以扩大或者缩小数据段。
- mmap()系统调用:作用和windows下的VirtualAlloc相似,作用是向操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件,当它不将地址空间映射到某个文件时,我们称做为匿名空间,它可以拿来作为堆空间。
10.3.3 Windows进程堆管理
Windows的进程将地址分配给了各种EXE、DLL、堆、栈。
每个线程默认栈的大小是1MB,在线程启动时,系统就会为它的进程地址空间中分配相应的地址空间作为栈,线程栈的大小可以由创建时CreatThread的参数指定。
Windows提供一个API叫做VirtualAlloc(),用来向系统申请空间,它要求空间大小必须为页的整数倍。
堆分配算法在的实现位于堆管理器,堆管理器提供了一套与堆相关的API用来创建、分配、释放和销毁堆空间
- HeapCreate:创建一个堆
- HeapAlloc:在一个堆中分配内存
- HeapFree:释放已经分配的内存
- HeapDestroy:摧毁一个堆
每个进程在创建时都会有一个默认堆,这个堆在进程启动时创建,并且直到进程结束都一直存在。默认堆大小为1MB。一个进程中一次性能够分配的最大堆空间取决于最大的那个堆。
10.3.4 堆分配算法
如何管理一大块连续的内存空间,能够按照需求分配、释放其中的空间,这就是堆算法。
空间链表
空闲链表实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的块并且将它拆分,当用户释放空间时将它合并到空闲链表中。
空闲链表时这样一种结构,在堆里的每个空闲空间的大小(或结尾)有一个头(Header),头结构里记录了上一个(prev)和下一个(next)空闲块的地址。所有的空闲块形成一个链表。
位图
核心思想:将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总时分配整数个块给用户,第一个块我们称为已分配区域的头,其余的称为已分配区域的主体。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,所以仅仅需要2为即可表示一个块,所以称为位图。
优点:
- 速度快:由于整个堆的空闲信息存储在一个数组内,所以访问数组时cache容易命中。
- 稳定性好:为了避免用户越界读写破坏数据,我们只需简单的备份一下位图即可,而且即使部分数据被破坏,也不会导致整个堆无法工作。
- 块不需要额外信息,易于管理。
缺点:
- 分配内存时容易产生碎片。
- 如果堆很大,或者设定的一个块很小,那么位图将会很大,可能失去cache命中率高的优势,也会浪费一定的空间。
对象池
思路:如果每次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候,只需要找到一个小块就可以了。
对象池的管理方法可以采用空闲链表,也可以采用位图,与它们的区别仅仅在于它假定了每次请求的都是一个固定大小,因此实现起来很容易。由于每次总是只请求一个单位内存,因此请求得到满足的速度非常块,无须查找一个足够大的空间
如本文对您有帮助请移步右下角,推荐本文,谢谢大家的点赞,因为您的支持是我最大动力