编译原理--运行时刻环境

编译器需准确实现源程序语言中包含的各个抽象概念.
这些抽象概念常包括我们之前曾讨论过的那些概念,
如名字,作用域,绑定,数据类型,运算符,过程,参数,控制流构造.
编译器还需和操作系统及其他系统软件协作,在目标机上支持这些抽象概念.
为做到这一点,编译器创建并管理一个运行时刻环境,它编译得到的目标程序就运行在这个环境中.这环境处理很多事务,如为源程序中命名的对象分配和安排存储位置,确定目标程序访问变量时使用的机制,过程间的连接,参数传递机制,及与操作系统,输入输出设备及其他程序的接口.

存储组织

对逻辑地址空间的管理和组织是由编译器,操作系统,目标机共同完成的.
操作系统将逻辑地址映射为物理地址,物理地址对整个内存空间编址.
一个目标程序在逻辑地址空间的运行时刻映像包含数据区和代码区.
本书中,
假定运行时刻存储是以多个连续字节块的方式出现的,
其中字节是内存的最小编址单元.
一个字节包含8个二进制位,4个字节构成一个机器字.
多字节数据对象总是存储在一段连续的字节中,并把第一个字节作为它的地址.

数据对象的存储布局受目标机的寻址约束的影响很大.
因为对齐的原因而产生的闲置空间称为补白.
生成的目标代码的大小在编译时刻就已经固定下来了,
因此编译器可将可执行目标代码放在一个静态确定的区域:代码区.
这个区常位于存储的低端.
类似地,
程序的某些数据对象的大小可以在编译时刻知道,
它们可被放置在另一个称为静态区的区域中,
该区域可被静态确定.
放置在这个区域的数据对象包括全局常量和编译器产生的数据,如用于支持垃圾回收的信息等.

为了将运行时刻的空间利用率最大化,
另外两个区域--栈和堆被放在剩余地址空间的相对两端.
这些区域是动态的.
这两个区域根据需要向对方增长.
栈区用来存放称为活动记录的数据结构,这些活动记录在函数调用过程中生成.

实践中,栈向较低地址方向增长,堆向较高地址方向增长.
本章,下一章,我们假定栈向较高地址方向增长,以便我们能在作用例子中方便地使用正的偏移量.

静态和动态存储分配

两个形容词静态和动态分别表示编译时刻和运行时刻.
如编译器只需通过观察程序文本即可做出某个存储分配决定,而不需观察该程序运行时做什么,就任务这个存储分配决定是静态的.反过来,如只有在程序运行时才能做出决定,则这个决定就是动态的,
1.栈式存储
一个过程的局部名字在栈中分配空间.
2.堆存储

空间的栈式分配

有些语言使用过程,函数或方法作为用户自定义动作的单元,
几乎所有针对这些语言的编译器都把它们的运行时刻存储按一个栈管理.

活动树

如果过程调用[或过程的活动]在时间上不是嵌套的,则栈式分配就不可行了.

请添加图片描述

如果过程p的一个活动调用了过程q,则q的该次活动必定在p的活动结束之前结束.
有三种常见的情况
1.q的该次活动正常结束,则基本上任何语言中,控制流从p中调用q的点之后继续.
2.q的该次活动[或q调用的某个过程]直接或间接地中止了,也就是说不能再继续执行了,此时,q和p同时结束.
3.q的该次活动因为q不能处理的某个异常而结束.过程p可能会处理这个异常,此时q的活动已经结束而p的活动继续执行.如果p不能处理这个异常,那么p的活动和q的活动一起结束.一般来说,某个过程的尚未结束的活动将处理这个异常.

因此,可用一棵树来表示在整个运行期间的所有过程的活动,这棵树称为活动树,
树中的每个结点对应于一个活动,根结点是启动程序执行的main过程的活动.
在表示过程p的某个活动的结点上,其子结点对应于被p的这次活动调用的各个过程的活动.
按这些活动被调用的顺序,自左向右地显示它们.
一个子结点需在其右兄弟结点的活动开始之前结束.

请添加图片描述
请添加图片描述

在活动树和程序行为之间存在下列多种有用的对应关系,正是因为这些关系使我们可使用运行时刻栈.
1.过程调用的序列和活动树的前序遍历相对应.
2.过程返回的序列和活动树的后续遍历相对应.
3.假定控制流位于某个过程的特定活动中,且该过程活动对应于活动树上的某个结点N.
则当前尚未结束的就是结点N及其祖先结点对应的活动.
这些活动被调用的顺序是从根到N的出现顺序,按此顺序反序返回.

活动记录

过程调用和返回通常由一个称为控制栈的运行时刻栈进行管理.
每个活跃的活动都有一个位于这个控制栈中的活动记录.
活动树的根位于栈底.
栈中全部活动记录的序列对应于在活动树中到达当前控制所在的活动结点的路径.

