《垃圾回收算法手册 自动内存管理的艺术》——引言、标记清扫(笔记)

一、引言

托管语言(managed language) 以及 托管运行时系统( managed run-time system)

  • 提升程序的安全性
  • 通过对操作系统和硬件架构的抽象来提升代码的灵活性

虚拟机( virtual machine)

  • 提供的多种服务可以减少开发者的工作量
  • 如果编程语言是 类型安全(type-safe) 的,并且运行时系统可以在程序加载时进行代码检查,在运行时对资源访问冲突、数组以及其他容器进行边界检查,同时提供自动内存管理能力,那么代码的安全性将更有保障。

类型安全的语言:类型安全意味着编译器将在编译时验证类型,如果您尝试将错误的类型分配给变量,则会抛出错误

  • 使跨平台程序开发变得更加简单、开发成本更低。开发者也可将主要精力专注在应用程序的逻辑上。

动态内存分配(allocation)

  • 几乎所有的现代编程语言都使用。
  • 允许进程在运行时分配或者释放无法在编译期确定大小的对象,且允许对象的存活时间超出创建这些对象的子程序时间。
  • 动态分配的对象存在于 堆(heap) 中而非栈(stack)或者静态区(statically)中。

,即程序的活动记录(activation record)栈帧(stack frame)
静态区则是指在编译期或者链接期就可以确定范围的存储区域。

在这里插入图片描述

堆分配是十分重要的功能,它允许开发者:

  • 在运行时动态地确定新创建对象的大小(从而避免程序在运行时遭遇硬编码数组长度不足产生的失败)。
  • 定义和使用具有递归特征的数据结构,例如链表(list)、树(tree)和映射(map)
  • 向父过程返回新创建的对象,例如工厂方法。
  • 将一个函数作为另一个函数的返回值,例如函数式语言中的闭包 ( closure)或者悬挂( suspension)R。

堆中分配的对象需要通过引用(reference) 进行访问。一般情况下,引用即指向对象的指针(pointer)(也就是对象在内存中的地址)。

引用也可以通过间接方式实现,例如它可以是一个间接指向对象的句柄(handle)。使用句柄的好处在于,当迁移某一对象时,可以仅修改对象的句柄而避免通过程序改变这个对象或句柄的所有引用。

直接使用指针的方式可以称之为直接引用,它的好处就是快,节省了一次指针定位的时间开销。

注意,下面的截图来源于另一个本书。
在这里插入图片描述
在这里插入图片描述

1.1 显示内存释放

任何一个运行在有限内存环境下的程序都需要不时地回收运行过程中不再需要的对象。

堆中对象使用的内存可以使用 显式释放( explicit deallocation) 策略(例如C语言的free函数或者C++的delete操作符)、基于引用计数的运行时系统、追踪式垃圾回收器进行回收。

显式内存释放会在两个方面增加程序出错的风险。

  • 开发者可能过早回收依然在引用的对象,这种情况将 引发悬挂指针( dangling pointer) 问题(如图1.1所示)。访问悬挂指针,将让执行结果不可知。
    在这里插入图片描述

检测悬挂指针的一种策略是使用肥指针( fat pointer),它将指针目标对象的版本号作为指针本身的一部分。当对肥指针进行解引用时,运行系统会首先判断肥指针中记录的版本号与其目标对象的版本号是否一致。这一方法存在额外开销,而且并非完全可靠,因此其应用范围几乎局限于调试工具上。

  • 开发者很可能在程序将对象使用完毕之后未将对象释放,从而导致 内存泄漏( memory leak) 现象的出现。在小程序中,内存泄漏可能不会造成太大影响,但对于较大的程序,内存泄漏却有可能导致显著的性能下降,设置崩溃。

这两类错误多出现在对象共享的情况下。在并发编程情况下,当两个或多个线程引用同一个对象时问题将更加严重。

随着多核处理器的普及,开发者需要投入相当多的精力来构建线程安全的数据结构库。实现线程安全的数据结构库的算法必须面临一系列问题,例如死锁(deadlock)、活锁(livelock)、ABA问题(ABA problem)。自动内存管理可以显著降低这些问题的解决难度(例如可以消除某些情况下的ABA问题),否则编程方案将十分复杂。

更为根本的问题在于,显式释放需要花费开发者更多的精力。如何正确进行内存管理往往是编程中的固有难题,如何将对象正确地释放是一个十分复杂的问题。

在不支持自动化动态内存管理的语言中,众多研究者已经付出了相当大的努力来解决这一难题,其方法主要是管理对象的所有权。Belotsky[2003]等人针对C++提出了几个可行策略:

  1. 开发者在任何情况下都应当避免堆分配,例如可以将对象分配在栈上,当创建对象的函数返回之后,栈的弹出( pop)操作会自动将对象释放。

  2. 在传递参数与返回值时,应尽量传值而非引用。

