文章目录
- 十二、特定语言相关内容
- 12.1 终结
- 12.1.1何时调用终结方法
- 12.1.2 终结方法应由哪个线程调用
- 12.1.3 是否允许终结方法彼此之间的并发
- 12.1.4 是否允许终结方法访问不可达对象
- 12.1.5 何时回收已终结对象
- 12.1.6 终结方法执行出错时应当如何处理
- 12.1.7 终结操作是否需要遵从某种顺序
- 12.1.8 终结过程中的竞争问题
- 12.1.9 终结方法与锁
- 12.1.10 特定语言的终结机制
- 12.2 弱引用
- 12.2.1 其他动因
- 12.2.2 对不同强度指针的支持
- 12.2.3 使用虚对象控制终结顺序
- 12.2.4 弱指针置空过程的竞争问题
- 12.2.5 弱指针置空时的通知
- 十三、并发算法预备知识
- 13.1 硬件
- 13.1.1 处理器与线程
- 13.1.2 处理器与内存之间的互联
- 13.1.3 内存
- 13.1.4 高速缓存
- 13.1.5 高速缓存一致性
- 13.2 硬件内存一致性
- 13.2.1 内存屏障与先于关系
- 13.2.2 内存一致性模型
- 13.3 硬件原语
- 13.3.1 比较并交换
- ABA问题
- 13.3.2 加载链接/条件存储
- 13.3.3 原子算术原语
- 13.3.4 检测-检测并设置
- 13.3.5 更加强大的原语
- 13.3.6 原子操作原语的开销
- 13.4 前进保障
- 前进保障与并发回收
- 13.9 事务内存
- 13.9.1 何为事务内存
- 13.9.2 使用事务内存助力垃圾回收器的实现
- 13.9.3 垃圾回收机制对事务内存的支持
- 附录
十二、特定语言相关内容
许多编程语言都具备垃圾回收能力,因此开发者们自然希望将这一能力拓展到自动内存管理之外的更多场景。
在这一思想的指导下,研究者们发展出了多种在应用程序和回收期间进行交互的方式,这些交互方式拓展了编程语言的基本内存管理语义。
12.1 终结
使用垃圾回收器进行自动内存管理可以为大多数对象提供合适的内存管理语义。但是,如果托管对象引用了托管范围之外的其他对象,自动垃圾回收器将无能为力,进而可能导致资源的泄漏。
一个典型的案例是程序所打开的文件:
操作系统接口通常会以一个被称为“文件描述符”的小整数来表示一个文件,该接口限制了给定进程在同一时刻可以打开的最大文件数量。
编程语言通常会将每个打开的文件封装成对象,以供开发者管理文件流。大多数情况下,当程序完成某一文件流的处理之后,开发者可以通过运行时系统来关闭文件流,后者会调用操作系统接口来关闭对应的文件描述符,以便将其复用。
但是,如果文件流被程序中的多个组件共享,运行时系统将很难确定在何时所有组件都能完成文件流的处理。
如果使用某一文件流的每个组件在使用完毕后都将指向文件流的引用置空,则当该文件流对象不再被任何组件引用时,回收器(终究)会探测到这一情况。
图12.1展示了这一状态,我们或许可以借助于回收器来实现文件描述符的关闭。
为达到这一目的,我们需要在给定对象不可达之后执行用户自定义的行为——更加确切地讲,应该是对象不再从赋值器可达时。我们将这一过程称为终结。典型的终结机制允许用户指定一段代码,即 终结方法(finaliser) ,回收器在判定特定对象不可达时将调用该方法。
终结机制的典型实现方式是由运行时系统维护一张特殊的终结表,该表中所记录的是包含(由开发者指定的)终结方法的对象。赋值器无法访问到该表但回收器可以。我们将从终结表可达但从赋值器不可达的对象称为 终结可达(finaliser-reachable) 对象。
图12.2展示的情况与图12.1一致,唯一的不同之处在于其增加了终结方法。由于应用程序可能提前关闭文件,所以终结方法在关闭文件描述符之前应当进行条件判断。
在引用计数系统中,回收器可以在释放对象之前查找终结表,并判断该对象是否需要终结,如果需要,则回收器执行终结方法,并将其从终结表中移除。类似地,追踪式回收系统也可以在追踪阶段完成之后检查终结表,找出其中的未标记对象并执行终结方法,然后将其从终结表中移除。
终结机制有多种不同的实现策略,它们之间会存在一些细微的差别,接下来我们将介绍终结机制的实现方式以及可能遇到的问题。
12.1.1何时调用终结方法
一种实现策略是:
一旦回收器发现需要终结的不可达对象,便立即调用其终结方法。但该策略的问题在于,回收的中间状态可能无法执行一般用户代码,例如此时可能无法进行新对象的分配。因此,大多数终结机制都在回收过程之后才调用终结方法。
回收器可以使用队列来组织待终结对象。为避免在回收过程中分配这一队列,回收器可以将终结表划分为两个分区:
-
一个分区用于容纳待终结对象
-
另一个则用于记录包含终结方法但尚未入队(即依然存活——译者注)的对象。
当回收器需要将某一对象加入待终结队列时,它只需要将该对象移动到终结表的待终结分区。一种简单但稍显低效的方案是为终结表中的每个对象关联一个待终结位,此时回收器就需要通过扫描终结表来找到待终结对象。为避免这一扫描过程,我们可以将表中的待终结对象放置在一起,此时回收器便可通过换位的方式来添加新的待终结对象。
终结方法通常会影响线程之间的共享状态。我们不能因为待终结对象即将消亡而将终结方法的操作范围限定在对象内部。例如,终结方法可能需要访问全局数据以实现某一共享资源的释放,该操作很可能需要加锁。这也是回收器不能在回收阶段调用终结方法的另一个原因,即可能引发死锁。更加糟糕的一种情况是,如果运行时系统提供了可重入锁(即允许线程重复获取其已经持有的锁),终结方法便会绕开死锁进入临界区,并悄无声息地破坏应用程序的状态。
即使将终结方法的调用推迟到回收完成之后,回收器依然会面临一些与在回收过程中调用终结方法相同的问题。终结方法的调用时机之一是在回收过程刚刚结束时,此时赋值器线程尚未恢复执行。尽管该策略可以提升终结操作的时效性,但却会增大赋值器的停顿时间。除此之外,如果终结方法会与其他持有锁的线程发生交互,或者终结方法需要通过加锁的方式争用全局数据结构,那么线程之间的交互便可能出现问题,甚至引发死锁。
最后需要考虑的问题是,编程语言的终结机制不应当限制回收器可能使用的回收技术。
12.1.2 终结方法应由哪个线程调用
对于支持多线程的编程语言,终结机制最自然的一种实现策略是在后台运行一个终结线程,该线程会在赋值器线程执行过程中异步地调用待终结对象的终结方法。此时终结方法便可能会与赋值器并发执行,因此必须确保其在并发环境下执行时的安全性。
一种特别需要关注的情形是:
- 当终结线程正在执行类型T某一实例的终结方法时,赋值器线程有可能在相同时刻执行其另一个实例的分配与初始化。
在这一场景下,任何对共享数据结构的操作都必须以同步方式执行。对于只支持单线程的编程语言,由哪个线程来调用终结方法显然不会是一个问题,但此时的问题将主要集中在何时调用终结方法上。
我们前面已经指出了确定执行时机的困难所在,因此在单线程环境下,唯一可行且安全的方案是对待终结对象进行排队,并在显式控制之下调用其终结方法。而在多线程系统中,正如我们前面所提到的,最佳的解决方案是使用独立的终结线程来执行终结方法,从而避免锁相关问题。
12.1.3 是否允许终结方法彼此之间的并发
如果在大型并发应用中使用终结机制,则可能需要更多的终结线程来确保可伸缩性。
因此从编程语言的设计角度来看,不仅应当允许终结线程之间并发执行,而且还应当允许终结线程和赋值器线程之间并发执行。
因此,开发者通常必须谨慎地设计终结函数来应对可能出现的并发情况,只要能够确保终结线程与赋值器线程并发执行时的安全性,终结线程彼此之间的并发便不会存在更多问题。
12.1.4 是否允许终结方法访问不可达对象
在许多场景下,用户都希望终结方法能够访问待回收对象。
在图12.2所描述的文件流案例中,我们可以很自然地将操作系统文件描述符(一个小整数)放在文件流对象的某个域中,此时终结方法最简单的实现方案便是读取该域,并调用操作系统接口来关闭文件(在此之前可能需要刷新缓冲区中尚未输出的数据)。
但是,如果终结方法无法访问对象,而只能执行一小段未关联任何数据的代码,则终结机制的效用将大打折扣——终结方法需要基于特定的上下文进行工作。这一上下文信息在在函数式语言中可能是一个闭包,在面向对象语言中可能是一个对象。因此终结机制需要为终结方法提供特定的参数。
总的来说,允许终结方法访问即将终结的对象的灵活性更高。假设终结方法是在回收完成后执行,这一策略意味着待终结队列中的对象必须存活到回收结束之后。由于终结方法可能访问任何从其上下文可达的对象,所以回收器必须避免回收所有从待终结对象可达的对象,这便导致追踪式回收器必须额外引入两个阶段:
-
第一阶段找出所有待终结对象
-
第二阶段对待终结对象进行追踪并保留所有从待终结队列可达的对象
基于引用计数的回收器可以在对象加人到待终结队列时增加其引用计数,相当于是待终结队列引用了该对象并为其贡献了引用计数。
一旦终结线程将对象从待终结队列移除并调用其终结方法,其引用计数便会降低为零,进而得到回收,但在此之前,所有从该对象可达的对象都不会得到回收。
12.1.5 何时回收已终结对象
终结方法会持有待终结对象的引用,这意味着其可能会将该引用添加到某一全局数据结构中。这一现象被称为 复活(resurrection) 。尽管复活机制本身并不存在问题,但得到复活的对象通常不会再次终结。
其原因在于,系统通常难以探测到引发对象复活的写操作,且将对象加入终结表的操作通常是对象分配以及初始化过程的一部分。例如 Java便保证对象不会被终结超过一次。对于支持以更加动态的方式建立终结任务的编程语言,开发者可以请求运行时系统对某个已复活对象再次进行终结,因为编程语言允许对任何对象执行这样的操作。
如果已终结对象未被复活,则其将在下一轮回收过程中得到回收。对于使用分区策略的回收器(例如分代回收器),已终结对象可能会驻留在某个回收间隔较长的空间内,因此终结方法可能会显著延长对象的物理寿命。
12.1.6 终结方法执行出错时应当如何处理
如果应用程序以同步的方式来执行终结操作,则开发者很容易对其中的终结操作进行包装以便捕获其所返回的错误或者抛出的异常。
但如果终结方法以异步的方式执行,则最好的方法是捕获异常并将其记录,同时将其交由应用程序在合适的时间处理。
12.1.7 终结操作是否需要遵从某种顺序
终结顺序与应用程序密切相关。
考虑图12.3所示的场景,Bufferedstream类的实例引用了一个类型为Filestream的对象,后者持有一个已经打开的操作系统文件描述符。两个对象都需要终结,但是在Filestream对象关闭文件描述符之前,应用程序必须确保Bufferedstream 实例先将缓冲区中的数据刷新到文件中。
对于类似于图12.3的分层设计模型,合理的终结语义显然应当是从上到下逐层进行终结。由于下层对象从上层对象可达,所以对象之间自动依照这种顺序进行终结是有可能的。
需要注意的是,如果我们限制了对象的终结顺序,则终结过程的最终完成可能需要很久,因为每次回收仅可以终结位于某一层次的对象。也就是说,每次回收过程中我们仅可以终结那些不从其他待终结对象可达的对象。
这一策略存在一个显著的缺陷:
- 它无法处理由多个待终结对象组成的环。
但是这一情况通常极少发生,因而确保对象之间依照可达性顺序来进行终结通常更加简单有效,也就是说,如果对象B从对象A可达,则系统应当先终结对象A。
一旦待终结对象组成环,开发者必须进行手工干预。诸如弱引用(参见第12.2节)等机制虽然较为复杂,但对解决这一问题也可能有所帮助。
通用的解决方案应当是在设计时便将需要终结的域从对象中拆分,进而打破待终结对象可能构成的环。
例如在图12.4a中,对象A和B都存在终结方法且彼此相互引用。为避免终结过程中环的出现,我们可以将B拆分为B和B’,其中B不包含终结方法而B’包含(参见图12.4b),此时尽管对象A和B依然相互引用,但重要的是B’并未引用A。此时回收器便可依照可达顺序先终结A,再终结B’。
12.1.8 终结过程中的竞争问题
即使终结操作的执行顺序并无特定的顺序要求,依然存在一种微妙的竞争问题,这一问题导致直接使用终结过程会存在一些十分隐晦的错误。重新考虑图12.2所示的Filestream案例。
假设在赋值器最后一次向文件中写入数据的过程中,Filestream对象的writeData方法会先获取文件描述符,然后再执行write系统调用向其中写入数据。
由于在write系统调用之后Filestream对象便会死亡,所以编译器可能会进行优化,即在执行write系统调用之前Filestream对象便已经不可达。如果回收过程发生在write操作的执行过程中,则Filestream对象的终结方法可能会在write调用真正引发操作系统的数据写入之前便将描述符关闭。这一问题较为棘手,且Boehm根据经验认为此类错误是普遍存在的,但是由于可能出错的时间窗通常较短,所以问题很少暴露出来。
在Java语言中,这一问题的解决方案我们在前面已经提到过,即一旦对象的锁被持有,则回收器将其判定为存活,且该对象的终结方法只能同步执行。
但更加通用的避免竞争的解决方案是强制编译器将Filestream对象的引用保持更长时间,即把该对象的引用传递给稍后的某一调用(该调用可以不执行任何操作),且编译器不会将这一调用优化掉。.NET框架提供了这一能力,例如C#中的cc.KeepAlive函数。
12.1.9 终结方法与锁
Boehm 提出,终结方法的目的通常是通过更新某些全局数据结构来释放与不可达对象相关的资源,所以此类数据结构是全局的,因而对它们的访问通常需要引入一些同步机制。
关闭已打开的文件或者其他软件组件(此处是指操作系统)句柄的操作通常会引发隐式同步,但对程序数据结构的更新则必须引入显式同步,因为对于程序中大多数代码而言,终结方法的执行是异步的。
开发者可以通过两种策略来处理这一情况。
- 一种策略是令所有针对全局数据结构的访问都使用同步操作,即使是在单线程情况下也必须如此(因为终结方法可能会在全局数据结构的某一中间状态对其进行操作)。
这要求编程语言不得将包含终结方法的、明显私有的对象所需的同步操作省略。
- 另一种策略是回收器仅将待终结对象排队,但不执行真正终结操作。
某些编程语言的内建终结机制本身就使用这种排队策略,但也有一些并非如此,此时开发者便需要手工将待终结对象添加到自定义终结队列中。除此之外,开发者还需要在适当的(安全)位置增加代码来处理终结队列中的对象。
由于终结方法的执行可能会导致新的待终结对象被添加到终结队列中,所以队列处理代码应当一直处理到整个队列变空为止,如果处理过程中有一些重要的资源需要立即释放,处理代码可以强制发起回收。
算法12.1即为这一操作的伪代码实现。上文提到,执行这一算法的线程不应当持有任何待终结对象的锁,这便限制了终结队列处理代码可以安全执行的位置。
12.1.10 特定语言的终结机制
(笔记只截取Java与C++部分)
Java
object类位于Java类继承体系的最上层,该类提供了一个名为finalize的方法,但该方法并不做任何事情。子类可以通过重载该方法的方式来请求终结。
Java并不保证终结顺序,且其在并发方面唯一可以提供的保障是终结方法仅会在不持有任何用户可见锁的上下文中执行。这也意味着终结方法可能在多个线程中并发执行,尽管手册并未对这一情况进行说明。
如果finalize方法抛出异常,Java系统会将其忽略并继续执行。如果待终结对象并未复活,则其将在后续的回收过程中得到回收。
Java使用java.lang.ref API支持用户控制的终结机制,这将在12.2节进行描述。
C++
C++语言提供了 析构函数(destructors) 来处理对象的销毁,其相当于初始化新对象的构造函数的逆操作。大多数析构函数的主要目的是显式释放内存并将其归还给分配器。
除此之外,开发者还可以在析构函数中添加任意代码,因而其可以完成文件关闭等任务。开发者也可将析构函数作为 钩子(hook) 来实现非环状共享对象的引用计数回收。
实际上,C++模板允许通过一种通用的智能指针机制来实现引用计数。析构函数中的大多数工作通常都与内存释放有关,这正是垃圾回收器所要处理的任务,因此对C++来说,几乎无需引人额外的终结机制。析构函数中的内存释放通常相对安全且直接,这不只是因为它不会引入用户可见的锁那么简单。
但是,一旦开发者需要面临真正的终结场景,则前面提到的所有问题,包括锁相关处理、终结方法的调用顺序等,都将重新出现并需要开发者去解决,要正确处理这些问题通常较为困难。
.NET
在C++现有的析构函数之外,.NET framework为C、C++及frame work所支持的其他语言增加了终结方法。析构函数的调用是确定性的,它是由编译器生成的代码所调用的,其目的在于当程序离开对象作用域时执行必要的对象清理。
析构函数可能会调用其他对象的析构函数,但是所有的析构函数都与托管资源(即.NET运行时系统控制的资源,主要是内存)的释放有关。而终结方法的目的则在于显式回收非托管资源,例如打开的文件句柄等。如果某类对象需要终结,则对于编译器生成代码显式回收对象的场景,析构函数需要调用终结方法。如果对象最终得到隐式回收,则终结方法的调用最终是由回收器来完成的,此时析构函数将不会被调用。
不论如何,.NET framework中的终结机制与Java十分类似,但其最终形态却混合了C++的析构函数以及某些与Java十分类似的终结机制,其终结方法的调用既可能是同步的,也可能是异步的。
12.2 弱引用
垃圾回收机制通过指针链的可达性来判定哪些对象需要保留、哪些对象需要回收。对于自动内存管理而言这是一种合理的策略,但其在某些场景下依然可能遇到问题。
例如,某些编译器会令同名变量(例如xyz)指向相同的名称字符串实例,此时如果要比较两个变量的名称是否相等,只需比较它们指向自身名称字符串的指针是否相等。为达到这一目的,编译器需要通过一张表来记录所有已经使用过的变量名。该表中的字符串即为变量名的 规范实例(canonical instance) ,因此该表在某些情况下也被称为 规范化表(canonicalisation tables) 。如果某个变量名在运行期间不再使用(即该变量名不再对应任何数据结构),但其所对应的规范实例将依然存在。运行时系统或许可以将仅被规范化表所引用的字符串回收,但可靠地探测到这一情况却比较困难。
弱引用(wak reference) (也称弱指针(weak pointer))可以较好地解决这一问题。
如果从根出发,经由一系列由 强引用(strong references) 构成的指针链可以到达某一对象,则称该对象 强可达(strongly reachable) 。只要对象依旧强可达,则指向该对象的弱引用便可一直保持其引用关系。
但是,一旦从根出发到达该对象的任意一条指针链都包含至少一个弱引用,则回收器可以将该对象回收,并将所有直接引用该对象的弱引用设置为空。我们将此类对象称为 弱可达(weakly-reachable) 。
我们即将看到,回收器在回收弱可达对象时还会执行一些额外动作,例如通知赋值器某一弱引用已被设置为空。
在上文提到的使用规范化表来维护变量名的案例中,如果将从规范化表到变量名的引用设置为弱引用,则一旦用于表示某一变量名的字符串不存在强引用,回收器便可回收该字符串并将其在规范化表中的弱引用设置为空。
需要注意的是,规范化表的设计也需要将这一因素考虑在内,即程序可能偶尔需要清理规范化表,或者值得进行清理。
例如,如果规范化表使用哈希表的方式实现,且哈希表里每个桶中均维护有一条链表,则已经死亡的弱引用(即已经被置空的弱引用)可能会占用链表中的部分节点,因此我们可能需要偶尔将这些引用从哈希表中清除。这同时也说明了通知机制的作用:我们可以利用通知来触发清除逻辑。
强引用和弱引用的实现方式。
首先考虑追踪式回收器中的场景。为支持弱引用,回收器在首轮遍历过程中应当避免对弱引用的目标进行追踪,但需对其进行记录以便第二次遍历。
第一次遍历完成之后,回收器将找出所有经强引用指针链可达的对象,即强可达对象。在第二次遍历过程中,回收器将检查第一轮遍历过程中发现并记录的弱引用:
- 如果其所引用的对象依旧强可达,则回收器保留该弱引用(复制式回收器还需将其更新到目标对象的最新副本)。
- 否则,回收器将把该弱引用设置为空,从而确保其目标对象不再可达。第二轮遍历完成后,回收器便可回收所有不可达对象。
回收器必须能够识别弱引用,可以在引用中通过一位来将其标记为弱引用。
- 例如对于依照字节定址的、按照字来对齐的机器,指针的低两位必然为零,我们便可将其中的一位设置为1来表示弱引用。
该方案的缺陷在于每个解引用操作都要先清空其最低位以避免当前引用是弱引用。如果弱引用仅用于编程语言的特定受限场景,这一开销或许可以接受。某些语言及其实现可能会使用 带标签值(tagged value) 来实现这一策略,但这样一来弱引用又会引入一种新的标签类型。该方案的另一个缺陷在于,回收器必须找到所有指向待回收对象的弱引用并将其置空,因此回收器要么必须对根和堆进行第二次遍历,要么必须记录第一次遍历过程中发现的所有弱引用。
- 使用高位作为标签值,并对整个堆进行二次映射。
此时堆中的每一页都会在虚拟内存中的两个位置出现,一个位置是其原本的地址,另一个位置则是较高的(不同)地址。两个地址的唯一不同之处在于高位中用于区分弱引用的位有所不同。该方案可以避免在使用指针之前先进行掩码操作,且其检测弱指针的方式也十分简单高效。但其缺点在于,可用地址空间少了一半,这在较小的地址空间中将成为问题。
- 最通用的实现方案可能是使用间接方式,即提供专门的弱对象来持有弱引用。
弱对象方案的不足之处在于其透明性不足:
-
回收器和赋值器均需要一次显式解引用操作才能访问弱引用的目标对象,从而引人了间接访问开销。
-
如果我们需要在某一对象中引人弱引用,还需额外分配一个弱对象。但幸运的是,弱对象的特殊性只需要分配器和回收器关注即可,对于用户代码而言,它们与普通对象并无二致。
-
系统可以在对象头部设置一个特殊的位来区分弱对象。另外,如果对象包含一些用户自定义的追踪方法,则弱引用仍需要进行特殊处理。
在使用弱引用的真实场景下,系统必须提供一个原语,开发者可以通过该原语从对象O的强引用中获取其弱引用。如果使用弱对象来封装弱引用,弱对象类型通常需要提供一个构造函数,该函数可以从给定对象О的强引用中创建一个新的弱引用。系统甚至可以允许开发者改变弱对象中的引用域。
12.2.1 其他动因
弱引用可以用于解决某些编程问题,或者可以提供更加简单高效的解决方案。规范化表只是使用案例之一,另一个案例是用于管理在必要情况下可以恢复或者重建的数据缓存。
此类缓存的目的在于达到时间和空间上的某种平衡,但如何控制缓存大小却是一个比较困难的问题。而我们可以使用弱引用来管理缓存,回收器在发现缓存不再强可达之后可以将弱引用设置为空。此时回收器便可根据空间使用情况来调整缓存。
回收器还可以将对象从强可达到弱可达的状态变化通知给应用程序,后者可以在该对象得到回收之前执行适当的操作。该场景属于第12.1节所介绍的终结机制的推广。在其他条件之外,以适当的方式来组织弱引用有助于程序更好地控制对象终结方法的调用顺序。
12.2.2 对不同强度指针的支持
在强引用之外,弱引用可以泛化成多种不同强度的弱指针,它们可以处理上文所描述的多种问题。以引用强度为顺序的回收可以为每种强度级别关联一个正整数。
-
对于给定的整数a > 0:如果从根出发存在一条指针链可以到达某一对象,且该指针链中的所有指针强度均不小于α,则称该对象为α*可达。
-
可达(没有“*”号)意味着对象α*可达但并非(a+1)*可达。
-
如果某一对象的所有可达路径都存在至少一个强度为α的指针,且至少有一条路径不包含强度小于α的指针,则该对象为α可达。
-
描述强度的数字可以是任意的,因为我们只需要用其来表述强度的相对顺序,在后续表述中我们将用名称代替数字来描述强度的级别。
每种强度的引用通常会与回收器的特定行为相关联。在支持多种不同强度的弱引用的编程语言中,最知名的当属Java,其提供的引用类型从最强到最弱可以分为以下几种:
-
强引用(strong reference):普通的引用,具有最高的引用强度。回收器永远不会将强引用置空。
-
软引用(soft reference):回收器可以根据当前的空间使用率来判定是否需要将软引用置空。如果Java回收器将某个指向对象О的软引用置空,它必须在同一时刻自动将所有导致对象О强可达的软引用置空。这一规则可以确保在回收器将这些引用置空之后,对象将不再软可达。
-
弱引用(weak reference):一旦回收器发现某一(软*可达的)弱引用的目标对象变为弱可达,则回收器必须将该引用置空(从而确保其目标对象不再软*可达)。与软引用类似,一旦回收器将某个指向对象О的弱引用置空,则必须同时将所有其他导致该对象软*可达的软*可达弱引用置空。
-
终结方法引用(finaliser reference):我们将从终结表到待终结对象的引用称为终结方法引用。我们曾在第12.1节描述了Java的终结机制,此处再次进行描述是为了说明此类引用的相对强度。终结方法引用只在运行时系统内部出现,它并不会像弱对象一样暴露给开发者。
-
虚引用(phantom reference): Java中最弱的一种引用类型。虚引用只有与通知机制联合使用才具有一定意义,这是因为虚引用对象不允许程序经由该引用获取目标对象的引用,因此程序唯一可能的操作是将虚引用置空。程序必须显式地将虚引用置空来确保回收器将其目标对象回收。
Java语言中不同强度的引用并没有我们此处描述的这么多,但此处的每种语义却与语言规范所定义的每种弱引用相关联。
- 软引用允许系统对可调整的缓存进行收缩
- 弱引用可以用于规范化表或者其他场景
- 虚引用允许开发者控制回收的顺序以及时间
多强度引用的实现需要在回收过程中增加额外的遍历,但这些操作通常可以很快完成。
下面我们以Java的4种强度为例来描述复制式回收器对不同强度引用的处理方式。回收器应当以如下方式执行堆遍历过程:
-
从根开始,仅对强可达对象进行追踪并复制,同时找出所有的软对象、弱对象、虚对象(但不对这些对象进行追踪)。
-
如果必要,则自动将所有软引用置空。如果无需将软引用置空,则需对其进行追踪和复制,并找出所有的软*可达对象。对软可达对象进行追踪时可能会发现新的弱可达或虚可达对象。
-
如果弱引用的目标对象已被复制到目标空间,则更新该引用,否则将该引用置空。
-
如果尚未复制的对象中存在需要终结的对象,则将其加入终结队列,然后回到第1步,并以终结队列作为新的根进行追踪。需要注意的是,在第二轮执行过程中将不会再有需要终结的对象产生。
-
如果虚引用的目标对象并未得到复制,则将其加入ReferenceQueue里。然后回收器将从该对象开始完成虚*可达对象的追踪与复制。需要注意的是,回收器不会将任何虚引用置空,这一操作必须由开发者显式操作。
尽管上述步骤是以复制式回收器作为原型来描述的,但这一过程同样也适用于标—清扫回收。但是,为Java的弱引用语义设计引用计数版本的实现却显得相当困难。
一种实现策略是在正常的引用计数中忽略来自软引用、弱引用、虚引用的贡献(统称为非强引用),并在对象中使用一个独立的位来反映其是否存在非强引用来源。我们同样也可以通过一个独立的位来记录对象是否包含终结方法。除此之外,系统可能还需要通过一张全局的表来记录每个弱引用目标对象的引用来源。我们将该表称为反向引用表(Reverse Reference Table)。
由于引用计数并不会像追踪式回收器一样发起单独的回收调用,所以必须引入一些其他的启发式方法来判定何时置空软引用,而这一操作需要自动执行。在这一方案下,最简单的实现方案可能是将对象的软引用来源也(像正常引用一样)计入引用计数中,而当其强引用被置空时,回收器将使用启发式方法来判定是否需要回收该对象,以及是否需要处理其更弱的来源引用。
如果某一对象的正常引用(强引用)变为零,则该对象将被回收(同时减少其子节点的引用计数),除非其是非强引用的目标并需要终结。
如果对象头部的标记位显示其存在至少一个非强引用来源,则我们必须从反向引用表中找出其所有非强引用来源,并执行如下所示的流程来处理这些引用对象(Reference对象)。引用的处理应当依照从强到弱的顺序。
-
弱引用:将弱引用置空,并将需要终结的对象添加到待终结队列。
-
终结方法引用:将对象加入待终结队列,这一操作需要正常增加对象的引用计数,因此其引用计数将会恢复到1。与此同时,清空对象头部中包含有终结方法的位。
-
虚引用:如果目标对象存在终结方法,则无需任何操作。如果该对象存在来自虚对象的引用,则需将虚对象加入用户指定的队列中,同时标记其已经入队。为避免虚引用的目标对象被回收,需要增加目标对象的引用计数。当用户显式置空虚对象中的虚引用时,如果其已经入队,则减少目标对象的正常引用计数值,回收器在自动回收虚对象时也需如此。
除此之外有一些特殊的情况需要注意。当回收某一Reference对象时,我们需要将其从反向引用表中移除,同理,当用户显式清空Reference对象时也需执行相同的操作。
当回收器发现环状垃圾时还有一些额外的技巧。在对环状垃圾进行处理之前,我们需要先判断其中的某个对象是否存在软引用来源,若结果为真,则将环状垃圾全部保留,但仍需周期性地进行这一检测。如果环状垃圾中的所有对象都不存在软引用来源,但某些对象存在弱引用来源(即存在来自弱对象的引用),则我们需要自动将这些弱对象全部置空,并将所有需要终结的对象加入待终结队列。
最后,如果上述情况都未发生,但环中的某些对象存在虚引用来源,则我们需要保留整个环,并将引用环中对象的虚对象加入用户指定的队列中。如果环中的所有对象都不存在非强引用来源,且都不需要终结,则可以回收整个环。
12.2.3 使用虚对象控制终结顺序
假设有两个对象,A和B,我们希望它们的终结顺序是先A后B。
一种实现策略是创建虚对象A’来持有对象A的虚引用。除此之外,A’的类型应该是对Java的PhantomReference的扩展,其将持有一个指向对象B的强引用,以避免对象B被提早终结。图12.5演示了这一情况。
一旦对象A’(即对象A的虚引用来源)被加入到用户指定的队列,意味着对象A已经从应用程序不可达,并且A对应的终结方法已经运行过,这是因为终结队列可达要比虚可达的强度更高。
然后我们将虚对象A’指向A的虚引用置空,再将其指向B的强引用置空,如此一来,对象B的终结方法将在下一轮回收中得到调用。
最后我们再把虚对象从全局对象表中删除,则虚对象本身也将得到回收。我们很容易将该策略进行推广,进而实现三个或者更多对象的终结顺序控制,所付出的代价是在两两对象之间通过虚对象来施加终结顺序限制。
终结顺序的控制只能通过虚对象完成,弱对象无法胜任。对于图12.5所示的状态,如果我们将虚对象替换为弱对象,则当对象A不可达时,A’中的弱引用将被置空,且A’将被添加到用户指定的队列中。
此时我们可以将A’中指向B的强引用置空,但不幸的是,置空A’中弱引用的操作将发生在A的终结方法执行之前\,此时我们将很难知道A的终结方法何时执行完毕,因此对象B的终结方法可能会先得到执行。
故意将虚引用的强度设计得比终结方法引用更低,目的正在于确保只有当对象的终结方法执行完毕之后,其虚引用来源才会被加入到用户指定的队列。
12.2.4 弱指针置空过程的竞争问题
在12.1节我们提到,某些特定的编译优化可能会引发竞争,进而可能导致终结方法被过早地调用。弱指针也存在同样的问题,这一竞争也可能导致弱指针被过早地置空。
12.2.5 弱指针置空时的通知
在弱引用机制之上,某些应用程序可能需要在特定的弱引用被清空时得到通知(虚引用可以通知应用程序对象已被终结,而弱引用则可以通知应用程序对象可能将要终结),然后再执行某些适当的动作。
因此,弱引用机制通常也会支持通知,其实现策略一般是将弱对象添加到开发者指定的队列中。例如,Java的ReferenceQueue内建类便是以此为目的设计的,应用程序既可以对其进行轮询,也可使用阻塞式的操作来获取元素(或者附加额外的超时时间)。
应用程序也可以检测某个给定的弱对象是否已经加入到某个队列中( Java只允许弱对象最多被加入到一个队列中)。回收器在对弱指针的多次遍历过程中可以很轻松地实现弱对象的入队。许多语言都增加了类似的通知机制。
12.2.6其他语言中的弱指针(略)
十三、并发算法预备知识
13.1 硬件
13.1.1 处理器与线程
处理器(processor) 是硬件执行指令的单元。
线程(thread) 是单一顺序控制流,是软件执行的具体化。
线程的状态可以是
- 运行中(running) (也称调度中 ( scheduled))
- 可运行(ready to run)
- 为等待某些条件而被阻塞(blocked)。
Java并发编程中的线程参看《Java并发编程的艺术》——线程(笔记
调度器(scheduler) 通常是操作系统组件,其功能是确定在任意时刻哪些线程应当在哪些处理器上执行。如果某个线程被调度器从某个处理器换出(其状态从运行中转变为可运行或者被阻塞),则当其下一次被调度时很可能会在另一个不同的处理器上运行。当然,调度器也允许线程和处理器之间存在一定的 亲和性(affinity)。
某些处理器硬件支持多个逻辑处理器共用一条指令流水线,该技术称为同时 多线程(simultaneous multithreading,SMT) 或者 超线程(hyperthreading) 。这一概念会给我们的定义带来一定的麻烦。在我们的术语中,逻辑处理器通常被称为线程,但在此处,同时多线程处理器则可以看作是多(逻辑)处理器,并可以独立进行调度,因而线程这一概念便只能代表软件实体。
多处理器(multiprocessor) 是包含多个处理器的计算机。片上多处理器(chip multiprocessor,CMP) 是指在单个集成电路芯片上集成多个处理器的技术,也称多核处理器(multicore processor) 甚至 众核处理器(many-core processor) 。
抛开并发多处理器的概念不谈,多线程(multithread) 是指使用多个线程的软件,且每个线程可能在多个处理器上并发运行。多程序(multiprogram) 是指在单一处理器上执行多个进程或者线程的软件。
13.1.2 处理器与内存之间的互联
多处理器与集群计算、云计算或者分布式计算的区别在于,前者存在每个处理器都可以直接访问的共享内存。处理器对共享内存的访问需要以某种互联网络作为媒介。最简单的互联方式是处理器和内存之间使用单个共享 总线(bus) 来传递信息。
我们可以简单地将内存访问操作看作是处理器和内存单元之间的消息通信,每次通信所需的时间可能会达到上百个处理器时钟周期。单个总线的原始速度相当快,但该速度在多处理器同时发起请求时却仍然会成为瓶颈。带宽最高的互联方式可能是在处理器和内存两两之间建立私有通道,但该方案所需的硬件资源却会正比于处理器和内存单元数量的乘积。为获取更高的整体带宽(整个系统中处理器和内存在一秒内可以传输的数据量),将内存分割成多个单元也是一种不错的方案。另外,处理器与内存之间的数据传输通常都是以高速缓存行(参见13.1.4节)而非单独的字或者字节为单位的。
对于更大的片上多处理器,一次内存访问请求在互联网络中的传递可能需要经过多个节点,例如当互联网络以网状或者环状方式组织时。此处的具体细节超出本书的讨论范围,但我们需要了解的是,内存的访问时间可能会随着处理器与内存单元在互联网络中的位置不同而发生变化。另外,相同互联路径上的并发访问也可能引发更大的延迟。
在单总线系统中,当处理器的数量达到8~16个之后,总线一般都会成为瓶颈。但相比其他互联方式,总线的实现通常更加简单且更加廉价,且总线允许每个单元 侦听(listen) 总线中的所有通信(有时也称为 窥探(snooping) ),这可以简化系统对高速缓存一致性的支持(参见13.1.5节)。
如果内存单元与处理器之间相互独立,则该系统可以称为对称 多处理器(symmetricmultiprocessor,SMP) 架构,该架构中每个处理器访问任意内存单元的时间都是相同的。我们同样也可以将内存与每个处理器相关联,此时处理器在访问与自身关联的内存时速度更快,而在访问与其他处理器关联的内存时则速度较慢。此类系统被称为 非一致内存访问(non-uniform memory access,NUMA) 架构。同一系统可以同时包含全局的SMP内存以及NUMA内存,每个处理器还可以拥有私有内存,但共享内存与垃圾回收技术的关联更大。
对于处理器与内存之间的互联,最值得注意的地方是内存访问需要花费较长的时间,且互联网络可能成为系统的瓶颈,与此同时,不同处理器访问内存的不同部分可能会花费不同的时间。
13.1.3 内存
尽管内存在物理上可能会跨越多个内存单元或者处理器,但从垃圾回收器的角度来看,共享内存看起来就是一块由字或者字节组成的单个地址空间。由于内存是由多个可以并发访问的单元组成的,所以我们无法对其在任意时刻的状态给出一个全局性的描述,但是内存中的每个单元(也就是每个字)在每个时刻的状态都是确定的。
13.1.4 高速缓存
由于内存的访问速度如此之慢,所以现代体系架构通常会在处理器和内存之间增加一到多层高速缓存,其中所记录的是处理器最近访问过的数据,进而降低了程序运行期间处理器需要访问内存的次数。高速缓存与内存的数据交换是以高速缓存行(也称高速缓存块)为单位的,通常为32或者64字节。
如果处理器在访问某一地址时发现其所需要的数据已经存在于高速缓存中,这一情况称为 高速缓存命中(cache hit) ,反之则称 高速缓存不命中(cache miss) ,此时处理器便需访问更高一级缓存,如果最高一级缓存依然不命中,则处理器必须访问内存。
片上多处理器(CMP) 中某些处理器可能会共享最高一级缓存,例如,每个处理器可能都拥有专属的L1高速缓存,但是其会与相邻的一个存储器共享L2高速缓存。各级高速缓存的缓存行大小可以不同。
当某一级缓存出现不命中,且该级缓存也无法容纳新的缓存行时,处理器就必须依照某种策略从中选择一个缓存行进行置换,被换出的缓存行称作 受害者(victim) 。当在缓存中写入数据时,某些缓存使用的是 写通(write-through) 策略,即当某一缓存行中的数据得到更新时,下一级缓存中的对应数据也会尽快得到更新。另一种策略是 写回(write-back) ,即在被修改的行(也称脏行)得到换出之前其中的数据不会写入下一级缓存,除非进行显式 刷新(flush) (需要使用特殊的指令)或者显式写回(也需要特殊指令支持)。
缓存置换策略在很大程度上依赖于缓存的内部组织形式。
-
全相联(fully-associative) 缓存允许内存中任意地址的数据放置到缓存的任意一行中,其置换策略也可选择任意一行进行淘汰。
-
直接映射(direct-maped) 缓存,允许内存中某一地址的数据只能放置到缓存中特定的行,因而其置换策略只可能淘汰特定的缓存行。
-
k路组相联(k-wayset-associative) 缓存是上述两种极端方案的折中,该策略允许内存中特定地址的数据映射到缓存中的k个缓存行,其置换策略也可从这k个缓存行中选择一个进行淘汰。
高速缓存设计中需要注意的另一方面是各级缓存之间的关系。
-
对于相邻两级缓存,如果较低级别缓存中的数据一定会在高级别缓存中存在,则两级缓存为(严格) 包客(inclusive) 关系。
-
相反,如果同一数据最多只能出现在两级缓存中的一级,则两级缓存为 排他(exclusive) 关系。
真正的高速缓存设计也可进行折中,即允许同一行存在于两级缓存中,但也并不强制要求高级别缓存一定要包容低级别缓存的数据。
13.1.5 高速缓存一致性
高速缓存中所持有的数据在内存中很可能是共享的。由于每个缓存中的数据不可能同时得到更新(特别是对于使用写回策略的缓存),所以内存中同一地址的数据在不同缓存中的副本可能会出现不一致。因此,不同处理器在同一时刻读取同一地址的数据,可能会获得不同的结果,这显然是不应该出现的。
为解决这一问题,底层硬件通常会提供一定级别的高速缓存一致性支持。一种经典的高速 缓存一致性协议(coherence protocol) 是MESI,在该协议中,每个缓存行可能有4种状态,这4种状态的首字母构成了该协议的名称。
-
被修改(modified):该缓存行持有数据的唯一有效副本,其中的数据被修改过,但尚未写回内存。
-
独占(exclusive):该缓存行持有数据的唯一有效副本,同时其中的数据与内存保持一致。
-
共享(shared):其他缓存行也可能持有数据的有效副本,同时所有副本中的数据均与内存保持一致。
-
无效(Invalide):缓存行中不包含任何有效数据。
- 只有当缓存行的状态为“被修改”、“独占”、“共享”其中之一时,处理器才可以读取该缓存行
- 只有当缓存行的状态为“被修改”或“独占”时,处理器才可以将数据写入该缓存行,写入之后其状态将成为“被修改”。
- 如果处理器需要从“无效”缓存行中读取数据,则系统的后续行为取决于该缓存行在其他缓存中的状态:
- 如果为“被修改”,则处理器必须将其写回内存,并将其状态置为“共享”(或者“无效”)
- 如果状态为“独占”,则只需将其降级为“共享”(或“无效”)
- 如果其状态为“共享”或者“无效”,则处理器只需简单地从内存或者其他缓存里状态为“共享”的缓存行中加载数据
- 如果处理器需要将数据写入“无效”缓存行中,系统的后续行为与读取时的行为类似,唯一的不同之处在于其他缓存行的最终状态都将是“无效”
- 如果处理器需要将数据写入“共享”缓存行中,其必须先将其他缓存行降级为“无效”
- 该协议可以进行的改进包括:
- 以写为目的读可以在读取完成之后将其他缓存行的状态降级为“无效”
- 写回操作可以将缓存行的状态从“被修改”降级为“独占"
- 令某一缓存行失效的操作可以将状态为“被修改”的缓存行写回内存,然后将其状态置为“无效”。
MESI协议的关键之处在于,任意缓存行在同一时刻只能被一个处理器写,且两个缓存针对同一数据的缓存行永远不会产生不一致。MESI协议的实现难点在于,当处理器数量增大时算法的性能会下降,这也是所有由硬件支持的缓存一致性协议的共有问题。
因此,更大的片上多处理器逐渐开始放弃内建的缓存一致性协议,转而开始由软件来管理一致性,此时软件便可选择任意类型的缓存一致性协议。即便如此,处理器数量增大时算法依然会存在性能问题,但与将算法固化在硬件中的策略相比,开发者至少可以根据其具体需求选择更好的一致性算法。
缓存一致性要求引发了另一个问题,即 伪共享(false sharing) :
- 当两个处理器同时访问并更新位于相同缓存行的不同数据时,由于处理器在写操作之前必须将缓存的状态更改为“独占”,所以两个处理器令对方缓存行失效的操作会产生“乒乓”效应,从而引发互联网络中大量的一致性通信,并可能引发额外的内存读取操作。
13.1.6高速缓存一致性对性能的影响示例:自旋锁(略)
13.2 硬件内存一致性
我们假定共享内存可以提供与高速缓存相同的 一致性 (coherence) 保障,即:不存在未完成的写操作。
如果两个处理器读取内存中相同位置的值,所获取的值也是相同的。大多数硬件还可以进一步保证:
- 如果两个处理器同时对内存的相同位置发起写操作,则其中的一个将先于另一个发生,同时所有处理器后续读取到的值都将是最后一次写入的值。
- 另外,如果某一处理器已经读取到了最终的值,则在下一次写操作发生之前,其不可能读取到其他值。也就是说,针对内存中任何特定位置的写操作都是经过排序的,且在任意处理器看来,特定位置值的变化顺序都是相同的。
但是,对于程序在多个位置的写入(或者读取)操作,硬件系统却不能保证程序发起操作的顺序与其在高速缓存或者内存中的生效顺序完全一致,更不能保证其他处理器能够以相同的顺序感知到这些地址的数据变更。
也就是说,程序顺序(program order) 不一定要与 内存顺序(memory order) 完全一致。
不要求两者完全一致是出于性能考虑—— 严格一致性(consistency) 要么会耗费更多的硬件资源,要么会降低性能,或者两者兼有。
对于硬件而言,许多处理器会使用 写缓冲区(write buffer/store buffer) 来保存未完成的内存写入操作。写缓冲区本质上是一个<地址,数据>对组队列。正常情况下,写操作可能会有序执行,但如果某一后发写操作的目的地址已经存在于写缓冲区中,则硬件可能会将其合并到队列里尚未完成的写操作中,这便意味着写操作可能会出现“后发先至”的情况,即较晚的写操作可能会越过较早的、针对其他地址的写操作而立即在内存中生效。
处理器的设计者会小心地确保处理器操作针对其自身的一致性,也就是说,如果处理器在读取某一位置的值时发现该位置存在未完成的写操作,则处理器要么通过直接硬件路径(速度较快但开销较大)来读取该值,要么必须等写缓冲区刷新完毕后再从高速缓存中读取该值。
另一个可能导致程序操作被重排序的原因是高速缓存不命中。一旦读取过程中发生高速缓存不命中,许多处理器会将其跳过并继续执行后续指令,进而可能出现后发读/写操作越过先发读/写操作先执行完毕的情况。
另外,对于使用写回机制的高速缓存,其中的数据只有在被淘汰或者显式刷新时才会写入内存,因此针对不同缓存行的写操作的执行顺序可能会出现大幅度调整。上述各种硬件方面的原因只是说明性的,但并非面面俱到。
由软件产生的重排序大多是由编译器造成的。例如,如果编译器已知两个引用指向的是同一地址,且两个引用的读取操作之间并无其他写操作会影响该值,则编译器可能直接使用其第一次读取到的值优化掉第二次读操作。更一般的情况是,如果编译器可以确保各变量之间均不存在别名关系(即不引用相同的内存地址),则其可以对这些变量的读写操作以任意方式重排序,因为不论采用何种顺序,最终的执行结果都是相同的(对于单处理器而言,且假定不存在线程切换)。读取结果复用以及重排序策略可以产生更高效的代码,且在大多数情况下并不影响语义,因而许多编程语言都允许这一策略。
从开发者的角度来看,程序顺序与内存顺序之间缺乏一致性显然是一个潜在的问题,但是从硬件实现者的角度来看,如此设计可以大幅提升性能并减少开销。
放宽一致性要求的后果:
- 这可能导致程序的执行完全背离开发者的意图,也可能导致在完全一致性模型下可正常执行的代码在更加复杂的一致性模型下产生混乱的执行结果。
- 锁相关等技术要求硬件以某种方式确保对不同地址的访问能够有序执行。
各种顺序模型必须能够区别出3种主要的访问原语:
- 读(read)
- 写(write)
- 原子(atomic)
原子操作需要原子化的“读-修改-写”原语,该原语通常是条件性的,例如TestAndset。
内存一致性对于 依赖加载(dependent load) 也十分重要,所谓依赖加载,即程序需要先从地址x加载数据,然后再从地址y加载数据,但第二次加载操作的地址y取决于地址x的加载结果。依赖加载的一个典型案例是沿着指针链进行追踪。在完全一致性之外,还存在着许多较弱的内存访问顺序模型,我们将选择其中较为常见的进行介绍。
13.2.1 内存屏障与先于关系
内存屏障(memory fence) 是一种处理器操作,它可以阻止处理器对某些内存访问进行重排序。特别地,它可以避免某些访问指令在屏障之前 发送(issue) ,也可以避免某些访问指令被延迟到屏障之后发送,或者两者皆可。例如,完全读内存屏障可以确保屏障之前的所有读操作都能先于屏障之后的读操作执行。
先于关系(happens-before) 这一概念则更加规范化,它是指内存访问操作在存储中所应遵从的发生顺序。
完全读内存屏障相当于是在每两个相邻读操作之间施加了先于关系。原子操作通常会为其内部的所有子操作施加完全内存屏障:
- 所有较早的读、写、原子操作都必须先于较晚的读、写、原子操作发生。
在先于关系之外还存在其他一些模型,例如 获取-释放(acquire-release) 语义。
在该模型下,获取操作(acquire operation) (可以将其看作是获取锁)能够阻止较晚操作在该操作之前发生,但较早的读写操作则可以在获取操作之后发生;释放操作( release operation)与之完全对称:它能够阻止较早的操作在释放操作之后发生,但是较晚的操作则可以在释放操作之前发生。简而言之,处理器可以将获取–释放操作对之外的操作移动到其内部,但却不能将其内部的操作移动到外部。临界区 ( critical section)便可使用获取-释放模型来实现。
13.2.2 内存一致性模型
- 最强的内存一致性模型当属严格一致性(strict consitency),即所有的读、写、原子操作在整个系统中的任意位置都以相同的顺序 发生(occur) 。
严格一致性意味着所有操作的发生顺序都满足先于关系,且这一顺序是由某一全局时钟决定的。严格一致性是最容易理解的一种模型,这可能也是大多数开发者以为硬件系统所遵从的顺序,但这一模型很难高效地实现。
- 稍弱的一种模型是 顺序一致性(sequential consistency) 模型,在该模型中,全局先于顺序只需要与每个处理器的程序顺序保持一致即可。
相对于其他更加宽松的一致性模型,顺序一致性模型下的编程更加简单,因而规模较小的处理器通常会尝试达到或者接近顺序一致性的要求。
-
弱一致性(weak consitency) 会将所有原子操作当作完全屏障。上文所描述的获取—释放模型通常被称为 释放一致性(release consitency) 模型。
-
因果一致性(causal consistency) 模型的强度介于顺序一致性和弱一致性之间,该模型要求程序所发起的读操作与其后续的写操作之间必须满足先于关系,其目的在于避免读操作对写操作所写入的值造成影响,即对于先读取某个值然后再将其写入内存的操作,因果一致性会确保它们之间的先于关系。
-
宽松(松弛)一致性(relaxed consistency) 模型泛指所有比顺序一致性模型更弱的模型。
硬件系统所允许的重排列方式一定程度上还取决于互联网络和内存系统,这便超出了处理器的控制范围。表13.1展示了部分知名处理器家族所允许的指令重排列方式,所有的处理器至少都实现了弱一致性或者释放一致性。关于内存一致性模型的更多内容可以参见Adve和 Gharachorloo。
13.3 硬件原语
13.3.1 比较并交换
算法13.4展示了compareAndSwap原语以及与其十分相近的CompareAndset 原语。
compareAndset原语将某一内存地址的值与old比较,如果两者相等,则将其值置为new。该操作的返回值表示内存中的值是否被更新。
compareAndSwap原语与之的唯一区别在于其返回值是内存地址中原有的值(不论是否更新成功),而非一个布尔变量。尽管这两种原语的语义并非严格等价,但它们的使用场景基本都是一致的。
CompareAndSwap通常用于将某一内存地址的值从一个状态更新到另一个状态,例如从“被线程t1锁定”到“解锁”,再到“被线程t2锁定”。
算法13.5展示了compareAndSwap原语的一种常见用法,即先判断内存地址的当前值,然后再尝试原子性地将其更新,该操作通常被称为 “比较-比较并交换”( compare-then-compare-and-swap) 。
ABA问题
CompareAndSwap原语潜藏着一个微妙的陷阱,即在调用compareAndSwap时,目标内存地址的值改变了数次,但该地址的当前值却与调用者之前获取到的值相等。某些情况下这可能不会出现问题,但在其他情况下,相同的值并不意味着相同的状态。
例如在垃圾回收中,在经历两次半区复制回收之后,某一指针的目标对象很可能与其最初的目标对象完全不同。CompareAndswap原语无法探测到“某个值被修改,然后再被改回原值”的情况,即所谓的ABA问题(ABA problem)。
13.3.2 加载链接/条件存储
在LoadLinked 和 storeConditionally原语中,处理器会记录LoadLinked原语所访问的地址,并使用处理器的一致性机制来探测所有针对该地址的更新操作,从而解决ABA问题。
算法13.6描述了LoadLinked/StoreConditionally的具体实现,它要求处理器实现算法所描述的store语义。reservation变量不仅会被其所属的处理器清空,也会被其他处理器清空。
由于所有针对保留地址的写操作都会清空reserved旗标,所以“比较-比较并交换”操作可以据此来避免ABA问题,如算法13.7所示。
因此,LoadEinked/StoreConditionally原语比compareAndSwap更加强大,该原语允许开发者针对单个内存字实现任意类型的原子化“读-修改-写”操作。算法13.8展示了如何使用LoadLinked/StoreConditionally实现“比较并交换”、“比较并设置”原语。
LoadLinked/StoreConditionally原语还有另外一个特征需要注意:
- 即使任何处理器都没有更新过保留地址的值,storeConditionally原语也有可能出现假性失败。
多种低级硬件状况可能导致假性失败,其中值得注意的是中断的出现,包括缺页陷阱、溢出陷阱、时钟中断、I/O中断等,这些中断均需要由内核进行处理。
这种失败通常不会成为问题,但是如果LoadLinked和storeConditionally之间的某段代码总是引发陷阱,则storeconditionally操作可能始终都会失败。
由于LoadLinked/storeConditionally原语可以优雅地解决ABA问题,所以我们更加倾向于用其替代可能产生ABA问题的compareAndSwap原语。当然,我们也可为compare-Andswap原语关联一个计数器来解决ABA问题。
严格意义上讲,如果storeConditionally原语所操作的并非之前保留的地址,则其最终结果可能是未定义的。但某些处理器在设计上便允许这种使用方式,这相当于提供了一种在某些场景下有用的、针对任意两个内存地址的原子操作。
13.3.3 原子算术原语
算法13.9定义了几种原子算术原语。我们也可使用atomicAdd或 FetchAndAdd原语来实现AtomicIncrement和AtomicDecrement操作,且其返回值既可以是原始值,也可以是新值。
另外,处理器在执行这些原语时通常会设置 条件码(condition code) ,其值可以用于反映目标地址的值是否为零(或者原始值为零),也可反映其他一些信息。
在垃圾回收领,FetchAndAdd原语可以用于实现并发环境下的顺序分配(即阶跃指针分配),但更好的做法通常是为每个线程建立本地分配缓冲区,如7.7节所述。
FetchAndAdd可以简单地用于从队列中添加或者移除元素的操作,但是对于环状缓冲区,还需要小心地 处理回绕(wrap-around) 问题(参见13.8节)。
这些原子算术原语的能力严格弱于compareAndSwap,同时也弱于LoadLinked/storeConditionally。每种原语都存在一个可以用一致数consensus number)来描述的特征,如果某个原语的一致数为k,意味着它可以解决k个线程之间的一致问题,但无法解决多于k个线程之间的一致问题。所谓一致问题,是指多处理器算法是否能达到如下要求:
- 对于某个变量,每个线程均建议一个值
- 所有线程针对某个值达成一致
- 该变量的最终值为某个线程所建议的值
- 所有线程均能够在有限步骤内完成操作,即算法必须满足 无等待(wait-free) 要求(参见13.4节)。
对于所有的无条件设置原语(例如AtomicExchange)或者对相同值产生相同运算结果的更新原语(如AtomicIncrement与 FetchAndadd),其一致数均为2。
而CompareAndSwap和LoadLinked/storeConditionally的一致数则为α ,即它们能够以无等待的方式解决任意多个线程之间的一致问题,正如算法13.13即将展示的。
无条件算术原语的一个潜在优势在于它们通常都会成功,而如果使用compareAndSwap或者LoadLinked/StoreConditionally来模拟无条件算术原语,线程之间的竞争很可能导致“饥饿”现象的出现R。
13.3.4 检测-检测并设置
“检测–检测并交换”即为算法13.3中所介绍的testAndTestAndset。
由于算法13.3会不断迭代,所以其正确性不存在问题。开发者应当避免将其实现为算法13.10所示的两种错误形式:
- testThenTestAndSetLock不会进行迭代,如果x在if和TestAndset语句之间被修改,TestAndset将执行失败,因而这一操作存在错误
- testThenTestThensetLock的错误则更加明显,它不使用任何原子操作原语,因此在变量x的两次读取之间以及变量x的读写之间任何针对x的更新操作都有可能发生。
需要注意的是,即使将x声明为volatile也无济于事。“比较-比较并交换”的实现也可能出现类似的错误。正确地构造出一个并发算法并非易事,这些错误便是开发者很容易遇到的陷阱。
13.3.5 更加强大的原语
在我们描述过的硬件原语中,LoadLinked/storeConditionally的通用性最强,也是针对单字的最强的原子更新语义。
除此之外,允许对多个独立字进行原子更新的硬件原语则更加强大。在单字原语之外,某些处理器还支持双字原语,例如双字比较并交换,我们称之为compareAndSwapWide/CompareAndsetwide(见算法13.11)。
如果仅从概念上来看,该原语的强大之处没得到充分体现。但是,使用双字compareAndSwap操作却可以轻松应对单字CompareAndSwap无法解决的ABA问题,此时我们只需要将第二个字用作记录第一个字被更新次数的计数器。
对于32位的字,计数器的值最多可以达到23,因而基本上可以忽略计数器回绕可能带来的安全问题。支持对相邻两个64位的字进行原子更新的硬件原语则更加强大。因此,尽管compareAndSwapWide在概念上与常规的compareAndSwap并无较大差别,但其在使用上却更加方便、更加高效。
尽管更新相邻两个字的原子操作原语十分有用,但如果其能够原子化地更新内存中任意两个(不相邻)字则会显得更加强大。
Motorola 880000以及 Sun的Rock处理器均提供了 “双比较并交换”指令(compare-and-swap-two,也称double-compare-and-swap) ,如算法13.12中的compareAndSwap2原语所示。
compareAndSwap2的硬件实现较为复杂,因而目前尚无商业级别的处理器支持这一原语。compareAndSwap2可以泛化为通用的 n路比较并交换(compare-and-swap-n,也称n-way compare-and-swap) ,同理,也可对LoadLinked/storeConditionally原语进行泛化,由此得到的结果便是 事务内存(transactional memory) ,13.9节将对其进行介绍。
13.3.6 原子操作原语的开销
开发者经常会错误地使用原子操作原语,其原因之一是他们知道原子操作的开销较大,因而会刻意避免使用原子操作,而另一种原因则是他们可能错误地使用了原子操作,例如算法13.10中两种错误的testAndTestAndSet实现。
我们曾经提到,有两个原因导致原子操作的开销较大:
- 原子化的“读-修改-写”原语必须以独占方式访问相关的高速缓存行
- 在指令结束之前,处理器必须完成数据读取、计算新值、写人新值这一系列操作
现代处理器可能会使用多指令 重叠(overlap) 技术,但是如果后续操作强依赖于原子操作的结果,则必然会减少流水线中的指令条数。由于原子操作需要确保一致性,因而其通常会涉及总线甚至内存的访问,这通常会花费较多的指令周期。
另一个导致原子操作执行速度较慢的原因是,它们要么天然包括内存屏障语义,要么要求开发者在其开始和结束位置手动添加额外的内存屏障。这潜在削减了指令重叠与流水线技术所带来的性能优势,从而导致处理器很难隐藏这些原语访问总线或者内存的开销。
13.4 前进保障
当多个线程之间竞争相同数据结构时(例如共享堆,或者回收器数据结构),确保整个系统能够正常往下执行尤为重要(特别是在实时环境中),我们将这一要求称为 前进保障(progress guarantee) 。
了解不同硬件原语在前进保障方面的相对强度也十分必要,常见的前进保障级别从强到弱分别是:
- 无等待:对于并发算法而言,如果每个线程始终都可以向前执行(不论其他线程执行何种操作),则称其为 无等待(wait-free) 算法
- 无障碍:如果在并发算法中,对于任意一个线程,只要其拥有足够长的独占式执行时间,便能够在有限步骤内完成操作,则称该算法为 无障碍(obstruction-free) 算法
- 无锁:如果算法永远可以保证某些线程能在有限步骤内完成操作,则称该算法为 无锁(lock-free) 算法
- 无等待算法通常会引入线程互助的概念,也就是说,如果线程t2即将执行的操作可能会打断线程t1正在执行的、在一定程度上可以确定超前于线程t2的操作,则线程t2将协助t1完成其工作,然后再开始执行自身工作。
假设线程数量存在固定上界,且线程之间相互协助的工作单元或者对数据结构的操作也存在上界,则任何工作单元或者操作的完成步骤便都存在上界。
但是,这一上界通常较大,且与较弱的前进保障相比,线程互助需要引人额外的数据结构与工作量,因而其操作时间通常相当长。
对于较为简单的一致性场景,为其设计时间开销较小的无等待算法通常比较容易,如算法13.13所示。从中我们可以看出,该算法满足解决N个线程的一致问题的所有标准,但是其空间开销正比于N。
2. 与无等待要求相比,无障碍更容易实现,但是其可能需要调度器的协助。如果线程发现当前存在竞争,则它可以使用随机递增的时间退让策略来确保其他线程优先完成工作。也就是说,每当线程探测到竞争时,其首先会计算一个比上次退让时间更长的时间周期T,然后再从0~T之间选择某一随机值作为本次退让的时间。从概率上讲,对于较少出现竞争的场景,每个线程最终都会成功执行。
- 无锁要求的实现则更加简单,它只要求在任何情况下至少一个竞争者可以继续往下执行,即使其他线程可能会永久性地等待下去。
前进保障与并发回收
- 并行回收器(parallel collector) 同时使用多个回收线程来处理回收工作,但其回收过程中仍会挂起所有赋值器线程
- **并发回收器(concurrent collector)**会在赋值器线程执行的同时执行(至少一部分)回收工作,其通常也会使用多个回收线程
并行回收和并发回收算法的执行通常可以分为数个阶段,例如标记、扫描、复制、转发或清扫,并发回收器还可能会让赋值器在执行过程中承担一定的回收工作。
多个回收线程之间必须进行协作,否则它们之间可能会相互干扰,或者干扰到赋值器的执行。
并发回收的最基本的要求显然是回收器不能发生任何明显错误——至少要确保不会回收任何可达对象,并确保赋值器的正常工作。
在此基础之上,回收器还应当确保回收工作终究能够结束,且其或多或少都应当回收一些不可达内存以便复用。
对于不同的回收算法,其每次调用所能回收内存的期望值有所不同:
-
保守式回收器(只能依赖模糊根)通常会高估堆中对象的可达性,进而导致某些不可达对象无法得到回收
-
对于分代回收器或者其他使用分区策略的回收器,其会故意避免回收堆中某些分区的不可达对象
完整的回收算法必须达到更高的要求:
- 只要垃圾回收的调用次数足够多,任意垃圾对象最终都能得到回收。
并发回收器中还有其他一些问题需要关注。其中的一个问题是,对于在回收过程中(由赋值器)分配并(在回收结束之前)不可达的对象,或者在回收开始之前分配但在回收过程中不可达的对象,回收器是否应当将其回收。对于特定回收器而言,答案既可能是肯定的,也可能是否定的。
顺序算法(sequentialalgorithm) 的结束特征通常比较明显,例如在对可达对象图进行标记的过程中,堆中对象会被划分成3个集合:
- 已标记且已扫描
- 已标记但未扫描
- 未标记
标记算法需要逐渐增大第一个集合,并最终使其囊括堆中所有可达对象。
如果赋值器的每一步操作都会产生更多的回收工作,那么回收器何时才能赶得上赋值器?
为此,赋值器可能需要减缓执行甚至完全停止一段时间。即使可以确保回收器的处理速度永远高于赋值器,但仍存在一些其他的难点:
- 除非回收算法使用无等待技术,否则回收器和赋值器之间的相互干扰可能会导致程序永远无法向前执行。
- 例如,在无锁算法中,某一线程在尝试完成某个阶段的工作时可能会持续失败。同时,存在竞争关系的两个线程可能会永久性地导致对方无法向前执行,这一现象被称为 活锁(livelock) 。
不同回收阶段的前进保障可能会有所不同——可能一个阶段为无锁级别,而另一个阶段为无等待级别。但在具体实现过程中,即使是在理论上完全无等待的算法也可能会引人一些(期望停顿时间较短的)万物静止式停顿。要达到最强的前进保障级别,不可避免地会增加代码复杂度并增加出错几率,因而在工程上花费大量精力以达到无等待要求的做法可能并不值得。
站在赋值器的角度来讲,只要回收器释放内存的速度足够快,能保证内存分配操作不会(由于等待回收器释放内存而)阻塞,就可以说回收算法达到无等待级别。另外,回收器还必须确保在回收周期结束之前堆空间不会耗尽。这些要求都远比在每个阶段中达到无等待要求要重要得多——它需要在堆大小、最大存活对象大小、分配率、回收率之间达到整体平衡。
13.5~13.8并发算法的符号记法、互斥、工作共享与结束检测、并发的数据结构略
13.9 事务内存
transcational memory
13.9.1 何为事务内存
与事务相同具有隔离、一致、永久性。
事务内存可以基于硬件实现,也可基于软件实现,还可使用软硬件混合方式实现。任何一种实现策略都必须提供以下几种操作:
- 原子化写操作
- 冲突检测
- 可见性控制(visibility control) (以支持隔离性),可见性控制可以作为冲突检测的一部分。
写操作的原子化可以通过 缓冲(buffer) 或者 撤销(undo) 的策略实现。
缓冲策略会先在内存中的某临时位置执行写操作,并且仅在事务提交时才将最终的值写人指定位置。
-
硬件缓冲策略可以通过增加缓存或者其他额外缓冲区的方式实现
-
软件缓冲则可能会借助于与待修改值同级别的字/域/对象。
引人缓冲之后,事务的提交要么会将缓冲的写操作生效,要么直接将缓冲区抛弃。大多数情况下,事务的成功提交通常需要较多工作,相比之下中止事务的开销相对较小。
基于撤销的策略则与缓冲策略截然不同,其事务执行过程中的写操作会直接修改数据,但每个写操作会在数据修改之前将原有的值记录到 撒销日志(undolog) 中。
如果事务最终成功提交,则可以直接抛弃执行过程中的撤销日志,而一旦事务被中止,则必须使用撤销日志来恢复被修改的值。
与缓冲策略类似,撤销日志也可使用硬件、软件、软硬件混合的方式实现。
冲突检测既可以是 积极(eagerly) 的,也可 懒惰(lazily) 的。
积极的冲突检测策略会在每次内存访问之前检测该操作是否会与当前正在执行的事务产生冲突,如果发现两个事务可能发生冲突,那么在必要情况下可以将其中的一个中止。
懒惰冲突检测策略则只有在尝试提交事务时才进行冲突检测。
某些冲突检测机制还允许事务在执行过程中检测当前是否有冲突发生。
-
软件实现策略可以设置对象头部的旗标,或者使用额外的表来记录对象的访问,事务性访问在冲突检测过程中会对其进行检测。
-
硬件实现策略则通常会将旗标与高速缓存行或者被修改的字相关联。
13.9.2 使用事务内存助力垃圾回收器的实现
事务内存与垃圾回收之间的关系主要表现在两个方面。
-
一方面,事务内存可以作为垃圾回收器的实现技术之一
-
另一方面,事务可能会是托管语言的基本语义之一,回收器必须能够正确地支持这一语义。
毋庸置疑,事务内存可以简化并发数据结构的实现,因而其可以简化并行/并发分配/回收的实现,特别是并发分配器、赋值器、回收器、读写屏障以及并发回收器数据结构的实现。
目前事务内存尚不存在统一的硬件实现标准,同时在软件方面也存在多种不同的实现方式,因而我们很难指定某种标准并进行描述,但不论如何,在自动内存管理中使用事务内存需要注意以下几个方面:
-
软件事务内存(software transactional memory) 通常会引入显著开销,即使在经过优化之后。如果要求自动内存管理系统中大部分组件的运行时开销都足够小,则软件事务内存的应用场景可能会受到相当大的限制。另外,访问频率不高的数据结构也可以使用锁来降低实现复杂度。
-
硬件事务内存(hardware transactional memory) 通常会包含一些与硬件相关的特殊要求。例如,冲突检测、访问与更新均必须以物理单元为最小粒度来执行(例如高速缓存行)。由于硬件容量通常存在限制(例如组相关高速缓存中每个缓存集合所包含的缓存行数量),所以某些硬件事务内存的实现可能会对事务中所涉及的数据有总量限制。另外,开发者所设计的变量布局与其在高速缓存行上的布局可能存在差异,因而开发者还需额外注意一些底层的实现细节。
-
事务内存在大多数情况下可以轻易满足无锁要求。即使事务内存的底层提交机制满足无等待要求,事务之间依然可能发生冲突,进而导致事务的中止或者重试。无等待数据结构的开发依然十分复杂,且需要注意许多细节。
-
事务内存需要开发者仔细进行性能调整。需要关注的内容之一是多个事务访问相同数据结构时的固有冲突。例如对于并发栈而言,其瓶颈在于不同线程更新栈顶指针的操作,但事务内存并不能解决这一问题。另外,对事务中的读写操作进行任何方式的调整(例如将其移动到靠近事务开始或者结束的位置)都会显著影响冲突发生的概率,以及事务的重试开销。
13.9.3 垃圾回收机制对事务内存的支持
垃圾回收和事务内存这两种机制的执行可能会相互干扰,特别是在并发程度很高的场景中。
- 内存管理器可能导致事务产生冲突,进而增大事务的重试开销,从而影响到赋值器或者回收器的正常执行,或者同时影响两者。
例如,如果赋值器尝试发起一个执行时间较长的事务,而其执行过程又与回收器产生冲突,则赋值器事务可能会持续性地被回收器中止,或者回收器可能会被阻塞较长的一段时间。
如果事务本身使用硬件事务内存来实现,则情况将更加复杂。例如,并发回收器针对某一对象所进行的标记、转发、复制等操作均有可能中止赋值器事务的执行,而原因仅仅是回收器与执行中的事务访问了相同的内存一即使语 言的实现者已经通过精心设计来确保它们之间不会相互干扰,但这一问题仍不能避免。由于硬件不可能意识到其所操纵的数据为托管数据,所以硬件事务内存无法解决这一问题,相比之下,针对特定语言设计的软件事务内存则可能会将对象头部与其数据域区分对待。
- 事务的执行还可能受到内存回收语义的影响。
例如,假设某一事务内存系统使用原地更新的实现策略,同时使用回滚日志来记录被修改数据原有的值,并据此来支持事务的正常中止。这一场景下,可能会出现某一对象仅从回滚日志可达的情况,此时如果事务处于未决状态,则该对象不应当被回收器判定为不可达。因此,回收器还需要把事务日志当作根集合的一部分来处理。
- 对于复制式回收算法,回收器不仅需要对回滚日志中的指针进行追踪,而且还必须将其更新到其目标对象的新地址。
事务性语言中的内存分配是一个值得关注的问题。如果事务在执行过程中存在对象分配,但事务最终被中止,则从逻辑上讲,所分配的对象应当以某种方式回收。但是,一旦分配操作会涉及全局共享数据结构,如果我们要在事务中止时精确恢复到事务执行之前的状态,则意味着事务需要对空闲链表或者阶跃指针加锁,直到事务提交或者中止,这显然是无法接受的。
因此,分配过程的回滚应当更加偏向于逻辑上而非物理上的操作。例如对于基于空闲链表的系统,事务在被中止时应当将其所分配的对象释放,这一分配释放过程可能会导致空闲链表中的节点的位置发生变化,也可能导致内存块分裂(且无法合并)。
编程语言也有可能允许事务在执行过程中发起–些非事务性的操作,这些操作所分配的对象可能会暴露给其他线程,因而它们不应当随着事务的中止而释放,同时这些对象初始化过程中所执行的操作(例如设置对象头域)也不应当回滚。
- 某些事务内存系统会在执行过程中产生大量的内存分配操作,进而增加分配器与回收器的负担。
特别是在某些软件事务内存的实现中,事务在修改某一对象之前必须先创建
该对象的副本,并基于副本进行修改,同时只有在事务成功提交的情况下才使用副本取代原有对象。在这一情况下,分配器在语义上并无更多需要处理的内容,但其负载可能会发生变化。
事务内存和垃圾回收之间的一个共同点在于,它们都需要高效的、满足合适前进保障要求的并发数据结构。例如,事务的提交便是一致性算法的一个应用场景,理想情况下其应当满足无等待要求。
附录
[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译