按惯例,我们画控制栈的时候将把栈底画在栈顶之上.
因此在一个活动记录中出现在页面最下方的元素实际上最靠近栈顶.
这里列出出可能出现在一个活动记录中的各种类型的数据:
1.临时值
如表达式求值过程中产生的中间结果无法存放在寄存器中时,就会生成这些临时值.
2.对应于这个活动记录的过程的局部数据.
3.保存的机器状态.
包括对此过程的此次调用之前的机器状态信息.
这些信息通常包括返回地址[程序计数器的值,被调用过程必须返回到该值所指位置]和一些寄存器的内容[调用过程会使用这些内容,被调用过程需在返回时恢复这些内容]
4.一个访问链.
当被调用过程需其他地方的某个数据时需要使用访问链进行定位
5.一个控制链,指向调用者的活动记录
6.当被调用函数有返回值时,要有一个用于存放这个返回值的空间.
不是所有的被调用过程都有返回值,即使有,也倾向于将该值放到一个一个寄存器中以提高效率.
7.调用过程使用的实在参数.
这些值通常将尽可能放在寄存器中,而非活动记录中.

请添加图片描述
请添加图片描述

调用代码序列

实现过程调用的代码段称为调用代码序列.
这个代码序列为一个活动记录在栈中分配空间,并在此记录的字段中填写信息.
返回代码序列是一段类似的代码,它恢复机器状态,使得调用过程能在调用结束之后继续执行.
一个调用代码序列中的代码通常被分割到调用过程和被调用过程中.
在分割运行时刻任务时,调用者和被调用者之间不存在明确界限.

源语言,目标机器,操作系统会提出某些要求,使得能选择出一种较好的分割方案.
总的来说,如果一个过程在n个不同点上被调用,分配给调用者的那部分调用代码序列会被生成n次.分别给被调用者的部分只被生成一次.因此,我们期望把调用代码序列尽可能多放在被调用者中.
被调用者不可能知道所有事情
在设计调用代码序列和活动记录的布局时,可使用下列的设计原则:
1.在调用者和被调用者之间传递的值一般被放在被调用者的活动记录的开始位置.
因此它们尽可能靠近调用者的活动记录.
调用者能计算该次调用的实在参数的值,并将它放在自身活动记录的顶部.
而不用创建整个被调用者的活动记录.
还使得语言可使用参数个数或类型可变的过程.
如printf,被调用者知道应把返回值放置在相对于它自己活动记录的哪个位置.
同时,不管有多少个参数它们都将在栈中顺序地出现在该位置下.
2.固定长度的项被放置在中间位置.
如果我们将机器状态信息标准化,则错误发生时,诸如调试器这样的程序将可更容易地将栈中的内容解码.
3.早期不知道大小的项将被放置在活动记录的尾部.
一部分局部变量具有固定的长度,编译器通过检查该变量的类型就可确定其长度.
4.需小心地确定栈顶指针所指位置.
一个常用方法是让这个指针指向活动记录中固定长度字段的末端.
这样,固定长度的数据就可通过固定的相对于栈顶指针的偏移量来访问.
而中间代码生成器知道这些偏移量.
后果是活动记录中的变长域实际上位于栈顶之上.
它们的偏移量需运行时刻计算.

请添加图片描述

这个调用代码序列及它在调用者和被调用者之间的划分描述如下:
1.调用者计算实在参数的值
2.调用者将返回地址和原来的top_sp值存放到被调用者的活动记录中.
调用者增加top_sp的值,使之指向图7-7所示的位置.
top_sp越过了调用者的局部数据和临时变量及被调用者的参数和机器状态字段.
3.被调用者保存寄存器值和其他状态信息
4.被调用者初始化其局部数据并开始执行.
一个与此匹配的返回代码序列如下:
1.被调用者将返回值放到与参数相邻的位置
2.使用机器状态字段中的信息,被调用者恢复top_sp和其他寄存器,
然后跳转到由调用者放在机器状态字段中的返回地址.
3.尽管top_sp已经被减少,但调用者仍然知道返回值相对于当前top_sp值的位置.
故调用者可使用返回值.
被调用者的代码需还能处理其他调用,
因此它要等到被调用时再检查相应的参数字段.

栈中的变长数据

运行时刻存储管理系统需频繁地处理某些数据对象的空间分配.
也可将未知大小的对象,数组及其他结构分配在栈中.
只有一个数据对象局限于某个过程,且当此过程结束时它变得不可访问,才可使用栈为这个对象分配空间.
为变长数组分配空间的一个常用策略如图7-8.
同样的方案可用于任何类型的对象的分配,只要它们对被调用的过程而言是局部的,且其大小依赖于该次调用的参数即可.