尽管这些方法避免了分配、释放错误,但其不仅会造成内存方面的压力,而且失去了对象共享的能力。

另外,开发者也可以在一些特殊场景下使用自定义内存分配器,例如对象池(pool of object),在程序的一个阶段完成之后,池中的对象将作为一个整体全部释放。

C++语言尝试通过特殊的指针类和模板来改善内存管理。此类方法通过对指针操作进行重载( overload)来提升内存回收的安全性,但是这些智能指针( smart pointer)通常存在一些限制。

大多数智能指针都是以库的形式提供,因此当需要关注性能时,其适用范围可能会受到限制。
智能指针可能只适用于管理数据块非常大、引用关系变更较少的场景,因为只有在这种情况下,智能指针的开销才有可能远小于追踪式垃圾回收。
另外,在没有编译器和运行时系统支持的情况下,基于引用计数的智能指针算不上是一种高效的、通用的小对象管理策略,特别是在指针操作非线程安全的情况下。

若安全地进行手动内存管理有过多的方法也会引发另一个问题:

  • 如果开发者始终需要考虑对象的所有权管理问题,那么他究竟应当使用哪种方法?

当使用第三方代码库时,这一问题将更加严重。

  • 例如,第三方代码库使用哪种方法进行内存管理?所有的第三方库是否都使用同一种方法?

1.2 自动动态内存管理

自动动态内存管理可以解决大多数悬挂指针和内存泄漏问题。

垃圾回收(garbage collection,GC) 可以将未被任何可达对象引用的对象回收,从而避免悬挂指针的出现。

此处应该指的是类似 “根可达方法” 一类的方法。 当对象不能从根上访问到时,它就是垃圾,需要回收。

原则上讲,回收器最终都会将所有不可达对象回收,但是有两个注意事项:

  1. 追踪式回收( tracing collection) 引入“垃圾”这一具有明确判定标准的概念,但它不一定包含所有不再使用的对象
  2. 后面章节将要描述,在实际情况下,出于效率原因,某些对象可能不会被回收。

只有回收器可以释放对象,所以不会出现二次释放(double-freeing)问题。

回收器掌握堆中对象的全局信息以及所有可能访问堆中对象的线程信息,因而其可以决定任意对象是否需要回收。

显式释放的主要问题在于其无法在局部上下文中掌握全局信息,而自动动态内存管理则简单地解决了这一问题。

此外,显示释放不满足低耦合的设计原则。而垃圾回收就可以解除模块之间内存管理层次之间的耦合,且不需要额外的内存接口,从而提高了可复用性。

内存泄漏是最普遍的一种内存错误,尽管垃圾回收可以减少内存泄漏现象的出现,但也不能保证完全根除。

如果某个对象在程序未来的运行过程中不可达(例如,从已知根集合开始通过任何指针链都不可达),则回收器会将其回收,这是删除对象的唯一方法,因此悬挂指针不会出现;如果删除某个对象导致其子对象也不可达,则它也将得到回收。

但是,垃圾回收仍不能确保根除内存泄漏,对于那种一直可达但无限增长的数据结构,或者一直可达但永远不会再用到的对象,垃圾回收也无能为力。

垃圾回收不是一剂万能的灵丹妙药,它所针对和解决的是一个特定的问题,即内存资源的管理问题。在具有垃圾回收功能的语言中,通用资源管理仍然是一个很大的问题。

在显式内存管理的系统中,内存的释放与其他资源的释放具有直接且天然的联系,而自动内存管理系统却引入了新的问题:

  • 如何在缺少这种天然联系的情况下对其他资源进行管理。

有趣的是,许多资源释放场景都会用到一些与垃圾回收类似的机制,并据此来检测某个资源在程序的后续运行过程中是否会继续使用(可达)。

1.3 垃圾回收算法的比较点

  1. 安全性:任何时候都不能回收存活的对象

  2. 吞吐量:垃圾回收所花的时间越少越好

文献中通常用 标记/构造率( marklcons ratio) 来衡量花费在垃圾回收上的时间。
它表示回收器(对存活对象进行标记)与赋值器(mutator)(创建或者构造新的链表单元)活跃度的比值。
然而在大多数设计良好的架构中,赋值器会比回收器占用更多的CPU时间,因此在适当牺牲回收器效率的基础上提升赋值器的吞吐量,并进一步提升整个程序(赋值器+回收器)的执行速度,一般来说是值得的。

  1. 完整性与及时性

    • 完整性:堆中所有的垃圾终会被回收。当然这通常是不现实、不可取的(自引用结构)。从性能而言,一次 回收过程(collection cycle) 回收部分堆对象更合理。
    • 及时性:及时回收不需要的垃圾。
  2. 停顿时间:目前没有垃圾回收算法能够与程序并行完成全流程。必然会暂停程序。应尽量减少任务被影响的时间。

