程序员的自我修养 - 内存

一、程序的内存布局

这里写图片描述

重点关注dynamic libraries,它用于映射装载的动态链接库。

二、栈与调用惯例

1. 栈

栈保存了函数调用所需要的维护信息,通常被称为栈帧(stack frame)或活动记录(active record)。栈帧通常包含以下信息:

  • 函数的返回地址和参数
  • 临时变量:函数内的非静态变量及编译器自动生成的临时变量
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器

在i386中,一个栈帧用esp和ebp两个寄存器划定范围:esp指向栈帧顶部,ebp指向调用该函数前的ebp的值,这样就可以在函数返回时,ebp可以读取这个值来恢复到调用前的值。

疑问:由于栈增长方向从高地址到低地址,因此,ebp指向的位置是不是应该在Old EBP和保存的寄存器之间才对?
这里写图片描述

之所以函数的活动记录会形成如上结构,是因为函数调用本身是如此书写的,以i386以例,函数调用总是:

  • 把所有或部分参数压入栈,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
  • 把当前指令的下一条指令的地址压入栈
  • 跳到函数体执行

以上步骤1和2,与图10-4的活动记录的参数和返回地址对应。步骤2和3由call指令一起执行,步骤3中的函数体的标准开头为:

  • ebp入栈,对应10-4的Old EBP
  • esp的值赋给ebp
  • 分配空间,并将寄存器值保存到已分配空间,对应10-4中的保存的寄存器

2. 调用惯例

调用方与被调用方要遵循约定的规则,这种规则就叫做调用惯例。规定涉及:

  • 函数参数的入栈顺序
  • 函数调用结束后由谁负责将数据弹出栈
  • 函数名称修饰符
    这里写图片描述

对c++而言,上述的名字修饰显然不能满足重载和命名空间的需求,因此c++有更加复杂的名字修饰策略。
此外,c++还用特殊的调用惯例:thiscall,专门用于成员函数的调用。其特点随编译器不同而不同:

  • VC里,this指针存放于ecx寄存器,参数由右至左入栈
  • gcc里,thiscall与cdecl完全一样,只是将this看作函数的第一个参数

3. 函数返回值传递

这里不举例,只给出结论,有兴趣的可以翻阅P299

  • (0,4]字节的返回值,由eax传递:即被调函数将返回结果存入eax中,调用函数读取eax内容得到返回值
  • (4,8]字节的返回值,由eax和edx联合返回:eax存放低4字节,edx存放高4字节
  • 8以上字节的返回值,eax存储返回值起始地址:栈上分配返回值所需字节的空间,将空间起始地址赋值给eax

三、堆与内存管理

malloc/new是如何实现堆空间分配的呢?

  • 方法1:把进程的内存管理交给操作系统的内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让程序使用这个系统调用申请内存,不就可以了吗?当然这是理论上可行的方法,但实际上性能很差,因为每次程序申请或释放堆空间都需要进行系统调用,而系统调用的性能开销很大(开销大的原因详见《程序员的自我修养 - 系统调用与API》),当程序频繁的申请或释放堆空间时,将会严重影响程序的性能。
  • 方法2:程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间。具体而言,管理着堆空间分配的往往是程序的运行库。运行库相当于向操作系统“批发”了一块较大的堆空间,然后“零售”给程序用。当使用“售完”或程序有大量的内存需求时,再根据需求向操作系统“进货”。当然运行库向程序“零售”堆空间时,必须管理“批发”来的空间,这类管理算法叫堆空间分配算法。

根据堆空间管理的顺序:批发 - 零售,先讲解批发的原理。

堆批发原理

1. linux进程堆管理

1.1 brk / sbrk
  • brk实际上通过将数据段(data与bss段统称为数据段)的结束地址往高地址移动,使得扩大的那部分数据段的空间可被当作堆空间使用。(疑问:通过增大数据段空间来增大可用堆空间?感觉很别扭!
  • sbrk以增量作为参数,即需要增加或减少的空间大小,返回值是增加或减少后数据段的结束地址,它实际上是对brk的包装。
1.2 mmap

mmap与windows的VirtualAlloc相似,它的作用是向操作系统申请一段虚拟地址空间,这段空间可以映射到某个文件,当它不被映射到文件时称为匿名空间,而匿名空间可以被当作堆空间

但mmap申请的空间的起始地址和空间大小都必须是系统页大小的整数倍,对于字节很小的请求无疑是一种浪费。

glibc的malloc函数是这样处理用户请求的:

  • 对于小于128KB的请求来说,它会在现有堆空间里按照堆空间分配算法分配一块空间并返回
  • 对于大于128KB的请求来说,它会先通过mmap申请匿名空间,再从匿名空间中分配空间并返回

最后,分析一下32位系统中,可分配的堆空间大小。
从图10-1中观察到(注意:kernel version为2.4.x),dynamic libraries从0x40000000开始往高地址增长,使得实际可申请的最大堆空间只有2GB,而在2.6版本的内核中,dynamic libraries已经被挪到靠近栈空间的位置,即位于0xbf******附近。

除了空间布局以外,还有诸多因素可以影响到malloc最大空间大小,如系统的资源限制(ulimit),物理内存和交换空间的总和等。mmap申请的匿名空间的大小不可能超过物理内存和交换空间的总和!!!

2. windows进程堆管理

要分析堆管理,必须先搞清楚windows进程空间地址分布,如下:
这里写图片描述

简单解释一下上图为何会有那么多栈。
我们知道,每个线程的栈都是独立的,所以一个进程可以有多个线程,就对应多个栈。

windows用VirtualAlloc来向操作系统申请空间(不仅仅用作堆,可视需求而定),与mmap一样,它申请到的空间的起始地址及大小都必须是系统页大小的整数倍。为了提高堆的使用效率,windows提供了堆管理器,它对应一组API,用于创建堆、在堆内分配内存、释放已分配的内存及销毁堆。

从图10-14可看出,进程地址空间较为零碎,因此可分配的最大堆空间取决于最大连续空间的大小,图中对应的最大堆为heap5。

堆零售原理(堆空间分配算法)

在运行库完成了从操作系统批发堆空间的动作后,就需要根据用户程序的需求分配已批发到的空间。下面就简单介绍运行库是如何管理并分配堆空间的。

1. 空闲链表法

这里写图片描述

用双向链表将所有空间链接串连起来,同时若用户申请了K个字节的内存大小,则分配K+4个字节的空间,额外的4个字节位于空间的起始位置,用来指示K的大小。

2. 位图法

将堆划分个固定大小的块,每块大小相同。当用户请求内存时,总是分配整数块的空间,第一个块称为已分配区域的头(head),其余称为分配区域的主体(body)。
这里写图片描述
(H : Head B : Body F : Free)

3. 对象池

在实际应用中,被分配的堆对象的大小是较为固定的几个值,这时候就可以针对这样的特征设计一个更为高效的堆算法,称为对象池。

对象池的思路很简单,如果每次分配的空间大小都一样(假设为K字节),那么就可以以此空间大小作为一个单位,把整个空间划分为大量的K字节大小的块,每次请求时只需要找到一个小块就可以。

对象池的管理方法可以是空闲链表,也可以用位图。

以上介绍了三种堆空间分配算法,实际应用时采用复合算法。比如对于glibc来说

  • 对于小于64字节的空间申请,采用类似对象池的算法
  • 对于大于512字节的空间申请方法,采用最佳适配算法
  • 对于大于64字节小于512字节的空间申请,采用上述的最佳折衷策略(所以到底是怎么个折衷法)?
  • 对于大于128KB的申请,使用mmap机制
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值