请添加图片描述

尽管这些数组的存储出现在栈中,它们并不是p的活动记录的一部分.
p执行时,这些指针的位置相对于栈顶指针的偏移量是已知的.
对栈中数据的访问通过指针top和top_sp完成.
这里,top标记了实际的栈顶位置,它指向下一个活动记录将开始的位置,
第二个指针top_sp用来找到顶层活动记录的局部的定长字段.

栈中非局部数据的访问

没有嵌套过程时的数据访问

对于不允许声明嵌套过程的语言而言,变量的存储分配和访问这些变量较为简单
1.全局变量分配在静态区.
变量的位置不变,编译时刻可知.
2.其他变量一定是栈顶活动的局部变量,可通过运行时刻栈的top_sp指针来访问这些变量.

和嵌套过程相关的问题

一种语言允许嵌套地声明过程且仍然遵循通常的静态作用域规则时,数据访问变得比较复杂.
为一个内嵌过程p中的一个非局部名字x找出对应的声明是一个静态的决定过程,
将块结构的静态作用域规则进行扩展就可解决这个问题.
设x在一个外围过程q中声明.
根据p的一个活动找到相关的q的活动是一个动态的决定过程,需要额外的有关活动的运行时刻信息.

一个支持嵌套过程声明的语言

1.ML是一种函数式语言.
变量一旦被声明并初始化就不会在改变,只有少数几个例外.
2.定义变量并设定它们不可更改的初始值的语句有如下形式
val <name> = (expression)
3.函数使用如下语法进行定义
fun<name> (<arguments>) = <body>
4.使用下列形式的let语句来定义函数体
let<list of definitions> in <statements> end
其中,定义通常是val或fun语句.
每个这样的定义的作用域包括从该定义之后直到in为止的所有定义,及直到end为止的所有语句.函数可嵌套地定义.如函数p的函数体可能包括一个let语句,而该语句又包含了另一个函数q的定义.

嵌套深度

对不内嵌在任何其他过程中的过程,嵌套深度为1
如一个过程p在一个嵌套深度为i的过程中定义,则设定p的嵌套深度为i+1.

堆管理

存储管理器

1.分配
如有可能,它使用堆中的空闲空间来满足分配请求;
如没有请求大小的空间块可供分配,试图从操作系统中获得连续的虚拟内存来增加堆区的存储空间.如空间已经用完,通知应用.
2.回收
存储管理器通过不会将内存返回给操作系统

期望存储管理器具有的特性:
1.空间效率
使一个程序所需的堆区空间的总量达到最小.
空间效率通过使存储碎片达到最少而得到的.
2.程序效率
存储管理器应充分利用存储子系统,使程序可运行得更快.
通过关注对象在存储中的放置方法,存储管理器可更好地利用空间,并且有希望使程序运行得更快.
3.低开销
最小化开销,即花费在分配和回收上的执行时间在总运行时间中所占的比例.分配的开销由小型请求决定,管理大型对象的开销相对不重要.

一台计算机的存储层次结构

一个处理器通常具有少量寄存器,寄存器中的内容由软件控制.
有一层或多层高速缓存通常用静态RAM制造.
主存,动态RAM.
物理内存由下一层的虚拟内存提供支持,由磁盘实现.
在一次内存访问中,机器首先在最近的存储中寻找数据,如找不到,到下一层寻找.

请添加图片描述

数据以连续存储块的方式进行传输.
为分摊访问的开销,内存层次结构中较慢的层次通常使用较大的块.
在主存和高速缓存之间的数据是按照被称为高速缓存线的块进行传输的.
高速缓存线的长度通常在32-256字节之间.
在虚拟内存[硬盘]和主内存之间的数据是以被称为"页"的内存块进行传输的.页的大小通常在4-64KB之间.

程序中的局部性

如一个程序访问的存储位置很可能将在一个很短的时间段内被再次访问,就说这个程序具有时间局部性.如果访问过的存储位置的临近位置很可能在一个很短的时间段内被访问,就说这个程序具有空间局部性.
通常认为程序把90%的时间用来执行10%的代码
1.程序经常含很多从来不执行的指令.
2.程序一次典型运行中,被调用的代码可能只有一小部分被实际执行.
3.通常的程序往往将大部分时间花费在执行程序中的最内层循环和最紧凑的递归环上.

利用存储层次结构的优化
将最近使用过的指令放入高速缓存的策略通常很有效.
还可改变数据布局或计算顺序,从而改变一个程序中的数据访问的时间局部性和空间局部性.

碎片整理

将空闲存储块称为"窗口".
对于每个分配请求,存储管理器需将请求的存储块放入一个足够大的"窗口"中.
除非找到一个大小恰好相等的窗口,否则,会切分.
对每个回收请求,被释放的存储块被放回到空间空间的缓冲池中.
涉及窗口合并.