在并发垃圾回收器中,赋值器与回收器同时工作,其目的在于避免或者尽量减少用户程序的停顿。此类回收器会遇到浮动垃圾(floating garbage)问题,即如果某个对象在回收过程启动之后才变成垃圾,那么该对象只能在下一个回收周期内得到回收。
因此在并发回收器中,衡量完整性更好的方法是统计所有垃圾的最终回收情况,而不是单个回收周期的回收情况。不同的回收算法在回收及时性(promptness)方面存在较大差异,进而需要在时间和空间上进行权衡。

  1. 空间开销:垃圾回收功能也会产生空间占用。

  2. 针对特定语言的优化:不同种类的语言可能对回收器具有不同的要求,最显著的差异是语言中指针功能的不同,以及回收器调用对象终结的需求不同。

  3. 可拓展性与可移植性:很多垃圾回收算法需要操作系统或者硬件的支持(例如需要依赖页保护机制,需要对虚拟内存空间进行二次映射,或者要求处理器能够提供特定的原子操作),但这些技术并不需要很强的可移植性。

1.4性能上的劣势

与显式内存管理相比,自动内存管理是否存在性能上的劣势?

我们将通过对这一问题的分析,来对两者的优劣进行总结。一般来说,自动内存管理的运行开销很大程度上取决于程序的行为,甚至硬件条件,因而很难对其进行简单评估。一个长期以来的观点是,垃圾回收通常会在总内存吞吐量以及垃圾回收停顿时间方面引人一些不可接受的开销,从而导致应用程序的执行速度慢于显式内存管理策略。自动内存管理确实会牺牲程序的部分性能,但是远不如想象中那样严重。诸如malloc和free等显式内存操作也会带来一些显著开销。

Herts,Feng,Herger[2005]测量了多种Java基准测试程序和回收算法花费在垃圾回收上的真正开销。他们构建了一个 Java虚拟机并用其精确地观察到对象何时不可达,同时使用可达追踪的方法驱动模拟器来测量回收周期与高速缓存不命中( cache miss)的情况。他们将许多不同种类的垃圾回收器配置与各种不同的malloc/free实现进行比较,比较的方法是:如果追踪发现某一对象变成垃圾,则调用free将其释放。

Herts等人发现,尽管用这两种方式的测量结果差异较大,但是如果堆足够大(达到所需最小空间的5倍),那么垃圾回收器的执行时间性能将可以与显式分配相匹敌,但对于一般大小的堆,垃圾回收的开销会平均增大17%。

1.5 术语和符号

首先需要说明的是存储的单位。我们遵循一个字节包含八个位这一惯例。我们简单地使用KB (kilobyte)、MB ( megabyte)、GB ( gigabyte)、TB ( terabyte)来描述对应的2的整数次幂内存单元(分别是 2 10 2^{10} 210 2 20 2^{20} 220 2 30 2^{30} 230 2 40 2^{40} 240),而不使用SI数字前缀的标准定义。

1.5.1 堆

堆是由一段或者几段连续内存组成的空间集合。

  • 内存颗粒(granule) 是堆内存分配的最小单位,一般是一个字(word)或者一个双字(double-word),这取决于需要的 对齐(alignment) 方式。

1 word = 2 byte
1 doublte-word = 2 word

  • 内存块(chunk) 是一组较大的连续内存颗粒。
  • 内存单元(cell) 是由数个连续颗粒组成小内存块,通常用于内存的分配和释放,也可能由于某种原因被浪费或闲置。

对象(object) 是为应用程序分配的内存单元。对象通常是一段可寻址的连续字节或字的数组,其内部被划分成多个 槽(slot) 或者 域(field),如图1.3所示(尽管在某些实时系统或者嵌入式系统中的内存管理器会通过指针结构来实现较大的独立对象,但是这一结构并不暴露给用户程序)。

  • 可能会包含一些非引用的纯数据,例如整数。引用可以是指向某个堆中对象的指针,也可以是空(NULL)。
  • 引用通常是一个正规指针(canonical pointer),指向对象头部(即对象首地址)或者距头部有一定偏移量的地址。某些情况下,对象会用一个 头域(header field) 来存放运行时系统会用到的元数据,它一般(但并不绝对)位于对象的起始地址。
  • 派生指针(derived pointer) 一般是在对象的正规指针的基础上增加一个偏移量而得到的指针
  • 内部指针 (interior pointer) 是指向内部对象域的派生指针。

在这里插入图片描述

  • 内存块(block) 是依照特定大小(通常是2的整数次幂)对齐的大块内存。

