为了更加有效地管理存储器且少出错,现代系统提供了对主存的抽象概念,叫做虚拟存储器(VM)
。
虚拟存储器
是硬件异常,硬件地址翻译,主存,磁盘文件和内核软件的完美交互。提供了3个重要能力。
为每个进程提供一致的地址空间
保护了每个进程的地址空间不被其他进程破坏。
程序员为什么要理解它?
虚拟存储器
是中心的。 虚拟存储器
是强大的。
虚拟存储器
是危险的
malloc
动态分配程序,就会和虚拟存储器交互。本章从两个角度分析。
虚拟存储器
如何工作。虚拟存储器
。物理地址(Physical Address,PA)
:计算机系统的主存被组织为M个连续的字节大小的单元组成的数组。每个字节的地址叫物理地址
.
CPU访问存储器的最自然的方式使用物理地址
,这种方式称为物理寻址
。
- 早期的PC,数字信号处理器,嵌入式微控制器以及Cray超级计算机使用物理寻址
。
现代处理器使用的是虚拟寻址(virtual addressing)
的寻址形式。
CPU通过生成一个虚拟地址(Virtual address,VA)
来访问主存。
虚拟地址
转换为物理地址
叫做地址翻译(address translation)
。地址翻译
也需要CPU硬件和操作系统之间的紧密结合。
存储器管理单元(Memory Management Unit,MMU)
的专用硬件。 地址空间(address space)
是一个非负整数地址
的有序集合。
如果地址空间中整数是连续的,我们说它是线性地址空间(linear address space)
。
在一个带虚拟存储器的系统中,CPU从一个有N=2^n
个地址的地址空间
中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space)
。
一个地址空间
大小是由表示最大地址所需要的位数来描述的。
N=2^n
个地址的虚拟地址空间叫做n
位地址空间。32位
或64位
。一个系统还有物理地址空间
,它与系统中物理存储器的M=2^m
(假设为2的幂)个字节相对应。
地址空间
的概念很重要,因为它区分了数据对象(字节)和 它们的属性(地址)。
字节(数据对象)
一般有多个 独立的地址(属性)
。每个地址都选自不同的地址空间。 字节
有一个在虚拟地址空间
的虚拟地址
。物理地址空间
的 物理地址
。字节
。感悟
在讲述这一小章之前,必须交代一下我对
虚拟存储器
概念的存疑。
原本我以为虚拟存储器
=虚拟内存
。
以下是虚拟内存
的定义
虚拟内存
是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片
,还有部分暂时存储在外部磁盘存储器
上,在需要时进行数据交换而在下面的定义我们可以看到
CSAPP
中认为虚拟存储器
是存放在磁盘上的。
在此,我们姑且当做两者是不同的东西,以后有更深刻的理解,再思考。
虚拟存储器(VM)
被组织为一个存放在磁盘上的N个连续字节大小的单元组成的数组。
虚拟地址
,这个虚拟地址作为到数组的索引。磁盘
上数组的内容被缓存到主存
中。
磁盘
上的数据被分割称块
。 块
作为磁盘和主存之间的传输单元。虚拟页(Virtual Page,VP)
就是这个块
虚拟页
大小为P=2^p
字节。物理页
,大小也为P
字节 页帧(page frame)
任何时候,虚拟页
的集合都被分为3个不相交的子集。
块
没有任何数据与之相关联。 malloc
来分配DRAM
表示虚拟存储器系统的缓存,在主存中缓存虚拟页
,有两个特点。
DRAM
缓存不命中处罚十分严重。 磁盘
比DRAM
慢100000多倍。DRAM
缓存的组织结构由这种巨大的不命中开销驱动。因此有以下特点。
(有些地方不是特别懂,之后看完第六章应该会好点)
虚拟页
往往很大。
DRAM
缓存是全相联
虚拟页
都能放在任何物理页
中。更精密的替换算法
DRAM
缓存总是写回
直写
判断命中和替换又多种软硬件联合提供。
操作系统软件,MMU中的地址翻译硬件和页表(page table)
。
页表
是存放在物理存储器的数据结构。
页表
将虚拟页映射到物理页。页表
。操作系统负责维护页表
的内容,以及磁盘及DRAM之间来回传送页。
页表
就是一个页表条目(Page Table Entry,PTE)
的数组. PTE
.PTE
由一个有效位
和n位地址字段
。 有效位
表明虚拟页是否被缓存。 在虚拟存储器的习惯说法中,DRAM缓存不命中称为缺页
。
处理过程如下:
PT
。PTE
有效位,发现未被缓存,触发缺页异常。调用缺页异常处理程序
写回
)中断结束,重新执行最开始的指令。
DRAM
中读取成功。虚拟存储器
是20世纪60年代发明的,因此即使与SRAM缓存使用了不同的术语。
块
被称为页
。页
的活动叫做交换(swapping)
或者页面调度(paging)
。不命中
发生时,才换入页面,这种策略叫做按需页面调度(demand paging)
。 比如某个页面
所指向地址为NULL
,将这个地址指向磁盘某处,那么这就叫分配页面
。
此时虚拟页
从未分配
状态 变为 未缓存
。
虚拟存储器
工作的相当好,主要归功于老朋友局部性(locality)
尽管从头到尾的活动页面数量大于物理存储器大小。
但是在局部内,程序往往在一个较小的活动页面集合工作
这个集合叫做工作集(working set)
或者叫常驻集(resident set)
程序有良好的时间局部性
,虚拟存储器
都工作的相当好。
工作集
大于物理存储器
大小。这种状态叫颠簸(thrashing)
. 统计缺页次数
可以利用Unix的getrusage
函数检测缺页数量。
实际上,操作系统为每个进程提供一个独立的页表
。
因此,VM
简化了链接
和加载
,代码
和数据共享
,以及应用程序的存储器
分配。
简化链接
独立的空间地址意味着每个进程的存储器映像使用相同的格式。
0x08048000
(32位)处或0x400000
(64位)处开始。一致性极大简化了链接器
的设计和实现。
简化加载
加载器
可以从不实际拷贝任何数据从磁盘到存储器。将一组连续的
虚拟页
映射到任意一个文件中的任意位置的表示法称作存储器映射
。Unix提供一个称为mmap
的系统调用,允许程序自己做存储器映射。在9.8详细讲解。
简化共享
共享
机制.printf
.简化存储器分配.
虚拟页
连续(虚拟页还是单独的),物理页
可以不连续。使得分配更加容易。任何现代操作系统必须为操作系统提供手段来控制对 存储器系统的访问。
方式:在PTE
上添加一些格外的许可位
来控制访问。
SUP
:是否只有在内核模式下才能访问?READ
:读权限。WRITE
:写权限。如果指令违反了许可条件,触发一般保护性异常,然后交给异常处理程序,Shell
一般会报告为段错误(segmentaion fault)
。
认识到硬件在支持虚拟存储器中的角色
以下是接下来可能要用到的符号,作参考。
形式上来说,地址翻译是一个N元素的虚拟地址空间(VAS
)中的元素和一个M元素的物理地址空间(PAS
)元素之间的映射,
以下展示了MMU
(Memory Management Unit
,存储器管理单元)如何利用页表实现这样的功能
页表基址寄存器(Page Table Base Register,PTBR)
指向当前页表。n
位的虚拟地址
包含两个部分
p
位的虚拟页面偏移(Virtual Page Offset
,VPO
)n-p
位的虚拟页号(Virtual Page Number
,VPN
) MMU
利用VPN
选取适当的PTE(页面条目,Page Tabe Entry,PTE)
页面条目 (PTE
)中物理页号(PPN
)和虚拟地址中的VPO
串联起啦,即是物理地址
PPO
和VPO
是相同的VPN
,PPN
都是块,都是首地址而已,所以需要偏移地址PPO
,VPO
图(a)展示页面命中,CPU硬件执行过程
MMU
。MMU
生成PTE
地址(PTEA
),并从高速缓存/主存请求中得到它。PTE
。MMU
构造物理地址(PA),并把它传送给高速缓存/主存。页面命中完全由硬件处理,与之不同的是,处理缺页需要 硬件和操作系统内核协作完成。
PTE
有效位是零,所以MMU
触发异常,传递CPU中的控制到操作系统内核中的 缺页异常处理程序。PTE
。在任何使用虚拟存储器又使用SRAM
高速缓存的系统中,都存在应该使用虚拟地址 还是 使用 物理地址 来访问SRAM高速缓存
的问题。
使用虚拟地址的优点,就是类似于使用虚拟存储器的优点,更好的利用空间。但是设计更复杂。两者的使用需要权衡。
大多数系统是选择物理寻址。
使用物理寻址,多个进程同时在高速缓存中有存储块和共享来自相同虚拟页面的块称为简单的事。
PTE
)的一部分。以下是一个例子(将PTE
进行高速缓存)。
每次CPU产生一个虚拟地址,MMU
就必须查阅一个PTE
,以便将虚拟地址翻译为 物理地址。
PTE
碰巧缓存在L1
中,那么开销就下降到一到两个周期许多系统都试图消除这样的开销,他们在MMU
中包含了一个关于PTE
的小缓存,称为翻译后备缓冲器(Translation Lookaside Buffer,TLB)
。
TLB
是一个小的,虚拟寻址的缓存。
PTE
组成的块。TLB
通常用于高度的相连性如图所示
索引
和标记字段
是从虚拟地址中的虚拟页号中提取出来的。TLB
有T=2^t个组 TLB索引
(TLBI
)是由VPN的t
个最低位组成。(对应于VPO
)TLB标记
(TLBT
)是由VPN中剩余位组成(对应于VPN
)下图展示了TLB
命中步骤
TLB
命中
MMU
从TLB
取出对应的PTE
。MMU
将这个虚拟地址翻译成一个物理地址,发送到高速缓存/主存
高速缓存/主存
所请求的数据字返回给CPU当TLB
不命中的时候,MMU
必须从L1
缓存或内存中取出相应的PTE
,并进行类似缺页处理过程。
如果我们有一个32位地址空间,4KB
大小的页面(p=2^12
)和一个4B
的PTE
,即使应用所引用的只是虚拟地址空间中很小的一部分,也总是需要一个4MB
的页表驻留在存储器中。
所以多级页表
的诞生用于解决在很少使用时有一个很大的页表常驻于内存。
计算方式,最多可能要
2^32/4KB=1MB
个页面,每个页面需要4B的PTE
所以需要4MB
大小的页表。
思考虚拟地址是31~p
,p-1~0
即VPN,VPO。VPN
即可表示页面个数(上文中的1MB
),VPO
即页面大小(上文中的4KB
),显然知道两者相乘为2^32 次方、
用来压缩页表的常用方式是使用层次结构的页表。
页表本身一个优点就是用来解决 内存不够装载程序所用内存的情况,进行动态分配。那么当我们发现内存装载那么大的页表也是负担的时候,显然也可以用类似页表的形式来解决,这就是多级页表。
以下用上图的 两层 作为例子。
总共有9KB
个页面,PTE为4个字节。
2KB
个页面分配给代码和数据。6KB
个页面未分配1023
个页面也未分配一级页表中的每个PTE
负责映射虚拟地址空间中一个4MB
大小的片(chunk)
.
片
都是由1024个连续的页面组成。4MB=1024个页面*PTE大小4字节
。如果片i
中每个页面都没有分配,那么一级PTE i
就为空。
PTE 2
~PTE 7
片i
中有一个被分配了,那么PTE i
就不能为空。 三级四级页表
的原由也是如此。这种方法从两个方面减少了存储器要求。
PTE
为空,那么相应的二级页表就根本不会存在。 k级页表层次结构的地址翻译。
虚拟地址
被分为k
个VPN
和一个VPO
。每个VPN i
都是i-1
级页表到i
级页表的索引。PPN
存于k级页表。PPO
依旧与VPO
相同。此时TLB能发挥作用,因为层次更细,更利于缓存。使得多级页表的地址翻译不比单级页表慢很多。
在这一节里,我们通过一个具体的端到端的地址翻译示例,来综合一下我们学过的内容。
一个在有一个TLB
和L1 d-cache
的小系统上。作出如下假设:
14
位长(n=14)12
位长(m=12)64
字节(P=2^6)TLB
是四路组相连的,总共有16
个条目(?)L1 d-cache
是物理寻址,高速缓存,直接映射(E=1)的,行大小为4字节,而总共有16个组。(?)存储结构快照
TLB
: TLB
利用VPN
的位进行缓存。页表
: 这个页表是一个单级设计。一个有256个,但是这里只列出16个。高速缓存
:直接映射的缓存通过物理地址的字段来寻址。 E=1
。处理器包(processor package)
TLB
Linux
一页4kb
数据和指令
高速缓存。 L1
,L2
八路组相连L3
十六路组相连块
大小64字节。Intel QuickPath
技术。I/O
桥直接通信。L3
高速缓存DDR3存储器控制器
。上图完整总结了Core i7
地址翻译过程,从虚拟地址到找到数据传入CPU。
Core i7
采用四级页表层次结构。 CR3
控制寄存器指向第一级页表(L1)的起始位置 CR3
也是每个进程上下文的一部分。CR3
也要被重置。一级,二级,三级页表PTE
的格式:
P=1
时 地址字段包含了一个40位物理页号(PPN)
,指向适当的页表开始处。
强加了一个要求,要求物理页4kb
对齐。
PPO
为12
位 = 4kb
PPO
的大小就跟物理页的大小有关。四级页表的PTE
格式:
PTE
有三个权限位,控制对页的访问
R/W
位确定页的内容是可以 读写还是 只读。U/S
位确定用户模式是否能够访问,从而保护操作系统内核代码不被用户程序访问。XD
(禁止执行) 位是在64位系统引入,禁止某些存储器页取指令。 当MMU
翻译虚拟地址时,还会更新两个内核缺页处理程序会用到的位。
A
位
MMU
都会设置A
位,称为引用位(reference bit)
.引用位
来实现它的页替换算法。D
位
写
就会设置D
位,又称脏位(dirty bit)
.脏位
告诉内核在拷贝替换页前是否要写回
。引用位
或脏位
。四级页表如何将VPN
翻译成物理地址
VPN
被用作页表的偏移量。CR3
寄存器包含L1页的物理地址优化地址翻译
在对地址翻译中,我们顺序执行这两个过程
MMU
将虚拟地址翻译成物理地址。- 物理地址传送到
L1
高速缓存。
然而实际的硬件实现使用了一个灵巧的技巧,允许这两个步骤并行。加速了对高速缓存的访问
例如:页面大小为4KB
的Core i7
上的虚拟地址有12
位的VPO
,且PPO
=VPO
.而且物理地址的缓存,也是
6
位索引+6
位偏移,刚好是VPO
的12位。这不是巧合
- 一方面通过
VPN
找PPN
。- 另一方面直接通过
PPO
对高速缓存进行组选择。- 等找到
VPN
后就能立即进行关键字匹配。
目标:对Linux的虚拟存储系统做一个描述,大致了解操作系统如何组织虚拟存储器,如何处理缺页
。
内核虚拟存储器
内核虚拟存储器
包含内核中的代码和数据。
内核虚拟存储器
的某些区域被映射到所有进程共享的物理页面
Linux
也将一组连续的虚拟页面
(大小等同于系统DRAM
总量)映射到相应的一组物理页面
。(这句话啥意思???????????????????????????????)
内核虚拟存储器
包含每个进程不相同的数据。
Linux
将虚拟存储器组织成一些区域
(也叫做段
)的集合。
一个区域
就是已经存在着的(已分配的) 虚拟存储器的连续片
,这些片/页已某种形式相关联。
所有存在的虚拟页
都保存在某个区域。
区域
的概念很重要虚拟地址空间
有间隙。一个进程中虚拟存储器的内核数据结构。
内核
为系统中每个进程维护了一个单独的任务结构
。任务结构
中的元素包含或指向内核运行该进程所需要的全部信息。
task_struct
mm_struct
pgd
第一级页表
的基址。pgd
存放在CR3
控制寄存器mmap
vm_area_structs(区域结构)
vm_area_structs
都描述了当前虚拟地址空间的一个区域(area)
.vm_start
:指向这个区域的起始处。vm_end
:指向这个区域的结束处。vm_port
:描述这个区域内包含的所有页的读写许可权限。vm_flags
:描述这个区域页面是否与其他进程共享,还是私有。 vm_next
: 指向链表的下一个区域。MMU
在试图翻译虚拟地址A时,触发缺页。这个异常导致控制转移到缺页处理程序
,执行一下步骤。
虚拟地址A是合法的吗?
区域结构
链表。vm_start
和vm_end
做比较。 树的数据结构算法
查找试图访问的存储器是否合法?
保护异常
,终止进程。一切正常的话
存储器映射
: Linux通过将一个虚拟存储器区域
与一个磁盘
上的对象关联起来,以初始化这个虚拟存储器区域
的内容,这个过程叫做存储器映射
。
虚拟存储器区域
可以映射到以下两种类型文件。
Unix文件系统中的普通文件:一个区域
可以映射到一个普通磁盘文件的连续部分。
文件区(section)
被分成页
大小的片,每一片包含一个虚拟页面
的初始化内容。初始化
,虚拟页面
此时还并未进入物理存储器
。 CPU
第一次引用这个页面。匿名文件 : 一个区域
可以映射到一个匿名文件
。
匿名文件
由内核创建,包含的全是二进制零。CPU
第一次引用这样区域(匿名文件)的虚拟页面
时。
牺牲页面
全部用二进制零
覆盖。虚拟页面
标记为驻留在存储器中。又叫请求二进制零的页(demand-zero page)
。
交换文件
,交换空间
。(win
下叫做paging file
)
一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)
之间换来换去。交换文件
也叫交换空间
或者交换区域
。
需要意识到,在任何时刻,交换空间
都限制着当前运行着的进程分配的虚拟页面总数。
这一段不太明白。
共享对象
的由来
只读文本区域
。 printf
Uinx shell
的tcsh
共享对象
。一个对象被映射到虚拟存储器
的一个区域,一定属于以下两种。
共有对象
映射到它的虚拟地址空间的一个区域。 区域
的写操作,对于那些也把这个共享对象映射它的虚拟存储器的进程
是可见的。磁盘
上的原始对象。区域
叫做共享区域
。私有对象
的区域做出的改变,对于其他进程不可见.磁盘
上。区域
叫做私有区域
。进程1
,将共享对象映射到虚拟存储器
中,然后虚拟存储器
将这一段找一块物理存储器
存储。当进程2
也要引用同样的共享对象时。
进程1
已经映射了这个对象。进程2
的虚拟存储器
直接指向了那一块进程1
指向的物理存储器
。即使对象
被映射到多个共享区域,物理存储器依旧只有一个共享对象的拷贝。
私有对象
使用一种叫做写时拷贝(conpy-on-write)
的巧妙技术。
私有对象
开始生命周期的方式基本与共享对象
一样。
对于每个映射私有对象
的进程,相应私有区域
的页表条目
都被标记为只读。
区域结构(vm_area_structs)
被标记为私有的写时拷贝
。过程:只要有进程试图写私有区域
内的某个页面,那么这个写操作
触发保护异常
。
故障处理程序
会在物理存储器
中创建被修改页面的一个新拷贝。页表条目(PTE)
指向这个新的拷贝,恢复被修改页面的可写权限。故障处理程序
返回,CPU重新执行这个写操作
。通过延迟私有对象中的拷贝直到最后可能的时刻,写时拷贝
充分使用了稀缺的物理存储器。
了解fork
函数如何创建一个带有自己独立虚拟地址空间的新进程。
当fork
函数被当前进程调用时。
新进程
创建内核数据结构,并分配给它唯一一个PID
。新进程
创建虚拟存储器。 mm_struct
,区域结构
和页表的原样拷贝。私有的写时拷贝
。私有对象
的写时拷贝
技术。当fork
函数在新进程返回时。
fork
时存在的虚拟存储器相同。写
时,触发写时拷贝机制
。理解execve
函数实际上如何加载和运行程序。
Execve("a.out",NULL,NULL);
execve
函数在当前进程加载并执行目标文件a.out
中的程序,用a.out
代替当前程序。
加载并运行需要以下几个步骤。
区域结构
。映射私有区域。
为新程序的文本,数据,bss
和栈区域创建新的区域结构
。
区域结构
都是私有的
,写时拷贝
的。a.out
文件中的文件和数据区。bss
区域是请求二进制零
,映射到匿名文件。
a.out
中堆,栈
区域也是请求二进制零
。
映射共享区域
a.out
程序与共享对象链接。 设置程序计数器(PC)
execve
最后一件事设置PC
指向文本区域的入口点。mmap
函数的用户级存储器映射Unix进程
可以使用mmap
函数来创建新的虚拟存储器区域
,并将对象映射到这些区域中。
#include
#include
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);
返回:若成功时则为指向映射区域的指正,若出错则为MAP_FAILED(-1).
参数解释:
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)
释放
。C语言中提供一种叫malloc
程序显示分配器。
malloc
和free
C++
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
字节。吞吐率最大化
和存储器使用率
最大化。这两个性能要求通常是相互冲突的。
目标1:最大化吞吐率
假定n个分配和释放请求的某种序列R1,R2,R3.....Rn
吞吐率 :
每个单位时间完成的请求数。通过使分配和释放请求
的平均时间最小化 来最大化吞吐率
目标2:最大化存储器利用率
需要增加分配和释放请求的时间。
评估使用堆
的效率,最有效的标准是峰值利用率(peak utilization)
假定n个分配和释放请求的某种序列R1,R2,R3.....Rn
有效载荷(payload)
:如果一个应用程序请求一个p
字节的块,那么得到的已分配块
的有效载荷
是p
字节。(很有可能会分配p+1
个字节之类的)聚集有效载荷(aggregate payload)
:请求Rk
完成之后,Pk
表示当前已分配块的有效载荷
之后。又叫做聚集有效载荷
。Hk
表示堆的当前的大小(单调非递减的)。峰值利用率为Uk
吞吐率
和存储器利用率
是相互牵制的,分配器
设计的一个有趣的挑战就是在两者之间找到一个平衡。
造成堆利用率很低的主要原因是一种称为碎片(fragmentation)
的现象。
碎片
:虽然有未使用的存储器但不能满足分配要求时的现象。
1.内部碎片
:已分配块比有效载荷(实际所需要的)大时发生。
碎片
.内部碎片
的数量取决于以前请求
的模式和分配器的实现方式。 2.外部碎片
:当空闲存储器合计
起来足够满足一个分配请求,但是没有一个单独
的空闲块足够大可以处理这个请求发生的。
外部碎片
的量化十分困难。 请求
的模式和分配器的实现方式,还要知道将来请求
的模式。启发式策略
来用少量的大空闲块替换大量的小空闲块。一个实际的分配器要在吞吐率
和利用率
把我平衡,必须考虑一下几个问题。
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
所指向的块