best-fit和next-fit对象放置
经验表明,现实中程序碎片最少的一个策略是将请求的存储分配在满足请求的最小可用窗口中.
first-fit策略,对象被放置到第一个能容纳请求对象的窗口中.
为更有效实现best-fit,
可根据空闲块大小,将它们分在若干容器中.
为较小的尺寸设置较多的容器.更大尺寸的容器按指数增长划分.
在每一个容器中,存储块按其大小排列.
总是存在这样的一个空闲空间块,存储管理器可向操作系统请求更多的页面来扩展这个块.
这个块被称为"荒野块".
因为它的可扩展性,通常把这个块当做最大尺寸存储块的容器.
容器机制使得寻找best-fit块变得容易:
1.如被请求的尺寸有一个专有容器,
即该容器只含该尺寸的存储块,可任意取出一个块.
2.如被请求的尺寸没专有的容器,可找出一个能包含该尺寸的存储块的容器.
在这个容器中,可使用first-fit或best-fit策略.
如取出块包含剩余部分,剩余部分将切分出一个独立的块放入更小尺寸容器中.
3.目标容器可能为空,或这个容器中的所有存储块都太小.
此时,只需使用对应于下一个较大尺寸的容器重新搜索.
最后,要么找到可使用的存储块,要么到达"荒野块".
从此荒野块一定可得到需要的空间,但有可能需请求操作系统为堆区增加更多的内存页.
	
对best-fit有用的改进之一是在找不到恰好等于请求尺寸的存储块时,
使用另一种对象放置方法.
此时,使用next-fit策略,只要刚刚分割过的存储块中还有足够的空间来容纳这个对象,就把这个对象放在存储块.


管理和接合空闲空间
当一个对象通过手工方式回收时,存储管理器需将该存储块设置为空闲的.
某些情况下,还可将这个块和堆中的相邻块合并起来,构成一个更大的块.
如为所有有固定尺寸的存储块保留一个容器,那可能倾向于不把相邻的该尺寸的块合并成为双倍大小的块.一个简单的分配/回收方案是维护一个位映射,其中的每个比特对应于容器中的一个块.当一个块被回收时,将它对应的1改为0.如没有空闲块,就取一个新的页,将其分割为适当大小的存储块,同时扩展用于存储管理的位向量.

有些情况下,问题会变得比较复杂.
如,不使用容器而把堆区作为一个整体进行管理;或想接合相邻的块,并在必要的时候将合并得到的块移动到另一个容器中.有两种数据结构可用于支持相邻空闲块的接合:
1.边界标记
在每个[不管是空闲还是已分配的]存储块的高低两端,都存放了重要的信息.
在块的两端都设置了一个free/used位,用来标识当前该块是已用的还是空闲的.
在与每一个free/used位相邻的位置上存放了该块中的字节总数.
2.一个双重链接的,嵌入式的空闲列表
各个空闲块[而非已分配的块]还使用一个双重链表进行链接.
这个链表的指针就存放在这些块中.
尽管它的存在为块的大小设置了一个下界.
空闲列表中的存储块的顺序没有确定.

请添加图片描述

人工回收请求

人工回收带来的问题
1.一直未能删除不能被引用的数据,称为内存泄漏.
2.引用已经被删除的数据,称为悬空指针引用错误.
把诸如读,写,回收等沿着一个指针试图使用该指针所指对象的所有操作称为对这个指针的"解引用".
另一个相关的编程错误形式是访问非法地址.如对空指针的解引用和访问一个数组界限之外的元素.

编程规范和工具
1.当一个对象的生命周期能被静态推导出来时,对象所有者的概念是很有用的.
基本思想是在任何时候都给每个对象关联上一个所有者,这个所有者是指向该对象的一个指针.
所有者负责删除这个对象或把这个对象传递给另一个所有者.
可以有其他指针也指向同一个对象,但这些指针不代表拥有关系.
只有拥有者可以删除其拥有的对象.
这个规范可消除内存泄漏,也可避免将同一对象删除两次.
然而,它对解决悬空指针引用没帮助.[锁定&解锁]
2.当一个对象的生命周期需要动态确定时,引用计数会有所帮助.
它的基本思想是给每个动态分配的对象附上一个计数.
在指向这个对象的引用被创建时,将此对象的引用计数加1;
当一个引用被删除时,将此引用计数减1.
当计数变成0时,这个对象就不再被引用,因此可被删除.
然而,这个技术不能发现无用的循环数据结构.
其中的一组对象不能再被访问,但因为它们相互引用.因此均不释放.
引用计数可以解决悬空指针引用,消除指向已删除对象的引用.
引用计数在存储一个指针的每次运算上增加了额外开销,因此引入引用计数的运行时刻代价大.
3.对其生命周期局限于计算过程中的某个特定阶段的一组对象,可使用基于区域的分配方法.
当被创建的对象只在一个计算过程的某个步骤中使用时,
可把这些对象分配在同一个区域中.
一旦这个计算步骤完成,就删除整个区域.
基于区域的分配具有一定局限性.
但非常高效.
成批一次性删除区域中的所有对象.