帧(frame)和空间(space)

  • 帧(与“栈帧”的概念无关) 是地址空间中一大段 2 k 2^k 2k大小的地址空间,空间是由一系列(可能)不连续的内存块(甚至对象)组成的集合且系统处理每个内存块的方式相似。
  • 页(page) 是由硬件以及操作系统的虚拟内存机制定的,高速缓存行( cache line)或者高速缓存块(cache block)是由CPU的高速缓存(cache)定义的。
  • 卡(card) 是以2的整数次幂对齐的内存块,其大小一般小于一页,且通常与某跨空间指针(见11.8节)的方案有关。

为何要内存对齐:

  1. 操作系统读取数据是一段一段读取的,保证和为2的幂,可以方便寄存器读取。
    (因为读起来更快,如果是某页中的数据中的最小内存单位的某一部分,读取来会很麻烦。但如果是,整个页或者一个最小内存单位,就可以直接全部读出。)
  2. 当然也与一些硬件的限制有关。

1.5.2 赋值器与回收器

对于使用垃圾回收的程序,Dijkstra等将其执行过程划分为两个半独立的部分:

  • 赋值器 执行应用代码。这一过程会分配新的对象,并且修改对象之间的引用关系,进而改变堆中对象图的拓扑结构,引用域可能是堆中对象,也可能是根,例如静态变量、线程栈等。
  • 回收器(collector) 执行垃圾回收代码,即找到不可达对象并将其回收。

一个程序可能拥有多个赋值器线程,但是它们共用同一个堆。相应的,也可能存在多个回收器线程。

1.5.3 赋值器根

与堆内存不同,赋值器的根是一个有限的指针集合,赋值器可以不经过其他对象直接访问这些指针。

堆中直接由赋值器根所引用的对象称为根对象(root object)

当赋值器访问堆中的对象时,它需要从当前的根对象集合中加载指针(进而会增加新的根)。赋值器也可能会丢弃一些根,例如将某个根指针改写为新的引用(即改写为空指针或者指向其他对象的指针)。我们用Roots来表示根集合。

在实际情况下,根通常包括静态/全局存储以及线程本地存储(例如线程栈),赋值器可以通过根中的指针直接操纵堆中对象。在赋值器线程执行一段时间后,线程状态(及其根集合)可能发生变化。

在类型安全的语言中,一旦某个对象在堆中不可达,并且赋值器的所有根指针中也不包含对该对象的引用,赋值器将无法再次访问该对象。赋值器(在没有与运行时系统交互的情况下)不能随意地“重新发现”该对象,因为其不能通过任何指针到达该对象,也不能在该对象的地址上构造新对象。在某些语言中,部分对象需要进行终结(finalisation),即当这些对象不可达时,运行时系统会将其复活,从而赋值器会再次访问到复活之后的对象。这里我们关注的是,赋值器在没有外部系统协助的情况下是无法访问到不可达对象的。

1.5.4 伪代码

书中代码都是伪代码,<-代表赋值

二、标记—清扫回收

理想的垃圾回收的目的是回收程序不再使用的对象所占用的空间,任何自动内存管理系统都面临3个任务:

  1. 为新对象分配空间。
  2. 确定存活对象。
  3. 回收死亡对象所占用的空间。
  • 标记-清扫(mark-sweep)
  • 标记-复制(mark-copy)
  • 标记-整理(mark-compact)
  • 引用计数( reference counting)

是4种最基本的垃圾回收策略。大多数回收器可能会以不同的方式对这些方法进行组合。

标记–清扫算法是一种 间接回收( indirect collection)算法 ,它并非直接检测垃圾本身,而是先确定所有存活对象,然后反过来判定其他对象都是垃圾。

需要注意的是,该算法的每次调用都需要重新计算存活对象集合,但并非所有的垃圾回收算法都需要如此。

第5章将介绍一种 直接回收( direct collection) 策略,即引用计数。与间接回收不同,直接回收可以通过对象本身来判断其存活性,因此其不需要额外的追踪过程。

2.1 标记—清扫算法

从垃圾回收器角度来看,赋值器线程所执行的只有3种操作:创建、读、写。

每一个回收算法必须对这3种操作进行合理的重定义。

标记―清扫算法与赋值器之间的接口十分简单:如果线程无法分配对象,则唤起回收器,然后再次尝试分配。

为了强调回收器工作在 “万物静止”模式 下而非与赋值器线程并发执行,我们特别使用atomic关键字来标记回收程序。

我们假设赋值器运行在一个或者多个线程之上,且只有一个回收器线程,当回收器线程运行时,所有的赋值器线程均处于停止状态。这种“万物静止”( stop the world)的策略大幅简化了回收器的实现。
从赋值器线程角度来看,回收过程的执行是原子的。

分配阶段

