参数解释:
fd
,start
,length
,offset
:mmap
函数要求内核创建一个新的虚拟存储器区域,最好是从地址start
开始的一个区域,并将文件描述符fd
指定的对象的一个连续的片chunk
映射到这个新的区域。
length
字节offset
字节的地方开始。statr
地址仅仅是个暗示
NULL
,让内核自己安排。prot
参数prot
包含描述新映射的虚拟存储器区域的访问权限位
。(对应区域结构中的vm_prot
位)
PROT_EXEC
:这个区域内的页面由可以被CPU执行的指令组成。PROT_READ
:这个区域内的页面可读。PROT_WRITE
: 这个区域内的页面可写。PROT_NONE
: 这个区域内的页面不能被访问。flag
参数flag
由描述被映射对象类型的位
组成。
MAP_ANON
标记位:映射对象是一个匿名对象
。MAP_PRIVATE
标记位:被映射对象是一个私有
的,写时拷贝
的对象。MAP_SHARED
标记位:被映射对象是一个共享
对象。bufp = mmap(NULL,size,PROT_READ,MAP_PRIVATE|MAP_ANON,0,0);
bufp
包含新区域地址。munmap
函数删除虚拟存储器的区域:虽然可以使用更低级的mmap
和munmap
函数来创建和删除虚拟存储器的区域。
但是C程序员还是觉得用动态存储器分配器(dynamic memory allocator)
更方便。
动态存储器分配器
维护着一个进程的虚拟存储区域,称为堆(heap)
。
堆
是一个请求二进制零的区域。bss
区域,并向上生长(向更高的地址)。brk(break)
,指向堆顶。分配器
将堆
视为一组不同大小的块block
的集合来维护。
每个块
就是一个连续的虚拟存储器片
,即页面大小。已分配
,要么是空闲
。
已分配
已分配的块
显式地保留供应用程序使用。已分配
的块保持已分配状态,直到它被释放
。
释放
要么是应用程序显示执行。隐式
执行(JAVA)。空闲
空闲块
可用于分配。空闲快
保持空闲,直到显式地被应用分配。分配器
有两种基本分格。
显式
分配。显式分配器(explict allocator)
释放
。malloc
程序显示分配器。
malloc
和free
new
和delete
隐式分配器(implicit allocator)
分配器
检测一个已分配块何时不再被程序所使用,那么就释放
这个块。隐式分配器
又叫做垃圾收集器(garbage collector)
.
垃圾收集(garbage collection)
.Lisp
,ML
以及Java
等依赖这种分配器。本节剩余的部分讨论的是显示分配器
的设计与实现。
C标准库提供了一个称为malloc
程序包的显示分配器
。
#include
void* malloc(size_t size);
返回:成功则为指针,失败为NULL
malloc
返回一个指针,指向大小为至少size
字节的存储器块。
size
字节,很有可能是4
或8
的倍数
块
会为可能包含在这个块
内的任何数据对象
类型做对齐。Unix
系统用8
字节对齐。malloc
不初始化它返回的存储器。
calloc
函数。
calloc
是malloc
一个包装函数。realloc
h函数malloc
遇到问题。
NULL
, 并设置errno
。动态存储分配器
,可以通过使用mmap
和munmap
函数,显示分配和释放堆存储器。
或者可以使用sbrk
函数。
#include
void *sbrk(intptr_t incr);
返回:若成功则为旧的brk指针,若出错则为-1,并设置errno为ENOMEML.
sbrk
函数通过将内核的brk
指针增加incr
(可为负)来收缩和扩展堆。程序通过调用free
函数来释放已分配的堆块。
#include
void free(void *ptr);
返回:无
ptr
参数必须指向一个从malloc
,calloc
,realloc
获得的已分配块的起始位置。
free
行为未定义。free
没有返回值,不知道是否错了。这里的字=4字节,且malloc是8字节对齐。
程序使用动态存储器分配的最重要原因是:
显式分配器有如下约束条件
缓冲
请求。堆
。块
。
8
字节。吞吐率最大化
和存储器使用率
最大化。这两个性能要求通常是相互冲突的。
最大化吞吐率
R1,R2,R3.....Rn
吞吐率 :
每个单位时间完成的请求数。分配和释放请求
的平均时间最小化 来最大化吞吐率最大化存储器利用率
需要增加分配和释放请求的时间。
评估使用堆
的效率,最有效的标准是峰值利用率(peak utilization)
R1,R2,R3.....Rn
有效载荷(payload)
:如果一个应用程序请求一个p
字节的块,那么得到的已分配块
的有效载荷
是p
字节。(很有可能会分配p+1
个字节之类的)聚集有效载荷(aggregate payload)
:请求Rk
完成之后,Pk
表示当前已分配块的有效载荷
之后。又叫做聚集有效载荷
。Hk
表示堆的当前的大小(单调非递减的)。Uk
吞吐率
和存储器利用率
是相互牵制的,分配器
设计的一个有趣的挑战就是在两者之间找到一个平衡。造成堆利用率很低的主要原因是一种称为碎片(fragmentation)
的现象。
碎片
:虽然有未使用的存储器但不能满足分配要求时的现象。
内部碎片
:已分配块比有效载荷(实际所需要的)大时发生。
碎片
.内部碎片
的数量取决于以前请求
的模式和分配器的实现方式。
外部碎片
:当空闲存储器合计
起来足够满足一个分配请求,但是没有一个单独
的空闲块足够大可以处理这个请求发生的。
外部碎片
的量化十分困难。
请求
的模式和分配器的实现方式,还要知道将来请求
的模式。启发式策略
来用少量的大空闲块替换大量的小空闲块。一个实际的分配器要在吞吐率
和利用率
把我平衡,必须考虑一下几个问题。
9.9.6
)9.9.7
)9.9.8
)堆块
(十分巧妙的利用了本该永远为0的低三位):
块
由一个字的头部
,有效载荷
,以及可能的填充
组成。
头部
:编码了这个块
的大小(包括头部和填充),以及这个块
是否分配。
8字节
的对齐约束条件
0
。0~2^32
(只是必须是8的倍数),非0~2^29
。是否分配
之类的信息。将堆
组织为一个连续的已分配块
和空闲块
的序列。
这种结构就叫做隐式空闲链表
隐式
:
隐式链表
。
next
)来链接起来。头部
的长度隐含地链接起来。终止头部
(类似与普通链表的NULL
)
已分配
,大小为零
的块优缺点
:
开销
都与已分配块和空闲块的总数呈线性关系O(N)
.
字节
,也会分配2
个字
的块。空间浪费。当应用请求k
字节的块,分配器搜索空闲链表,查找一个足够大可以放置请求的空闲块。
有一下几种搜索放置策略
首次适配
从头开始
搜索空闲链表,选择第一个合适的空闲块。下一次适配
首次适配
很类似,但不是从头开始,而是从上一次查询
的地方开始。最佳适配
优缺点
首次适配
较大快
的搜索时间。下一次适配
存储器利用率
低最佳适配
分离式空闲链表
。两种策略
所有
空闲块
内部碎片
(但是如果内部碎片很少,可以接受)搜索速度
。外部碎片
(几个字节,很有可能是外部碎片)可能根本放置不了东西,但是却占用了搜索时间,还不如当内部碎片算了空闲块
,内部碎片也很少。如果分配器
不能为请求块找到合适的空闲块
将发生什么?
合并
相邻的空闲块(下一节描述)。sbrk
函数
大的空闲块
假碎片
: 因为释放
,使得某些时候会出现相邻的空闲块。
碎片
),合并却可以(假性
),所以叫假碎片
。重要的决策决定,何时执行合并?
立即合并
块
被释放时,合并所有相邻的块。抖动
。推迟合并
在对分配器的讨论中,我们假设使用立即合并
。
但要知道,快速的
分配器通常会选择某种形式的推迟合并
。
Q
:释放当前块
后,如果要合并下一个
块是十分简单,但是合并上一块
复杂度却很高。
A
:Knuth
提出边界标记
。
头部
的副本。其实就是双向链表
啦。
小块
时,产生明显的存储器开销
。Q
: 如何解决这种开销
。
A
: 使用边界标记
优化方法.
已分配/空闲位
存放到当前块多出来的低位(000
)中。
分配/空闲
已分配
的,不需要处理。
已分配
的不需要脚部。未分配
的,需要处理。
未分配
的依旧需要脚部。十分优美的优化。
基于隐式空闲链表
,使用立即边界标记合并
方式,从头到尾讲述一个简单分配器的实现。
序言块
8
字节的已分配块。普通块
malloc
和free
使用结尾块
序言块和结尾块都是用来消除合并边界条件的小技巧。
隐式空间链表
就是一个玩具而已,用来介绍基本分配器
概念。对于实际应用,还是太简单。
根据定义,程序并不需要一个空闲块
的主体。所以可以将空闲块
组织成一种显式数据结构。
O(块总数)
降低到O(空闲块总数)
。释放
块时可能是线性,也可能是常数(普通的是常数)
后进先出(LIFO)
策略
地址优先
内部碎片
程度。分离存储
: 维护多个空闲链表,其中每个链表中的块有大致相等的大小。
大小类(size class)
。
大小类
。
{1},{2},{3,4},{5~8},...{1025~2048},{2048~+oo}
.{1},{2},{3},{4},{5},{6},...{1025~2048},{2048~+oo}.
有关动态存储分配的文献描述了几十种
分离存储方法。
我们介绍两种基本的方法
简单分离存储(simple segregated storage)
和分离适配(segregated fit)
。大小类
大小类
的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。
{17~32}
中,这个类的空闲链表全是32
的块。空闲链表
额外存储器片
,将这个片分割,然后加入对应的链表。
常数级
释放
即可,然后分配器将释放
后的块直接插入空闲链表
。常数级
缺点
内部碎片
和外部碎片
分配器维护着一个空闲链表
的数组。
空闲链表
是和一个大小类相关联的,并且被组织称某种类型的显示或隐式链接。链表
包含潜在的大小
不同的块。
大小
是大小类
的成员。有许多种不同的分离适配分配器,这里介绍一个简单版本。
空闲链表
做首次适配。
分割
它。空闲链表
。空闲链表
释放
一个块,并执行合并,存入相应的空闲链表
。分离适配方法
是一种常见的选择,C标准库提供的GUN malloc
包就是采用的这种方法。
利用率
高
分离空闲链表
简单的首次适配
搜索,其存储器利用率
近似对堆的最佳适配搜索
。伙伴系统(buddy system)
是分离适配的一种特例,其中每个大小类都是2的幂。
2^m
请求块
大小向上舍入到最接近的2的幂,假设为2^k
。2^j
,满足(k<=j<=m
)2^(j-1)
和 2^(j-1)
两部分,其中半块丢入空闲链表中。
伙伴
。j=k
。O(log(m))
,很低如何释放,合并
合并
。
伙伴
处于空闲就不断合并
,否则就停止。O(log(m))
,很低。伙伴系统
分配器的主要
内部碎片
。通用目的
的工作负载。2的幂
的系统,伙伴系统
分配器就很有吸引力。垃圾收集器(garbage collector)
是一种动态存储分配器。
垃圾
: 它自动释放不再需要的已分配块,这些块称为垃圾(garbage)
.垃圾收集(garbage collection)
:自动回收堆存储的过程叫做垃圾收集
。
显式
分配堆块,但从不显式
释放堆块。垃圾收集器
定期识别垃圾快,并调用相应地free,将这些快放回空闲链表。垃圾收集
可以追溯到John McCarthy
在20实际60年代早期在MIT开发的Lisp
系统。
Java
,ML
,Perl
和Mathematic
等现代语言系统的一个重要部分。垃圾收集
方法,数量令人吃惊。McCarthy
自创的Mark&Sweep(标记&清除)
算法。
malloc
包的基础上,为C和C++提供垃圾收集。垃圾收集器
将存储器视为一张有向可达图
。
根结点
和一组堆结点
堆结点
对应于堆中一个已分配的块。根结点
对应于这样一种不在堆中的位置。
指针
,寄存器
,栈里的变量
,或者是虚拟存储区域中读写数据区域中的全局变量
p->q
意味着块p
中的某个位置指向块q
中的某个位置
指针
。根结点
出发到达p
的有向路径时。
p
是可达
的。不可达的
,不可达
结点对应于垃圾。垃圾收集器
的角色是维护可达图
的某种表示,并释放不可达结点返回给空闲链表。
ML
和Java
这样的语言的垃圾收集器,对应用如何创建和使用指针都有严格的控制。
C
和 C++
通常不能维护可达图的一种精确表示。这样的收集器叫做保守的垃圾收集器
保守
: 每个可达块都被标记为可达块,但有些不可达块也被标记为可达块。指针
由自己管理,系统无法判定数据是否为指针,那么就不好精确的遍历。如果malloc
找不到合适的空闲块,就会调用垃圾收集器
。回收一些垃圾到空闲链表。
free
。Mark&Sweep
垃圾收集器由标记(mark)
阶段和清除(sweep)
阶段
标记
阶段:标记出根结点的所有可达和已分配的后继。清除
阶段:后面的清除阶段释放每个未被标记的已分配块。
头部
的低位的一位用来表示是否被标记
。标记
的算法 就是从根结点开始,对结点的指针数据
深搜并标记。
isPtr()
来判断是否是指针,p
是否指向一个分配块的某个字。
起始位置
。清除
的算法 就是遍历图,然后释放未被标记的。
Mark & Sweep
(很有意思的一小节,败也指针)C语言的isPtr()
的实现有一些有趣的挑战。
C
不会用任何类型信息来标记存储器位置。
p
是不是一个指针。
java
等语言里面,指针全部由系统管理。isPtr()
也没没有明显的方式判断p
是否指向一个已分配块的有效载荷的某个位置。
解决方法: 将已分配块
维护成一颗平衡二叉树
。
头部
新增Left
,和Right
Left
:地址小于当前块
的块
。Right
:地址大于当前块
的块
。通过判断 addr<= p <= (addr + Size)
判断是否属于这个块。
这样子就能二分查找p
属于那个已分配块
。
C语言是保守的原因是,无法判断p
逻辑上是指针
,还是一个int标量
p
是个什么玩意,都必须去访问,如果他是指针
呢?
int
刚好还是某个不可到达块
的地址。那么就会有残留。而且这种情况很常见,毕竟指针
在数据段里毕竟不是特别多。
但是在java
等语言里,指针由系统统一管理,那么很容易就知道p
是否是一个指针了。
比如scanf("%d",a);
程序会把a的int值
看作指针
。而且运行中,无法判断。
scanf("%d",&val);
scanf("%d",val);
读/写
区域,造成奇怪的困惑的结果。堆存储器
并不会初始化。
calloc
.y[i]=0
;程序不检查输入串的大小就写入栈中的目标缓冲区
缓冲区溢出错误(buffer overflow bug)
。gets()
容易引起这样的错误
fgets()
限制大小。有的系统里,int
和 int *
都是四字节,有的则不同。
没啥好说的。
对指针的优先级用错。
例 :*size--
本意 (*size)--
忘记了指针的算术操作是以它们指向的对象
的大小为单位来进行的,这种大小不一定是字节。
返回一个指针,指向栈里面一个变量的地址。但是这个变量在返回的时候已经从栈里被弹出。
引用了某个已经free
掉的块。在C++
多态中经常容易犯这个错误。
守护进程
和服务器
这样的程序,存储器泄露是十分严重的事。
虚拟存储器
是对主存的一个抽象。
虚拟寻址
的间接形式来引用主存。
虚拟地址
,通过一种地址翻译硬件来转换为物理地址
。
虚拟存储器
提供三个功能
磁盘
上的虚拟地址空间内容。
虚拟存储器
缓存中的块叫做页
链接
共享数据
。存储器分配
以及程序加载
。存储器保护
。地址翻译
的过程必须和系统中所有的硬件缓存的操作集合。
L1
高速缓存中。
TLB
的页表条目的片上高速缓存L1
。现代系统通过将虚拟存储器片
和磁盘上的文件片
关联起来,以初始化虚拟存储器片
,这个过程叫做存储器映射
。
存储器映射
为共享数据,创建新的进程 以及加载数据提供一种高效的机制。可以用mmap
手工维护虚拟地址空间区域
。
动态存储器分配
,例:malloc
堆的区域
显示分配器
C
,C++
隐式分配器
JAVA
等GC
是通过不断递归访问指针
来标记已分配块
,在需要的时刻进行Sweep
。
C,C++
无法辨认指针导致无法实现完全的GC
。
GC
。p
所指向的块