垃圾回收概述

垃圾回收器的设计目标

基于类型信息,可知道该对象有多大,及该对象的哪些分量包含指向其他对象的引用[指针].
假定对对象的引用总是指向该对象的起始位置.
把一个用户程序称为增变者.
它会修改堆区中的对象集合.
增变者从存储管理器处获取空间,创建对象,还可引入和消除对已有对象的引用.
当增变者程序不能"到达"某些对象时,
这些对象就变成了垃圾.

垃圾回收器找到这些不可达对象,并将这些对象交给跟踪空闲空间的存储管理器,收回它们所占的空间.

一个基本要求:类型安全
为使垃圾回收器能工作,它需知道任何给定的数据元素或一个数据元素的分量是否为[或可否被用作]一个指向某块已分配存储空间的指针.
一种语言中,如任何数据分量的类型都是可确定的,则这种语言就称为类型安全的.
如果一个语言既不是静态类型安全的,又不是动态类型安全的,就称为不安全的.
类型不安全的语言不适合使用自动垃圾回收机制.
c/c++是类型不安全的.
在不安全语言中,存储地址可任意操作:
可将任意的算术运算应用于指针,创建出一个新的指针,且任何整数都可强制转化为指针.
因此,理论上一个程序可在任何时候引用内存的任何位置.
没有那个内存位置可认为是不可访问的.也就无法安全地收回任何存储空间.
实践中,大部分c/c++程序并没有随意地生成指针,因此人们开发了一个理论上不正确,但实践经验表明很有效的垃圾回收器.

性能度量
设计垃圾回收器时需考虑的性能度量指标
1.总体运行时间
垃圾回收不会显著增加一个应用的总体运行时间
2.空间使用
垃圾回收避免了内存碎片,并最大限度利用了可用内存
3.停顿时间
除了最小化总体运行时间外,
还希望将最长停顿时间最小化.
实时应用要求某些计算在一个时间界限内完成.
要么在执行实时任务时压制住垃圾回收过程,要么限定最长停顿时间.
4.程序局部性
可通过释放空间并复用该空间来改善增变者程序的时间局部性;
也可将那些一起使用的数据重新放置在同一个高速缓存线或内存页上,从而改善程序的空间局部性.
集成回收带来大的停顿,增加内存使用要求.
引用计数为每次指针运算引用常量开销.

可达性

把所有不需对任何指针解引用就可被程序直接访问的数据称为根集.
java中,一个程序的根集由所有的静态字段成员和栈中的所有变量组成.
对任意一个对象,如指向它的一个引用被保存在任何可达对象的字段成员或数组元素中,则这个对象本身也是可达的.
为使垃圾回收器能找到正确的根集,优化编译器可做如下的处理:
1.编译器可限制垃圾回收机制只能在程序中的某些代码点上被激活.
2.编译器可写出一些信息供垃圾回收器恢复所有的引用.
3.编译器可确保当垃圾回收器被激活时,每个可达对象都有一个引用指向它的基地址

一旦某个对象变得不可达,它就不可能再次变得可达.
下面是一个增变者程序改变可达对象集合的四种基本操作:
1.对象分配
操作由存储管理器完成.向可达对象集中添加成员.
2.参数传递和返回值
对象引用从实在参数传递到相应的形式参数,也可从返回结果传回给调用者.
这些引用指向的对象仍然是可达的.
3.引用赋值
对于引用u和v,形如u=v的赋值语句有两个效果.
u现在是v所指对象的一个引用.
只要u可达,v也可达.
u中原来的引用丢失了.如这个引用是指向某一可达对象的最后一个引用,则那个对象就变成不可达的.
4.过程返回
一个过程退出时,保存其局部变量的活动记录将被弹出栈.
如这个活动记录保存了某个对象的唯一引用,那个对象就变得不可达.
如刚刚变得不可达的对象保存了指向其他对象的唯一引用,这些对象也不可达.


两种寻找不可达对象的基本方法.
可捕获可达对象变得不可达的转变时刻,
也可周期性地定位出所有可达对象,推出所有其他对象都是不可达的.

引用计数垃圾回收器