如果回收完成之后仍然没有足够的内存以满足分配需求,则说明堆内存耗尽,这通常是一个严重的错误。某些语言在遇到这一情况时会抛出异常,开发者可以将其捕获,如果可以通过删除引用的方式释放内存(例如释放那些在未来可以重新创建的数据结构),那么可以再次请求分配。

在这里插入图片描述

标记阶段

回收器在遍历对象图之前,必须先构造标记过程需要用到的起始 工作列表(work list) (即算法2.2中的markFromRoots方法),即对每个根对象进行标记并将其加入工作列表(第11章将讨论如何寻找根集合)。

回收器可以通过设置对象头部某个位(或者字节)的方式对其进行标记,该位(字节)也可位于一张额外的表中。不包含指针的对象不会有任何后代,因此无需将其加入工作列表,但仍需将其标记。

为了减少工作列表的大小,markFromRoots方法在将线程根加入到工作列表之后立刻调用mark方法,而如果将mark方法(算法2.2的第8行)从循环中提出则可加快线程根扫描,例如并发回收器可能只需要挂起线程并扫描其栈,而接下来的对象图遍历则可以与赋值器并发进行。

在这里插入图片描述
在这里插入图片描述
单线程回收器可以基于栈来实现工作列表,此时回收器将以深度优先顺序遍历(depth-first traversal) 对象图。如果将标记位保存在对象中,那么mark方法所处理的将是那些刚刚被标记的对象,因此这些对象很可能仍在硬件高速缓存中。正如我们反复提到的,回收过程的高速缓存相关行为会影响到回收器的性能,我们将在稍后讨论提升回收器局部性的技术。

标记存活对象的过程非常直观:从工作列表中获取一个对象的引用,然后对其所引用的其他对象进行标记,直到工作列表变空为止。

需要注意的是,在这一版本的mark方法中,工作列表中的每个对象都拥有自身的标记位。如果某一指针域的值为空,或者其指向的对象已经标记过,则无需对其进行处理,否则回收器需要对其目标对象进行标记并将其添加到工作列表中。

标记阶段完成的标志工作列表变空,而不是将所有已标记对象都添加到工作列表。此时回收器已经完成每个可达对象的访问与标记,任何没有打上标记位的对象都是垃圾。

清扫阶段

回收器会将所有未标记的对象返还给分配器(如算法2.3所示)。

在这一过程中,回收器通常会在堆中进行线性扫描,即从堆底开始释放未标记的对象,同时清空存活对象的标记位以便下次回收过程复用。

另外,如果标记过程使用两个标记位,且连续两次标记过程使用的标记位不同,则可以省去清空标记位的开销。

在这里插入图片描述

我们将在第7章之后讨论allocate和free的具体实现细节,但需要注意的是,标记-清扫回收器要求堆布局满足一定的条件:

  1. 标记-清扫回收器不会移动对象,因此内存管理器必须能够控制堆内存碎片,这是因为过多的内存碎片可能会导致分配器无法满足新分配请求,从而增加垃圾回收频率,在更坏情况下,新对象的分配可能根本无法完成;

  2. 清扫器必须能够遍历堆中每一个对象,即对于给定对象,不管其后是否存在一些用于对齐的填充字节,sweep方法都必须能够找到下一个对象,因此用nextObject方法要完成堆的遍历,仅获取对象的大小信息是远远不够的(算法2.3中的第7行)。

我们将在第7章讨论堆的可遍历性。

2.2 三色抽象

三色抽象(tricolour abstraction)可以简洁地描述回收过程中对象状态的变化(是否已被标记、是否在工作列表中等)。

三色抽象是描述追踪式回收器的一种十分有用的方法,利用它可以推演回收器的正确性,这正是回收器必须保证的。

在三色抽象中,回收器将对象图划分为黑色对象(确定存活)白色对象(可能死亡)

  1. 任意对象在初始状态下均为白色
  2. 当回收器初次扫描到某一对象时将其着为灰色
  3. 当完成该对象的扫描并找到其所有子节点之后,回收器会将其着为黑色。

从概念上讲,黑色意味着已经被回收器处理过,灰色意味着已经被回收器遍历但尚未完成处理(或者需要再次进行处理)。

三色抽象也可以推广到对象的域中:灰色表示正在处理的域,黑色表示已经处理过的域。


在标记–清扫回收过程中,对象的颜色变化过程如图2.1所示。

在这里插入图片描述

2.3 改进的标记—清扫算法

2.3.1 位图标记

回收器可以将对象的标记位保存在其头部的某个字中,除此之外也可以使用一个 独立位图(bit-map) 来维护标记位,即:位图中的每个位关联堆中每个可能分配对象的地址。

位图所需的空间取决于虚拟机的字节对齐要求。位图可以只有一个,也可以存在多个,例如在块结构的堆中,回收器可以为每个内存块维护独立的位图,这一方式可以避免由于堆不连续导致的内存浪费。