使用引用计数的垃圾回收器时,每个对象需有一个用于存放引用计数的字段.
引用计数可按下面方法进行维护
1.对象分配
新对象的引用计数被设置为1
2.参数传递
被传递给一个过程的每个对象的引用计数加1
3.引用赋值
如u和v都是引用,对u=v.
v指向对象的引用计数加1,u本来指向的原对象的引用计数减1
4.过程返回
一个过程退出时,该过程活动记录的局部变量中所指向的对象的引用计数减1.
如多个局部对象存放了指向同一对象的引用,每个导致其引用计数减1
5.可达性的传递丢失
一个对象的引用计数变成0时,该对象中的各个引用所指向的每个对象的引用计数减1

引用计数缺陷:
1.不能回收不可达的循环数据结构
2.开销大

每一次引用赋值,每个过程的入口和出口处,都会增加一个额外运算.
延期引用计数,引用计数不包括来自程序根集的引用.
除非扫描整个根集仍没找到指向某一对象的引用,否则这对象不会被当作垃圾.

不会引起长时间停顿,回收可延期执行.
每次操作常量耗时增加.

基于跟踪的回收的介绍

不在垃圾产生时就回收,而是周期性运行.
寻找不可达对象并收回它们的空间.
通常是空闲空间被耗尽或空闲空间数量低于某个阀值时启动.

基本的标记-清扫式回收器

直接的全面停顿的算法.
找出所有不可达的对象,将它们放入空闲空间列表.
// 标记-清扫式垃圾回收
输入:一个由对象组成的根集,一个堆和一个被称为Free的包含了堆中所有未分配存储块的空闲空间列表.所有空间块都用边界标记进行标识,指明它们的空闲/已用状态和大小.
输出:在删除了所有垃圾之后的经过修改的Free列表
方法:
列表Free保存了已知的空闲对象.
一个名为Unscanned的列表保存了我们已经确定可达的对象,
但我们还没考虑这些对象的后继对象的可达性.
也即,还没扫描这些对象来确定通过它们能到达哪些对象.
列表Unscanned最初为空,
每个对象包括一个比特,用来指明该对象是否可达.
算法开始前,所有已分配对象的reched位都被设为0

// 标记阶段
// 把被根集引用的每个对象的reached位设置为1,并把它加入到Unscanned列表中
while(Unscanned != 空集)
{
    
    
	从Unscanned列表中删除某个对象o;
	for(在o中引用的每个对象o')
	{
    
    
		if(o'尚未被访问到)
		{
    
    
			将o'的reached位设置为1;
			将o'放到Unscanned中;
		}
	}
}

// 清扫阶段
Free = 空集;
for(堆区中的每个内存块o)
{
    
    
	if(o未被访问到)
		将o加入到Free中
	else
		将o的reached位设置为0
}

基本抽象

所有基于跟踪的算法都计算可达对象集合,然后取这个集合的补集
因此,内存按下列方式循环使用:
1.程序[或增变者]运行并发出分配请求
2.垃圾回收器通过跟踪揭示可达性
3.垃圾回收器收回不可达对象的存储空间

存储块的四种状态:
空闲的,未被访问的,待扫描的,已扫描的.
一个存储块的状态可存储在该块内部,也可用垃圾回收算法的某个数据结构隐含地表示

请添加图片描述

1.空闲的
存储块处于空闲状态表示它可被分配,空闲块内不会存放任何可达对象
2.未被访问的
除非通过跟踪证明存储块可达,默认是不可达的.
垃圾回收过程的任何时刻,如还没确定一个块的可达性,该块就处于未被访问状态.
一个存储块被存储管理器分配出去时,它的状态就设置为未被访问的.
一轮垃圾回收之后,可达对象的状态仍然会被重置为未被访问状态,以准备下一轮处理.
3.待扫描的
已知可达的存储块要么处于待扫描状态,要么处于已扫描状态.
如已知一个存储块是可达的,但该块中的指针还没被扫描,则该块就处于待扫描状态.
发现某个块可达时,就发生一个从未被访问状态到待扫描状态的转换.
4.已扫描的
扫描一个对象时,检查其内部的各个指针,沿着这些指针找到它们引用的对象.
如引用指向一个未被访问的对象,则该对象被设为待扫描状态.
对一个对象的扫描结束时,这个对象被放入已扫描状态.
一个已扫描的对象只能包含指向其他已扫描或待扫描对象的引用.

不再有对象处于待扫描状态时,可达性计算就完成了.
到最后仍然处于未被访问状态的对象确实是不可达的.
垃圾回收器收回它们占用的空间,将这些存储块置于空闲的状态.
为准备下一轮垃圾回收,
处于已扫描状态中的对象将回到未被访问状态.

请添加图片描述

标记-清扫式算法的优化

基本的标记-清扫式算法的最后一步代价很大.
一个优化算法用一个列表记录了所有已分配的对象.
需将不可达对象的存储返回给空闲空间.
为找出不可达对象的集合,可求已分配对象和可达对象之间的差集.
// Baker的标记-清扫式回收器
输入:一个由对象组成的根集,一个堆区,一个空闲列表Free,一个名为Unreached的已分配对象的列表
输出:经过修改的Free列表和Unreached列表.Unreached列表保存了被分配的对象.
方法:
Free,Unreached,Unscanned,Scanned的四个列表,分别保存了处于空闲,未被访问,待扫描和已扫描状态上的所有对象.
假定每个对象中都包含了一些二进制位,指明该对象处于上述四个状态的哪一个.
最初,Free就由存储管理器维护的空闲列表
所有已分配的对象都在Unreached列表中[这个表同时也由存储管理器在为对象分配存储块时维护]

Scanned = 空集;
Unscanned = 在根集中引用的对象的集合;并将这些对象对象从Unreached中删除;
while(Unscanned != 空集)
{
    
    
	将对象从Unscanned移动到Scanned;
	for(在o中引用的每个对象o')
	{
    
    
		if(在o'在Unreached中)
		{
    
    
			将o'从Unreached移动到Unscanned中;
		}
	}
}
Free = Free U Unreached;
Unreached = Scanned;
	假设存储管理器创建新对象时,它同样会被移除Free列表,加入到Unreached列表.

标记并压缩的垃圾回收器

进行重新定位的垃圾回收器会在堆内移动可达对象以消除存储碎片.
可达对象占用的空间要大大小于空闲空间.
因此,
标记出所有的"窗口"后并不一定要逐个释放这些空间.
另一个有吸引力的做法是将所有可达对象重新定位到堆区的一端,
使堆区的所有空闲空间成为一个块.
将所有可达对象放在一段连续的位置上可减少内存空间的碎片,使得它更容易存储较大的对象.

存在多种进行重新定位的回收器,不同之处在于它们是在本地进行重新定位还是在重新定位前预留了空间.
1.本节描述的标记并压缩回收器在本地压缩对象.
本地重新定位可降低存储需求.
2.拷贝回收器,把对象从内存的一个区域移到另一个区域.
保留额外的空间用于重新定位可使得一发现可达对象就立刻移动它.

下述算法标记并压缩垃圾回收器有3个阶段
1.标记阶段
2.第二阶段,算法扫描堆区中的已分配内存段,并为每个可达对象计算新的地址.
新地址从堆的最低端开始分配,
因此在可达对象之间没空闲存储窗口.
每个对象的新地址记录在一个名为NewLocation的结构中.
3.算法将对象拷贝到它们的新地址,更新对象中的所有引用.
使之指向新地址,新地址可在NewLocation中找到.
// 一个标记并压缩的垃圾回收器
输入:一个由对象组成的根集,一个堆,及一个标记空闲空间的起始位置的指针free
输出:指针free的新值
方法:
1.一个Unscanned列表
2.所有对象的reached位也和之前相同
初始时刻,所有的对象都是未被访问的.
3.指针free,标记了堆区中未分配空间的开始位置
4.NewLocation表,这个结构可是任意一个实现了如下两个操作的散列表,搜索树或其他数据结构.
a.将NewLocation(o)设为对象o的新地址
b.给定对象o,得到NewLocation(o)的值

// 一个标记并压缩回收器
Unscanned = 根集引用的对象的集合;
while(Unscanned != 空集)
{
    
    
	从Unscanned中移除对象o;
	for(在o中引用的每个对象o')
	{
    
    
		if(o'是未被访问的)
		{
    
    
			将o'标记为已被访问的;
			将o'加入到列表Unscanned中;
		}
	}
}

// 计算新的位置
free = 堆区的开始位置;
for(从低端开始,遍历堆区中的每个存储块o)
{
    
    
	if(o是已被访问的)
	{
    
    
		NewLocation(o) = free;
		free = free + sizeof(o);
	}
}

for(从低端开始,堆区中的每个存储块o)
{
    
    
	if(o是已被访问的)
	{
    
    
		for(o中的每个引用o.r)
		{
    
    
			o.r = NewLocation(o.r);
		}

		将o拷贝到NewLocation(o);
	}
}

for(根集中的每个引用r)
{
    
    
	r = NewLocation(r);
}

请添加图片描述

拷贝回收器

拷贝回收器预先保留了可将对象移入的空间,因而解除了跟踪和发现空闲空间间的依赖关系.
整个存储空间被划分为两个半空间A和B.
增变者在半空间之一内分配内存,直到它被填满.
此时增变者停止,垃圾回收器将可达对象拷贝到另一个半空间,比如B.
当垃圾回收完成时,两个半空间的角色对换.
增变者可继续运行,并在半空间B中分配对象.
下一轮垃圾回收将把可达对象移动到A
输入:一个由对象组成的根集,一个包含了From半空间和To半空间的堆区,其中From半空间包含了已分配对象,To半空间全部是空闲的.
输出:最后,To半空间保存已分配的对象.free指针指明了To半空间中剩余空闲空间的开始位置.Fron半空间此时全部空闲.
方法:
算法在From半空间找出可达对象,访问它们时立刻把它们拷贝到To半空间.

CopyingCollector()
{
    
    
	for(From空间中的所有对象o)
		NewLocation(o) = NULL;
	unscanned = free = To空间的开始地址;
	for(根集中的每个引用r)
		将r替换为LookupNewLocations(r);
	while(unscanned != free)
	{
    
    
		o = 在unscanned所指位置上的对象;
		for(o中的每个引用o.r)
		{
    
    
			o.r = LookupNewLocation(o.r);
		}
		
		unscanned = unscanned +sizeof(o);
	}
}

LookupNewLocation(o)
{
    
    
	if(NewLocation(o) = NULL)
	{
    
    
		NewLocation(o) = free;
		free = free + sizeof(o);
		将对象o拷贝到NewLocation(o);
	}
	return NewLocation(o);
}

请添加图片描述

停顿垃圾回收

可以按时间来分割工作任务,使垃圾回收和增变者的运行交错进行--增量回收
也可按空间来分割工作任务,每次只完成一部分垃圾的回收--部分回收

增量式回收器将可达性分析任务分割成若干较小单元,允许增变者和这些任务单元交错运行.
部分回收一个有名算法是世代垃圾回收.
根据对象已分配时间的长短来划分对象,且较频繁地回收新创建的对象.
列车算法,每次回收一部分垃圾.适合回收较为成熟对象.

增量式垃圾回收

将每次回收后留下的垃圾称为漂浮垃圾.
增量式回收器不应遗漏在回收周期开始就已经不可达的垃圾.

首先以不可中断的方式处理程序的根集.
找到了待扫描对象的初始集合后,增变者的动作与跟踪步骤交错进行.
任何可能改变可达性的增变者的动作被记录在一个副表,
使得回收器在继续执行时可做出必要的调整.
如在跟踪完成前空间就耗尽,
则回收器将不再允许增变者执行,并完成跟踪过程.
跟踪完成后,空间回收以原语的方式完成.

增量回收的准确性
一旦对象成为不可达,就不能再变成可达.
因此,垃圾回收和增变者运行时,可达对象的集合只能:
1.因为垃圾回收开始后的某个新对象的分配而增长
2.因为失去指向已分配对象的引用而缩小
如每次增变者丢失了一个指向某个对象的引用后都重新确定该对象的可达性,则开销会变得很大.因此增量式回收器不试图在跟踪结束时回收所有垃圾.任何遗留的垃圾应该是Lost对象的一个子集.

简单的增量式跟踪
一种找到集合R U New的上界的简单跟踪算法.
跟踪期间,增变者行为如下:
1.在垃圾回收开始前,已经存在的所有引用被保留.
2.新创建的对象被认为是可达的,放置在待扫描状态.

增量式可达性分析

增变者的动作可能会违反这个算法的一个关键不变式:
一个已扫描对象中的引用只能指向已扫描或待扫描对象,这些引用不可指向未被访问对象.
要得到一个准确且正确的增量式跟踪方法,关键在于需注意所有将一个指向当前未被访问对象的引用从一个尚未扫描的对象中拷贝到已扫描对象中的动作.
为了截获可能有问题的引用传递,算法可在跟踪过程按下列方式修改增变者的动作:
1.写关卡
截获把一个指向未被访问的对象o的引用写入一个已扫描对象o1的运算.
此时,将o作为可达对象并放入待扫描集合.
另一种方法是将被写对象o1放回待扫描对象集合

可行
2.读关卡
截获对未被访问或待扫描对象中引用的读运算.
只要增变者从一个处于未被访问或待扫描状态中的对象读取一个指向对象o的引用时,
就将o设为可达的,将其放入待扫描对象集合

代价高
3.传递关卡
截获在未被访问或待扫描对象中原引用丢失的情况.
只要增变者覆写一个未被访问或待扫描对象中的引用时,
保存即将被覆写的引用并将其设为可达的,将这个引用本身放入待扫描集合

会保留很多不可达对象

写关卡的实现:
1.增变阶段记录下所有被写入到已扫描对象中的新引用
可将这些引用放入一个列表
2.记住写运算发生的位置
a.可只记录包含了被写字段的对象,
不需记录被写的精确地址或被写的对象及字段
b.可将地址空间分成固定大小的块,块被称为卡片.
使用一个位数组记录曾经被写入的卡片
c.可选择记录下包含了被写位置的页

猜你喜欢

转载自blog.csdn.net/x13262608581/article/details/123948780