有两个问题:

  • 回收器可以将每个内存块的位图置于其自身内部,但如果所有内存块中位图的相对位置全部相同,则可能导致性能的下降,因为不同内存块的位图之间可能会争用相同的组相关高速缓存( set-associative cache)

采用组相联映射方式工作的高速缓存

  • 对位图的访问同时也意味着对位图所在页的访问(即可能导致缺页异常——译者注)。

首先,硬盘中存储不会一直连续,反而碎片化才是常态。
在内存中也是,读取时间的不同,也会让地址不连续。因此,由虚拟地址去映射实际内存地址,让虚拟地址连续。然后我们使用的时候实际会先使用虚拟地址。
由 MMU(Memory Management Unit)硬件单元处理,它作用是虚拟地址与物理地址的转换。这个转换的记录就页表(PageTable,内存中)。还有一个快表(TLB,在MMU中)。先查块表后查页表。
操作系统不会直接加载一个应用它的所有页,而是会写入页表(映射了应用所有的页)。
以上种种,当读取到页没有在页表中时,或者没有物理内存地址时,就会发生缺页。
缺页将耗费时间从硬盘中读取,然后写入页表,
可参考-初探 MMU
可参考-缺页异常

通过更多的指令,来保证程序的局部性。

为避免高速缓存的相关问题

  • 可以将内存块中位图的位置增加一个简单偏移量,例如内存块地址的简单哈希值。

  • 还可以将位图存放在一个额外的区域中,并以其所对应内存块的哈希值等作为索引,这样既避免了换页问题,也避免了高速缓存冲突。

位图标记通常仅适用于单线程环境,因为多线程同时修改位图可能存在较大的写冲突风险。设置对象头部中的标记位通常是安全的,因为该操作是幂等的,即最多只会将标记位设置多次。

相对于位图,实践中更常用的是字节图(byte-map),虽然它占用的空间是前者的8倍,但却解决了写冲突问题。另外还可以使用同步操作来设置位图中的位。


在实际应用中,如果将标记位保存在对象头部通常会带来额外的复杂度,因为头部通常会存放一些赋值器共享数据,例如锁或者哈希值,那么当标记线程与赋值器线程并发执行时可能会产生冲突。

因此,为了确保安全,标记位通常会占用头部中一个额外的字,以便与赋值器共享数据区分,当然也可以使用原子操作来设置头部中的标记位。

位图标记最初应用在 保守式回收器( conservative collector) 中。

保守式回收器的设计初衷是为C和C++等“不合作”的语言提供自动内存管理功能。

类型精确(type-accurate) 系统可以精确地识别每一个包含指针的槽,不论其位于对象中,还是位于线程栈或者其他根集合中。而保守式回收器则无法得到编译器和运行时系统的支持,因而其在识别指针时必须采用保守的判定方式,即:如果槽中某个值看起来像是指针引用,那么就必须假定它是一个指针。

保守式回收器可能错误地将一个槽当作指针,这带来了两个安全上的要求(也因此使用位图):

  1. 回收器不能修改任何赋值器可能访问到的内存地址的值(包括对象和根集合)。这一要求导致保守式回收器不能使用任何可能移动对象的算法,因为对象被移动之后需要更新指向该对象的所有引用。这同时也导致在头域中保存标记位的方案不可行,因为错误的指针会指向一个实际并不存在的“对象”,因此设置或者清理标记位可能会破坏用户数据。

  2. 应当尽可能减少赋值器破坏回收器数据的可能性。与将标记位等回收器元数据存放在一个单独区域的方案相比,为每个对象增加一个回收器专用头部数据会存在更高的风险。


使用位图标记的另一个重要目的是**减少**回收过程中的**换页次数**。

许多证据表明,对象往往成簇诞生并成批死亡,而许多分配器往往也会将这些对象分配在相邻的空间。

使用位图来引导清扫可以带来两个好处:

  1. 在位图/字节图中,一个字内部的每个位/字节全部都被设置/清空的情况会经常出现,因此回收器可以批量读取/清空一批对象的标记位

  2. 通过位图标记可以更简单地判定某一内存块中的所有对象是否都是垃圾,进而可能一次性回收整个内存块。


Printezis和 Detlefs在一个主体并发分代回收器(mostly-concurrent,generationalcollector)中使用位图来减少标记栈所占用的空间。与其他方法类似,回收器首先在位图中对赋值器的根进行标记,然后标记线程通过线性扫描位图来寻找存活对象。

在算法2.4中,回收器将遵从如下不变式:

  1. 位于当前指针(即 mark方法中的指针cur)之后的所有已标记对象均为黑色,而位于当前指针之前的所有已标记对象均为灰色。

  2. 根据“将对象从栈中弹出,并且递归地标记其后代,直到栈为空”的原则,当回收器找到下一个已标记存活对象cur时会将其压入标记栈中,并继续进行循环。

  3. 在markstep中,如果新追踪到的子节点地址小于堆中的cur,则回收器将其压入标记栈,否则该对象的处理将被推迟到后续的线性查找过程中。

该算法与算法2.1的主要区别在于将对象子节点压入标记栈的条件,即算法2.4中的第15行。

在算法2.4中,回收器的黑色波面将在堆中线性移动,只有位于该波面之后的对象才会得到标记(即被压入标记栈)。尽管该算法的时间复杂度与待回收空间的大小成正比,但在实际应用中,位图查找的开销通常较低。
在这里插入图片描述

2.3.2 懒惰清扫

标记过程的时间复杂度是O(L),其中L为堆中存活对象的数量;清扫过程的时间复杂度是O(H),其中H为堆空间大小。由于H>L,所以我们很容易误认为清扫阶段的开销是整个标记–清扫开销的主要部分,但实际情况并非如此。

标记阶段指针追踪过程中的内存访问模式是不可预测的,而清扫过程的可预测性则要高得多,同时清扫一个对象的开销也比追踪的开销小得多。

优化清扫阶段高速缓存行为的一种方案是使用对象预取

为避免内存碎片,标记–清扫算法中所用的分配器通常会将大小相同的对象分布在连续空间内(参见7.4节),此时回收器可以依照固定步幅对大小相同的对象进行清扫。这一方式不仅支持软件预取,而且可以充分利用现代处理器的硬件预取能力。

接下来我们考虑如何降低甚至消除清扫阶段赋值器的停顿时间。通过观察可以发现,对象及其标记位存在两个特征:

  1. 一旦某个对象成为垃圾,它将一直都是垃圾,不可能再次被赋值器访问或者复活

  2. 赋值器永远不会访问对象的标记位。因此在赋值器工作的同时,清扫器可以并行修改标记位,甚至修改垃圾对象的域,并将其链接到分配结构体中。


我们同样也可以使用多个清扫器线程与赋值器并发工作,但更加简单的方案是使用 懒惰清扫(lazy sweeping) 策略。(在生成新的对象的时候,才去可能去清除垃圾)

该方案利用分配器来扮演清扫器的角色,即把寻找可用空间的任务转移到allocate过程中,从而不再需要单独的清扫阶段。

最简单的清扫策略是,allocate简单地向前移动清扫指针,直到在连续的未标记对象中找到一块足够大的空间,但一次清扫包含多个对象的内存块更具有实用性。

算法2.5演示了使用懒惰清扫策略处理整个内存块的方法。分配器通常只会在一个内存块中分配相同大小的对象(在第7章中详述),每种空间 大小分级(size class) 都会对应一个或多个用于分配的内存块,以及一个待回收内存块链表(reclaim list of blocks)

在回收过程中,回收器依然需要将堆中所有存活对象标记,但标记完成后回收器并不急于清扫整个堆,而是简单地将完全为空的内存块归还给块分配器(见算法2.5的第5行),同时将其他内存块添加到其所对应空间大小分级的回收队列中。

一旦 “万物静止” 式的回收过程结束,赋值器立即开始工作。对于任意内存分配需求,allocate方法首先尝试从合适的空间大小分级中分配一个空闲槽(与算法7.2所使用的策略相同),如果失败则调用清扫器执行懒惰清扫,即从该空间大小分级的回收队列中取出一个或多个内存块进行清扫,直到满足分配要求为止(见算法2.5的第12行)。

但也可能会出现没有内存块可供清扫,或者被清扫的内存块不包含任何空闲槽的情况,此时分配器便要尝试从更低级别的块分配器中获取新内存块。新内存块通常需要通过设置元数据的方式进行初始化,如构建空闲槽链表或者创建标记字节图,但如果无法获取新内存块,则必须执行垃圾回收。

在这里插入图片描述
在这里插入图片描述
在块结构堆(例如从多个空间大小分级中进行分配)中进行懒惰清扫有一个细微的问题需要注意。

如果使用连续堆,并且确保在因内存耗尽而再次引发垃圾回收之前,分配器已经完成所有空闲节点的清扫。

但懒惰清扫却不能满足这一要求,因为几乎可以肯定的是,在分配器完成对每个空间大小分级的待回收内存块的清扫之前,必然会存在某个空间大小分级(及其所有的空闲内存块)先被耗尽。这将导致两个问题:

  1. 未清扫内存块中的垃圾对象不会被回收,进而产生内存泄漏,但如果未清扫块中包含存活对象,则泄漏是无害的,因为一旦赋值器从该空间大小分级中分配对象,这些槽便可以得到回收。

  2. 如果未清扫内存块中的对象后来都成为垃圾,那么我们就失去了将内存块整体回收的机会,从而只能使用开销更大的基于空间大小分级的方法进行清扫。

最简单的解决方案是在标记开始之前完成堆中所有内存块的清扫,但更好的策略是增加内存块得到懒惰回收的几率。

懒惰回收存在多种优点:

  1. 对象槽通常会在完成清扫后立即得到复用,因而提升了程序的局部性

  2. 懒惰清扫策略将标—清扫算法的复杂度降低到与堆中存活对象成正比的水平,这一点与第4章将要提到的半区复制式垃圾回收是相同的。

Boehm特别提到,在标记—复制算法能够最优发挥功效的场景下,标记—懒惰清扫算法也能达到最佳表现,即如果堆中大部分空间都是空闲的,那么通过懒惰清扫来查找未标记对象将是最快的。

在实际应用中,赋值器初始化对象的开销主要取决于清扫和分配过程的开销。

2.3.3 标记过程中的高速缓存不命中问题

我们已经看到,预取技术可以改进清扫阶段的性能。下面我们将考察如何利用预取技术提升标记阶段的性能。

2.3.1 节提到,使用位图标记可以减少由于读取和设置标记位导致的高速缓存不命中,但在追踪过程中对未标记对象的域的读取也会受到高速缓存不命中问题的影响。

此时使用位图标记所带来的潜在缓存好处会很容易被加载对象域的开销所掩盖。

每当处理器想要从主存储器中获取数据时,首先它将查看高速缓存缓冲区,以查看缓冲区中是否存在相应的地址。如果存在,它将使用缓存执行操作;无需从主内存中获取。这称为“缓存命中”。
如果该地址不存在于高速缓存中,则称为“高速缓存未命中”。
如果发生了高速缓存未命中,则意味着处理器需要进入主存储器以获取地址。

如果某个对象不包含指针,则无须加载其任意一个域。尽管不同语言和不同应用程序之间的差别较大,但无论如何,堆中都很可能存在相当多的不包含用户自定义指针的对象。

对象是否包含指针取决于该对象的类型,一种判定的方式是根据对象头部中的类型信息槽决定,也可以根据对象的 地址(address) 获取其类型信息,例如当同种类型的对象在堆中集中排列时。


Cher等观察到,高速缓存行的预取遵循广度优先 (breadth-first)、先进先出(first-in,first-out,FIFO)顺序,而标记—清扫算法对图的遍历却遵循深度优先(depth-first)、后进先出(last-in,first-out,LIFO)顺序。(因此命中率有问题)

他们的解决方案是在标记栈之前增加一个先进先出队列(如图2.2、算法2.6所示)。

与普通的标记过程类似,用mark方法将对象加入工作列表的方式依然是将其压入标记栈,而当从工作列表中获取一个对象时,回收器将栈顶对象弹出并将其追加到队尾,而用mark方法所处理的对象则是队列中最老的对象。

回收器会对从栈顶弹出的对象进行预取,队列的长度将决定预取的距离。在对象从栈中弹出之后,对其进行少量的预取工作可以确保要扫描的对象已经加载到高速缓存中,从而减少高速缓存不命中对标记过程的影响。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

基于先进先出队列进行对象预取可以确保mark方法在扫描对象时免受高速缓存不命中的影响(见算法2.2中第16~17行),但回收器检测和设置对象子节点标记位的操作依然可能受到高速缓存不命中的影响(见算法2.2中第18行)。

Garner等通过对mark方法中追踪循环的重组来达到更好的预取效果。在算法2.2中,对象图中的每个存活节点都会经历一次压入标记栈的过程,而另一种方案是将对象图中的每条边都遍历和压入标记栈一次,即对于未被标记对象的后代,回收器并不判断其是否已经被标记过,而是无条件地将其加入工作列表(见算法2.7)。


对象图中边的数量通常大于节点数,因此将边加人工作列表的方案不但会花费更多的指令,而且需要更大的工作列表。

然而,如果在工作列表中增、删这些额外对象的开销足够小,那么提升高速缓存命中率的收益将超过这些额外工作的开销。

算法2.7将标记操作从内部循环提出,因此isMarked和 Pointers等可能导致高速缓存不命中的方法所操作的将是已被先进先出队列预取的同一对象obj。

Garner等发现,即使不使用软件预取,追踪边也比追踪节点更能改善性能,他们猜测,循环结构的变化以及先进先出队列的使用可以提升内存访问模式的可预测性,进而允许更加积极的硬件预测行为。

在这里插入图片描述

附录

[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译
[2]《Java虚拟机:JVM高级特性与最佳实践》周志明 著

猜你喜欢

转载自blog.csdn.net/weixin_46949627/article/details/127729807