查看原文
其他

DartVM GC 深度剖析|得物技术

浮生若梦 得物技术 2024-03-08

目录

一、前言

二、Dart 对象

    1. 对象内存分配

    2. 对象内存布局

    3. 对象指针

三、DartVM GC

    1. Scavenge

    2. Mark-Sweep

        2.1 对象标记

        2.2 Sweep

    3. Mark-Compact

    4. 并发标记

    5. 写入屏障

    6. 写入屏障消除

四、Safepoints

五、GC 问题定位

六、总结&感悟

前言

GC 全称 Garbage Collection,垃圾收集,是一种自动管理堆内存的机制,负责管理堆内存上对象的释放。在没有 GC 时,需要开发者手动管理内存,想要保证完全正确的管理内存需要开发者花费相当大的精力。所以为了让程序员把更多的精力集中在实际问题上,GC 诞生了。Dart 作为 Flutter 的主要编程语言,在内存管理上也使用了 GC。

而在 Pink(仓储作业系统)的线上稳定性问题中,有一个和 GC 相关的疑难杂症,问题堆栈发生在 GC 标记过程,但是导致问题的根源并不在这里,因为 GC 流程相当复杂,无法确定问题到底出在哪个环节。于是,就对 DartVM 的 GC 流程进行了一次完整的梳理,从 GC 整个流程逐步排查。

Dart 对象

要想完整的了解 GC,就要先了解 Dart 对象在 DartVM 的内存管理是怎样呈现的。这里,我们先从 Dart 对象的内存分配来展开介绍。

对象内存分配

在 Flutter 中,Dart 代码会先编译成 Kernel dill 文件,再通过 gen_snapshot 将 dill 文件生成 Snapshot。而 dill 文件在生成 Snapshot 的中间过程,会将 dill 文件中 AST 翻译成 FlowGraph,然后再 FlowGraph 中的 il 指令编译成 AOT 机器指令。那创建对象的代码最终会编译成什么指令呢?接下来,我们先看一下 AST 中对象构造方法调用的表达式最终翻译成 FlowGraph 是什么样的。

编译前:

void _syncAll() { final obj = TestB(); obj.hello("arg");}
编译后的 FlowGraph:
@"==== package:flutter_demo/main.dart_::__syncAll@1288309603 (RegularFunction)\r\n"@"B0[graph]:0\r\n"@"B1[function entry]:2\r\n"@" CheckStackOverflow:8(stack=0, loop=0)\r\n"@" t0 <- AllocateObject:10(cls=TestB)\r\n"@" t1 <- LoadLocal(:t0 @-2)\r\n"@" StaticCall:12( TestB.<0> t1)\r\n"@" StoreLocal(obj @-1, t0)\r\n"@" t0 <- LoadLocal(obj @-1)\r\n"@" t1 <- Constant(#arg)\r\n"@" StaticCall:14( hello<0> t0, t1, using unchecked entrypoint, result_type = T{??})\r\n"@" t0 <- Constant(#null)\r\n"@" Return:16(t0)\r\n"@"*** END CFG\r\n"

可以看到,一个构造方法调用,最终会转换为 AllocateObject 和 StaticCall 两条指令,其中 AllocateObject 指令用来分配对象内存,而 StaticCall 则是真正调用构造方法。

那 AllocateObject IL 最终转化成的机器指令又是怎样的呢?

在将 AllocateObject 指令转换为 AOT 指令前,会先通过 GenerateNecessaryAllocationStubs() 方法为 FlowGraph 中的每一个 AllocateObject 指令所对应 Class 生成一个 StubCode,StubCode::GetAllocationStubForClass() 会先检查 对应 Class 是否已经存在 allocation StubCode,如果已经存在,则直接返回;如果不存在,则会执行下面代码,为 Class 生成 allocation StubCode。

可以看出,生成的 allocation StubCode 其实主要是使用了 object_store->allocate_object_stub(),而 object_store->allocate_object_stub() 最终指向的则是 DartVM 中的 Object::Allocate()。

生成 allocation StubCode 之后,我们来看一下 AllocateObject 指令转成 AOT 机器指令是怎样的。

可以看出,最终生成的机器指令主要就是对 StubCode 的调用,而调用的 StubCode 就是上文中通过 GenerateNecessaryAllocationStubs() 生成的 allocation StubCode。所以,Dart 对象的内存分配最终是通过 DartVM 的 Object::Allocate() 来实现的。接下来,我们简单看一下 Object::Allocate() 的实现。

可以看到,Object::Allocate() 主要是通过 DartVM 中的 heap 进行内存分配的,而 heap->Allocate() 的返回值就是内存分配的地址。接下来,通过判断 address == 0 来判断,内存是否分配成功,如果分配失败,说明 heap 上已经不能再分配更多内存了,就会抛出 OOM。反之,则通过 NoSafepointScope 来建立非安全点作用域,然后,通过 InitializeObject() 为对象中属性赋初始值,完成对象初始化。到这里,Dart 对象在内存中的分配流程就结束了,接下来就是调用构造函数,完成对象的真正构造。那么,Dart 对象在内存中的存储形式是怎样的呢?接下来,我们就来介绍一下 Dart 对象的内存布局。

对象内存布局

在 Dart 中,每个对象都是一个类的实例,其内部是由一系列数据成员(类的字段)和一些额外信息组成的。而 Dart 对象在内存中是怎么存储的呢?这里,就不得不先介绍一下 DartVM 中 raw_object。

Dart 中的大部分对象都是 UntaggedObject 的形式存储在内存中,而对象之间的依赖则是通过 ObjectPtr 来维系,ObjectPtr 是指向 UntaggedObject 的指针,所以对象之间的访问都是通过 ObjectPtr。

先看一下 UntaggedObject 的实现:

代码比较长,这里直接看一下伪代码:

class UntaggedObject { // 表示对象类型的 tag var tag;
}

UntaggedObject 是 Dart VM 中一种比较基础的对象结构,所以 Dart 中的大部分对象都是由 UntaggedObject 来承载的。由于 UntaggedObject 可以存储不同类型的数据,因此需要使用 tag 字段来标识当前对象的类型。具体的实现方式是,使用 tag 字段的低位来记录对象的类型,另外的高位用来存储一些额外的信息,例如对象是否已经被垃圾回收等。所以,UntaggedObject 可以看做是 Dart 对象的 header。

一个 Dart 对象其实是由两部分组成,一个 header,一个是 fields,而 header 就是上文中的 UntaggedObject。

+-------------------+ | header word | +-------------------+ | instance variables| | (fields) | +-------------------+
  • Header word:包含了对象的类型信息、标记位、长度等一些重要元信息。具体信息将根据对象的类型与具体实现而不同。

  • Instance variables(fields):是一个数组,用于存储类的实例变量。每个字段可以存储不同的数据类型,如布尔值、数字、字符串、列表等。

接下来,我们看一下,一个 Dart 对象是如何遍历它的所有属性:

可以看出,先通过 HeapSize() 获取对象在 heap 中的实际大小,然后根据对象起始地址 + UntaggedObject 的大小计算得出 fileds 中保存第一个 ObjectPtr 的地址,然后根据对象起始地址 + 对象时机大小 - ObjectPtr 的大小计算得出 fileds 中保存的最后一个 ObjectPrt 的地址,这样就可以通过第一个 ObjectPtr 遍历到最后一个 ObjectPrt,访问到 Dart 对象中的所有属性。

对象指针

对象指针就是上文中所提到的 ObjectPtr,它指向的是直接对象或者 heap 上的对象,可以通过指针的低位来进行判断。在 DartVM 上只有一种直接对象,那就是 Smi(小整形),他的指针标记为 0,而 heap 上的对象的指针标记则为 1。Smi 指针的高位就是小整形对应的数值,而对于 heap 对象指针,指针本身就是只是指向 UntaggedObject 的地址,但是需要地址稍作转换,将最低位的标记位设置为 0,因为每个 heap 对象都是大于 2 字节的方式对齐的,所以它的低位永远都为 0,所以可以使用低位来存储一些其他信息,区分是否为直接对象还是 heap 对象。

标记为 0 可以使 Smi 可以直接执行很多操作,而无需取消标记和重新标记。

标记为 1 的 heap 对象指针 在访问对象时需要先取消标记,代码实现如下。

Heap 中的对象总是以双字节增量分配的。所以 老年代中的对象是保持双字节对齐的(address % double-word == 0),而新生代中的对象则保持双字节对齐偏移(address % double-word == word)。这样的话,我们仅需要通过对象地址的对齐方式就能判断出对象是老年代 还是 新生代,也方便了在 GC 过程快速分辨出对象是新生代 还是 老年代,从而在遍历过程中直接跳过。

DartVM GC

在介绍 DartVM GC 之前,我们先来看一下 DartVM 的内存模型。

可以看到,DartVM 中可以运行多个 isolate group,而一个 ioslate group 中又运行着多个 isolate,对于 Flutter 应用来说,通常只有 一个 isolate group,运行 main() 方法的 Root Isolate 和其他 isolate,其他 isolate 也是通过 Root Isolate 孵化而来,所以都隶属同一个 isolate group。每个 isolate group 都有一个单独的 Heap,Heap 又分为新生代和老年代,所以 DartVM 采用的 GC 方式是分代 GC。新生代使用 Scavenge 进行垃圾回收,老年代则是使用 Mark-Sweep 和 Mark-Compact 进行垃圾回收。Scavenge 采用的 GC 算法是 Copying GC,而 Copying GC 算法的思路是把内存分为两个空间,这里可以称之为 from-space 和 to-space。接下来,我们来看一下 Scavenge GC 的具体实现。

Scavenge

为了提高 CPU 的使用率,Scavenge 是多线程并行进行垃圾回收的,线程数量通过 FLAG_scavenger_tasks 来决定(默认为 2),每个工作线程处理 root object 集合中的一部分。

可以看到,Scavenge 会在 主线程和 多个 helper thread 上并发执行 ParallelScavengerTask,接下来看一下 ParallelScavengerTask 的实现。

ParallelScavengerTask 中会通过 ProcessRoots() 来遍历整个 heap 上的所有根对象 以及 RememberedSet 中的对象,而 RememberedSet 中的对象不一定是根对象,也可能是普通的老年代对象,但是它的属性中保存了新生代对象的指针,因为新生代对象在移动之后,也要更新老年代对象中的对象指针,所以ProcessRoots() 会把这类对象也看做根对象进行遍历。

然后再通过 ParallelScavengerVisitor 访问所有的根对象,如果根对象是:

ScavengePointer() 会通过 ScavengeObject() 将当前属性对象转移至新生代的 to-space 或者老年代分页上,如果是转移至老年代分页上,则会将当前属性对象记录到 promoted_list_ 队列中;之后便将新对象的地址赋值到根对象的属性,完成对象引用更新。

ParallelScavengerTask 中执行完 ProcessRoots() 之后,便开始执行 ProcessToSpace() 遍历 to-space 区域中的对象,而此时 to-space 区域中存放的正是刚刚复制过来的根对象,然后通过 ProcessCopied() 遍历根对象中的所有属性。

遍历到的属性对象,如果是新生代对象,则继续移动到 to-space 区域或者老年代内存分页中,然后用移动后的新地址更新对象属性的对象指针。

移动后的对象因为放入到了 to-space 区域,此时新加入到 to-space 的对象也将被遍历,这样根对象的属性遍历结束后会紧接着遍历属性对象中的属性,然后新的属性对象又被移动到 to-space 区域。这样周而复始,就达到了广度优先遍历的效果。所有被根对象直接引用或者间接引用到的对象都会被遍历到,对象从 from-space 转移到 to-space 或者老年代分页上,并完成对象引用更新。

ScavengeObject() 移动对象的过程中,本来在 from-space 区域的对象不一定是移动到 to-space 区域,也有可能移动到老年代分页内存上,那这些对象所关联的属性该怎么更新呢?这就要介绍一下 promoted_list_,在 ScavengeObject() 过程中,移动到老年代的对象,将会被放入 promoted_list_ 集合中,当 ProcessToSpace() 结束之后,则会调用 ProcessPromotedList() 方法遍历 promoted_list_ 集合中的对象,从而对移动到老年代的对象的所有属性进行遍历,并将其所关联的对象指针进行更新。

接下来,我们来看一下 ScavengeObject() 的实现,也就是对象移动到 to-space 的具体细节。

代码较长,这里就只截出了部分细节,可以看到,ScavengeObject() 会先通过 ReadHeaderRelaxed() 获取到对象头,通过对象头来判断当前对象是否已经被转移,如果已经转移,也直接通过 header 获取新地址的对象,然后将新对象进行返回。如果未转移,则通过 NewPage::of() 获取到对象所在的新生代内存分页,通过该分页中的 survivor_end_ 来判定该对象是否是上次 GC 中的存活对象,如果不是上次 GC 的存活对象,说明是新对象,就直接通过 TryAllocateCopy() 在 to-space 上申请内存空间得到 new_addr。接下来,就判断 new_addr 是否为 0,如果为 0,就存在两种情况,一个是该对象是上次 GC 的存活对象,一个是 TryAllocateCopy() 分配内存失败,这两种情况下就会通过 page_space_ 在老年代内存上分配内存,从而使对象从新生代转移到老年代。接下来,就是 objcpy() 将对象数据复制到新地址中。复制完成后,就会通过 ForwardingHearder 来创建一个 forwarding_header 对象,并通过 InstallForwardingPointer 将其写入到原来对象的对象头中,这样在遍历对象过程中,快速判断出对象是否已经转移,并通过对象头快速获取到转移后的新地址。至此,ScavengeObject() 的流程就结束了,然后将新对象返回出去,然后上层调用点 ScavengePointer() 就会通过这个新对象来更新对象指针。可以看出,Scavenge 在移动对象的同时,将对象指针也进行更新了,这样就只需遍历一次新生代内存上的对象,即可完成 GC 的主流程。所以,新生代的 GC 算法相对于其他 GC 算法要高效很多。

而 Scavenge 之所以采用 Copying GC 算法,正是因为它优秀的吞吐量,吞吐量意思就是单位时间内 GC 的处理能力,可以简单理解为效率更好的算法吞吐量更优秀。对比一下,Mark-Sweep 算法的消耗是根搜索和遍历整个 heap 花费的时间之和,Copying GC 算法则是根搜索和复制存活对象。一般来说 Copying GC 算法的吞吐量会更优秀,堆越大差距越明显。众所周知,在算法上,时间维度和空间维度是成反比的,既然有了这么优秀吞吐量,那必然要牺牲一部分空间,所以 Copying GC 在内存使用效率上相对于其他 GC 算法是比较低的。Copying GC 算法总是有一个区域无法使用,对比其他使用整堆的算法,堆的使用效率低。这是 Copying GC 算法的一个重大缺陷。

Mark-Sweep

老年代 GC 主要分为两种方式,一个是 Mark-Sweep,一个是 Mark-Compact,而 Mark-Sweep 相对于 Mark-Compact 更加轻量。

触发 GC 的方式有两种,一个是系统处于空闲状态时,一个对象分配内存时内存不足,上方代码则是 Heap::NotifyIdle() 中的一段逻辑。Heap::NotifyIdle() 是 Dart VM 中的一个函数,用于通知垃圾回收器当前系统处于空闲状态,垃圾回收器可以利用这段空闲时间进行垃圾回收。具体来说,Heap::NotifyIdle函数会向垃圾回收器发送一个通知,告诉垃圾回收器当前系统处于空闲状态,可以进行垃圾回收。垃圾回收器在收到通知后,会开始启动垃圾回收器,对堆中的垃圾对象进行回收。这个函数可以在应用程序中的任何时间点调用,例如当应用程序处于空闲状态时,或者当应用程序需要高峰时期的性能时。

Mark-Sweep 主要分为两个阶段一个是标记阶段,另一个则是清理阶段。这里我们先看一下标记阶段。

对象标记

对象标记是整个老年代 GC 的一个重要流程,不管是 Mark-Sweep,还是 Mark-Compact,都建立在对象标记的基础上。而对象标记又分为并行标记和并发标记,这里我们以并行标记为例来介绍一下标记阶段。并行标记是利用多个线程同时进行标记任务,提高多核 CPU 的使用率,从而减少 GC 流程中对象标记所花费的时间,这里我们直接看一下 并行标记过程中 MarkObjects() 具体实现。

可以看到,MarkObjects() 中的 FLAG_marker_tasks 和 Scavenge 中的 FLAG_scavenger_tasks 相似,为了充分利用 CPU,提高 GC 的性能,通过 FLAG_marker_tasks 决定线程的个数,然后开启多个线程并发标记,FLAG_scavenger_tasks 默认值也是 2。这里我们假设 FLAG_scavenger_tasks 是 0,以单线程标记 来梳理 对象标记的整个流程。因为是单线程,所以这里忽略掉 ResetSlices() 的实现,ResetSlices() 的主要作用是进行分片,为多个线程划分不同的标记任务。接下来,我们可以看到 IterateRoots(),开始遍历根对象,从根对象开始标记,根对象标记之后,会将根对象添加至 work_list_。紧接着,会调用 ProcessDeferredMarking() 从 work_list_ 中取出对象,然后遍历它的所有属性,将属性所关联的对象进行标记,并将其再次加入 work_list_ 继续遍历,周而复始,就会把根对象直接引用或间接引用的对象都进行了标记,从而达到了从根对象开始的广度优先遍历。接下来,我们看一下对象标记 MarkObject() 的具体实现。

MarkObject() 截图中删除了部分细节,这里主要看一下关键流程。可以看到,MarkObject() 会先判断对象是否是小整形或者是新生代对象,小整形在上文有介绍到,指针即对象,无需标记,而新生代对象也无需标记,紧接着就是调用 TryAcquireMarkBit() 进行标记,标记完成后,就会调用 PushMarked() 将对象加入到 work_list_ 中。接下来,我们看一下 DrainMarkingStack() 的实现,也就是遍历 work_list_ 的实现。

正如上文所述,DrainMarkingStack() 会以一直从 work_list_ 中取出对象,然后通过 VisitPointersNonvirtual() 遍历对象中的所有属性,将属性所关联的对象进行标记,标记之后再将其加入到 work_list_,致使继续往下遍历,直到 work_list_ 中的对象被清空。这样一来,根对象直接引用和间接引用的对象都将会标记。至此,标记对象的核心流程就大致介绍完了。

接下来,我们看一下 Mark-Sweep 中另外一个重要的环节 Sweep。

Sweep

Sweep 作为 Mark-Sweep 的重要一环,主要作用是将未标记对象的内存进行清理,从而释放出内存空间给新对象进行分配。

我们直接看一下 Sweep 流程中的关键代码:

可以看到,老年代的 OldPage 主要是通过 sweeper 的 SweepPage() 来进行清理的,SweepPage() 清理完成后会返回一个 bool 值,表示当前的 OldPage 是否还存在对象,如果已经没有对象了,则会调用 Deallocate()对当前 OldPage 所占用的内存进行释放。

接下来,我们看一下 SweepPage() 的主要实现。

代码较长,这里只截出了关键部分,在遍历 OldPage 上的对象时,会先取出对象的 tags 判断是否被标记,如果对象被标记了,则会清除对象的标记位,然后将对象的大小累加到 used_in_bytes 中;如果没有标记, 则会创建一个 free_end 变量来记录可以清理的结束位置,然后通过 while 循环来遍历后续对象,直到遍历到一个已标记的对象,这样做的目的是为了一次性计算出可连续清理的内存,这样的话就可以释放出一个尽可能大的内存空间来分配新的对象,可以看到,最终是通过 free_end - current 来计算出可连续释放的空间,然后将可释放的起始地址与大小记录到 freelist 中,这样后续对象在分配内存时 就可以通过 OldPage 的 freelist 来获取到内存。

至此,Mark-Sweep 的流程就结束了。Mark-Sweep 作为老年代 GC 最常用的算法,也存着一些缺点,例如碎片化问题。为了解决碎片化问题,就引入了 Mark-Compact。接下来,我们就介绍一下老年代的另外一个 GC 算法 Mark-Compact。

Mark-Compact

Mark-Compact 主要分为两个部分,分别是标记和压缩。而标记阶段和上文中介绍的 Mark-Sweep 对象标记是保持一致,所以这里就不再介绍标记阶段,主要看一下压缩阶段的实现。

老年代内存在申请内存分页之后,会在当前内存分页尾部分配一块内存来存放 ForwardingPage 对象。而这个 ForwardingPage 对象中则是存放了多个 ForwardingBlock,ForwardingBlock 中则是存放当前分页存活对象即将转移的新地址。

从上方代码可以看出,整个压缩阶段主要分成两个步骤,一个 PlanPage(),一个是 SlidePage()。这里先介绍 PlanPage(),它的主要作用是计算所有存活对象需要移动的新地址,并将其记录到上文中所提到的 ForwardingPage 的 ForwardingBlock 中。由于跟CopyingGC 不同的是在同一块区域操作,所以可能会出现移动时把存活对象覆盖掉的情况,所以这一步只做存活对象新地址的计算。

可以看到,PlanPage() 并没有直接从 object_start() (分页内存中的第一个对象的地址)进行处理,而是调用了 PlanBlock() 来进行处理,顾名思义,内存分页会划分成多个 Block,然后对 Block 分别处理,并将计算出的新地址记录到 ForwardingBlock 中。接下来我们看一下 PlanBlock() 的具体实现。

可以看到,PlanBlock() 会先通过 first_object 计算得到 Block 中的起始地址,然后通过 kBlockSize 计算的得出 Block 的结束地址,然后通过起始地址遍历 Block 中的所有对象。如果当前遍历到的对象被标记了,则会通过 RecordLive() 记录到 ForwardingBlock 中,而 RecordLive() 内部使用了一些黑魔法,它并没有直接保存对象转移的新地址,而是先计算出对象在 Block 中的偏移量,然后通过这个偏移量对 live_bitvector_ 进行位移计算得到一个 bit 位, 用这个 bit 位来记录该对象是否存活。当 Block 中的所有对象都遍历完成后,通过 set_new_address() 整个 Block 中对象转移的新地址。所以,每个 Block 都只会存储一个新地址,那 Block 中的所有存活对象,怎么根据这个新地址进行移动呢?这就要介绍一下 SlidePage() 中的 SlideBlock(),这里我们就不再关注 SlidePage() 了,因为它的实现和 PlanPage() 差不多,里面循环调用了 SlideBlock(),这里我们直接看一下 SlideBlock() 的实现。

SlideBlock() 代码较长,只截了其中最关键的一部分,可以看到,在遍历 Block 中的对象时,会先通过 forwarding_block 获取到对象的新地址,然后将新地址转化为 UntaggedObject 的对象指针,然后通过 memmove() 将旧地址中的数据移动到新地址,最后通过 VisitPointers() 将对象中的属性所引用的对象指针进行更新。

看到这里,我们对 Compact(内存压缩) 已经有了一个大致的了解,CompactTask 先通过 PlanPage() 遍历老年代分页内存上的所有标记对象,然后计算出他们将要移动的新地址,然后再通过 SlidePage() 再次遍历老年代内存上的对象,将存活的对象移动到新地址,在对象移动的同时去更新对象属性中的对象指针(也就是对象之间的引用关系)。

接下来看一下 VisitPointers() 的实现,看一下对象属性中的对象指针是如何更新的。

可以看到,VisitPointers() 会遍历对象属性中的所有对象指针,然后调用 ForwardPointer() 完成对象指针更新。接下来,我们看一下 ForwardPointer() 的实现。

可以看到,它会先通过 OldPage::of() 找到对象指针所在内存分页,然后获取到它的 forwarding_page,通过 forwarding_page 查询出对象的新地址,然后再用新地址更新对象指针。

PlanPage()SlidePage() 执行结束之后,Compact 流程就接近尾声了,剩下的就是扫尾工作了,其实还是对象引用的更新,SlidePage() 中移动对象同时虽然会更新对象指针,但是这仅仅是处理了老年代内存分页上对象之间的引用,但是像新生代对象,它的对象属性中可能也存在老年代对象的对象指针,它们之间的引用关系还没有被更新。所以,接下来就是更新非老年代对象中的对象指针。

通过注释可以看出,接下来的就主要是对 large_page 与新生代内存中的对象进行对象指针更新。至此,Compact 的流程就基本结束了。

通过以上分析,可以发现,相对于 CopyingGC、Mark-Sweep,Mark-Compact 也存在着优缺点。

优点:

可有效利用堆:比起 CopyingGC 算法,它可利用的堆内存空间更大;同时也不存在内存碎片,所以比起 Mark-Sweep,可利用空间也是更大。

缺点:

压缩过程有计算成本。整个标记压缩流程必须对整个堆进行 3 次遍历,执行该算法花费的时间是和堆大小成正比的,吞吐量要劣于其他算法。

并发标记

在 GC 过程中,会通过“安全点”的方式挂起所有 isolate 线程,isolate 挂起就意味着无法立即响应用户的操作,为了减少 isolate 挂起时间,就引入了并发标记。并发标记会使 GC 线程在标记阶段时,与 isolate 并发执行。因为 GC 标记是一个比较耗时的过程,如果 isolate 线程能够和 GC 标记 同时执行,就不会导致用户界面长时间卡顿,从而提高用户体验。

但是,并发标记并不是在所有场景下都使用的。当内存到达一定阈值,相当吃紧的情况下,还是会采取并行标记的方式,挂起所有 isolate 线程,直到整个 GC 流程结束。

接下来,我们来看一下 StartConcurrentMark() 是如何实现并发标记的。

可以看到,StartConcurrentMark() 会先 通过 ResetSlices() 计算分片个数,新生代对象作为 GC 标记的根对象,为了提高标记效率,多个标记线程会同时遍历新生代对象,所以通过分片的方式可以让多个标记线程能够尽然有序的遍历新生代分页内存上的对象。接下来,就是通过 thread_pool() 来分配多个线程来执行标记任务 ConcurrentMarkTask,num_tasks 就是并发标记的线程个数,之所以减 1,是因为当前主线程也作为标记任务的一员,但是主线程只会调用 IterateRoots() 来遍历根对象,后续 work_list_ 中的对象则是通过 thread_pool() 重新分配一个线程来执行 ConcurrentMarkTask,主线程的标记任务到此就基本结束了,接下来就是通过 root_slices_monitor_ 同步锁,等待所有根对象遍历完成。剩下的都交给了 ConcurrentMarkTask 来完成。接下来,我们就看一下 ConcurrentMarkTask 的实现。

可以看到,ConcurrentMarkTask 在调用 IterateRoots() 完成根对象标记之后,就会调用 DrainMarkingStack() 来遍历 work_list_ 中的对象,而 DrainMarkingStack() 的实现在上文的对象标记中已经介绍过了,这里就不再赘述了。

有了并发标记,GC 标记任务和 isolate 线程就可以并发执行,这样就避免了 GC 标记因挂起 isolate 线程带来的长时间卡顿。

写入屏障

有了并发标记之后,就会引入另外一个问题。因为并发标记允许 isolate 线程与 GC 标记线程同时执行,所以就存在标记过程中,isolate 线程修改了对象引用。也就是说,两个对象被标记线程遍历之后,一个未被标记的对象引用 在 isolate 线程中被赋值给一个已经标记对象的属性,此时,未标记对象被标记对象所引用,此时未标记的对象理论上已经被根对象间接引用,应该 GC 过程中不能被清理,但是因为并发标记阶段没有被标记,所以在最终 Sweep 阶段将会被清理,这明显出现了错误。为了解决这个问题,就引入了写入屏障。

在标记过程中,当未标记的对象(TARGET)被赋值给已标记对象(SOURCE)的属性时,此时 TARGET 对象理应也该被标记,为了防止 TARGET 对象逃逸标记,写入屏障会对未标记的 TARGET 对象进行检查。

如果 TARGET 对象与 SOURCE 对象都是老年代对象时,写入屏障就会对未标记的 TARGET 对象进行标记,并将该对象加入到标记队列,致使该对象关联的其他对象也会被标记。

可以看到,SOURCE对象在保存 TARGET 对象指针建立引用关系时,会判断 TARGET 对象 是否是 heap 上的对象,如果是 heap 上的对象,则会调用 CheckHeapPointerStore() 对其进行检查。接下来,我们看一下 CheckHeapPointerStore() 的具体实现。

CheckHeapPointerStore() 方法中,会判断 TARGET 对象是否是新生代对象,如果是新生代对象,则会调用 EnsureInRememberedSet() 将 SOURCE 对象加入到 RememberedSet 中(主要作用于上文中介绍的 Scavenge,新生代对象转移时能够更新老年代对象中存储的对象指针),但并未对 TARGET 对象进行特殊处理,这是因为新生代对象在老年代 GC 标记过程中本身就作为根对象,而且在标记结束时,会重新遍历这些根对象。接下来,就是非新生代对象,非新生代对象只有两种 Smi 和老年代对象,因为在外层函数 StorePointer() 中有判断是否是 heap 上的对象,所以这里不可能是 Smi,只能是老年对象。老年代对象则调用 TryAcquireMarkBit() 进行标记,标记成功后,将其加入到标记队列中(也就是上文中所提到的 work_list_),使其关联到的对象也被遍历标记。

有了写入屏障,就确保了在并发标记时,isolate 修改 heap 对象之间的引用关系时,不会导致对象遗漏标记被 GC 清理。但是写入屏障也会带来额外的开销,为了减少这种开销,就要介绍到另外一个优化:写入屏障消除。

写入屏障消除

通过上面对写入屏障的介绍,我们可以得知,当 TARGET对象赋值给 SOURCE 对象的属性时,写入屏障主要作用于以下两种情况:

  • SOURCE 对象是老年代对象,而 TARGET 对象是新生代对象,且 SOURCE 对象不在 RememberedSet 中。

      此场景下,会将 SOURCE 对象加入到 RememberedSet 中,作用于新生代 GC Scavenge。

  • SOURCE 对象是老年代对象,TARGET 对象也是老年代且没有被标记,此时 GC 线程正在标记阶段。

      此场景下,会对 TARGET 对象进行标记,并将 TARGET 对象加入到 work_list_ 中。

而在这两种情况下,其实也存在着一些场景无需写入屏障,只要在编译时能够判定出是这些场景,就可以消除这类的写入屏障。我们简单列举一些场景:

  • TARGET 对象是一个常量(因为常量必定是老年代对象,即使在赋值给 SOURCE 对象时没有被标记,也会在 GC 过程中通过常量池被标记)。

  • TARGET 对象是 bool 类型(bool 类型只可能有三种情况:null、false、true,而这三个值都是常量,所以如果是 bool 类型,必定是一个常量)。

  • TARGET 对象是小整形(小整形在上文中也介绍过,指针即对象,所以他不算是 heap 上的对象)。

  • SOURCE 对象和 TARGET 对象是同一个对象(自身属性持有自己)。

  • SOURCE 对象是新生代对象或者是已经被添加至 RememberedSet 的老年代对象(上文中也介绍过,新生代对象作为根对象,在标记结束时,会重新遍历这些根对象)。

我们可以知道,当 SOURCE 对象是通过 Object::Allocate() 进行分配的(而不是从 heap 中加载的),它的 Allocate() 和它最近一次的属性赋值之间如果不存在触发 GC 的 instruction,那它的属性赋值也可以消除写入屏障。这是因为 Object::Allocate() 分配的对象一般情况下是新生代对象,如果是老年代对象,在 Allocate() 时会被直接修改为标记状态, 预先添加至 RememberedSet 和标记队列 work_list_ 中。

container <- AllocateObject<intructions that do not trigger GC>StoreInstanceField(container, value, NoBarrier)

在此基础上, 当 SOURCE 对象的 Allocate() 和它的属性赋值之间不存在函数调用,我们可以进一步来消除属性赋值带来的写入屏障。这是因为在 GC 之后,Thread::RestoreWriteBarrierInvariant() 会将 ExitFrame 下方的栈帧中的所有老年代对象添加至 RememberedSet 和标记队列 work_list_ 中(ExitFrame 是表示函数调用栈退出的特殊帧,当函数执行完毕时,虚拟机会将 ExitFrame 推入栈顶,以表示函数的退出)。

container <- AllocateObject<instructions that cannot directly call Dart functions>StoreInstanceField(container, value, NoBarrier)

可以看到,Thread::RestoreWriteBarrierInvariant() 遍历到 ExitFrame时,会开始扫描下一个栈帧,会通过 RestoreWriteBarrierInvariantVisitor 遍历栈帧中的所有对象,并将其 RememberedSet 和标记队列 work_list_ 中。所以,这个写入屏障消除必须保证 AllocateObjectStoreInstanceField 必须在同一个DartFrame 中,如果它们之间存在函数调用,就无法确保它们在ExitFrame 下方的同一个 DartFrame 中。

可以看到,写入屏障消除通过在编译时和运行时的一些推断,避免了一些不必要的额外开销。

Safepoints

任何可以分配、读写 Heap 的非 GC 线程或任务可以称为 mutator,因为它可以修改对象之间的引用关系。

GC 的某些阶段要求 Heap 不允许被 mutator 使用,我们称之为 safepoint operations。例如:老年代 GC 并发标记时的根对象标记,以及标记结束后的对象清理。

为了执行这些操作,所有 mutator 都需要暂时停止访问 Heap,此时 mutator 就到达了“安全点”。已经达到安全点的 mutator 将不能访问 Heap,直到 safepoint operations 完成。

在 GC 过程中,GcSafepointOperationScope 会致使当前线程等待所有 isolate 线程到达“安全点”之后才能继续执行,这样就保证了后续流程中 isolate 线程不会修改 Heap 上的对象。

NotifyThreadsToGetToSafepointLevel() 会通知所有 isolate 线程当前需要挂起。

WaitUntilThreadsReachedSafepointLevel() 会等待所有 isolate 线程进入安全点。

对应 isolate 在发送 OOB 消息时,会处理当前线程状态中的 interrupt 标记位,如果当前线程状态的 interrupt 标记位满足 kVMInterrupt,则会调用 CheckForSafepoint() 检查当前 isolate 是否被请求进入“安全点”,如果当前 isolate 的 safepoint_state_ 被标记需要进入“安全点”,则会调用 BlockForSafepoint() 标记 safepoint_state_ 已进入“安全点”,并挂起当前线程,直到“安全点操作”结束。

因此,当 isolate 发送 OOB 消息时,就会触发“安全点”检查,从而导致线程挂起进入“安全点”。那什么是 OOB 消息,而 OOB 消息发送又是何时被触发的,这就要简单介绍一下 isolate 的事件驱动模型。正如大部分的 UI 平台,isolate 也是通过消息队列实现的事件驱动模型。不过,在 isolate 中有两个消息队列,一个队列是普通消息队列,另一个队列叫 OOB 消息队列,OOB 是 "out of band" 缩写,翻译为带外消息,OOB 消息用来传送一些控制类消息,例如从当前 isolate 生成(spawn)一个新的 isolate。我们可以在当前 isolate 发送OOB消息给新 isolate,从而控制新 isolate。比如,暂停(pause),恢复(resume),终止(kill)等。

有了“安全点”,就保证了其他线程在 GC 过程中不能随意访问、操作 Heap 上的对象,确保 GC 过程中一些重要操作(根对象遍历、内存清理、内存压缩等等) 不受其他线程影响。

GC问题定位

先看一下 GC 的报错堆栈:

可以看到,问题发生在 GC 过程中的对象遍历标记。起初,猜想会不会是多个 isolate 线程都触发了 GC,多线程 GC 导致的,但是看了 Safepoints 实现之后,发现这种情况不可能存在,于是排除了此猜想。

因为 DartVM 中的老年代内存分页是通过 OldPage 进行管理的,在这些 OldPage 中,除了 code pages,其他 OldPage 都是可读可写的。

而 DartVM 也提供了相应的 API 来修改 OldPage 的权限。

  • PageSpace::WriteProtectCode()

  • PageSpace::WriteProtect()

在 GC 标记前,会通过 PageSpace::WriteProtectCode() 将“老年代” 中的  code pages 权限修改为可读可写,以便在标记过程中对 Instructions 对象进行标记,在 GC 结束后,再通过 PageSpace::WriteProtectCode() 将  code pages 的权限修改为只读。

因为 code pages 是用来动态分配的可执行内存页,用来生成 JIT 的机器指令,所以 code pages 只读权限导致的 SEGV_ACCERR 问题,只会在 Debug 包上才能复现,所以 release 包不会存在此问题。

而 PageSpace::WriteProtect() 也可以修改 OldPage 对应分页的读写权限,该方法可以将“老年代”上的所有 OldPage 修改为只读权限。目前通过搜索代码,发现只有一个调用时机,就是 ioslate 退出时,清理 ioslate 时会通过 WritableVMIsolateScope 对象的析构会将 "老年代" 上的 所有 OldPage 改为只读。OldPage 修改为只读之后,再对 OldPage 上的对象进行标记时就会出现问题。通过模拟 WritableVMIsolateScope 对象的析构,也复现了和线上完全一模一样的 crash 堆栈。但是 isolate 正常情况下是不会退出的,所以在前期排除了这种可能。

后来,还是把猜测转向了写入屏障消除,会不是写入屏障消除导致了对象逃逸了 GC 标记,致使所在 OldPage 被清理释放,再次触发 GC,遍历到此对象指针时,对象所在的内存已经被释放,野指针导致的 SEGV_ACCERR 问题。如果是这种情况的话,想到了一个临时的解决方案,在 GC 标记过程中,对 ObjectPtr 所指向地址做校验,判断是否是一个合法地址。因为标记访问的对象对存储在 OldPage 上,所以我们只判断一下该地址在不在 当前"老年代"的 OldPage 的内存区域内,如果地址在 OldPage 内存区域内,说明 ObjectPtr 所指向的对象所在 OldPage 还存在,没有被释放,此块内存区域肯定是可以访问的。

修复代码

通过 PageSpace::ContainsUnsafe(uword addr) 方法,来判断对象地址是否在 "老年代" 分页内存上,这个方法本来是轻量级的,但是 GC 过程中,需要标记大量对象,每次标记都要进行这个判断,导致此方法的总开销较大,整个 GC 时间被拉长。实测下来,每次 GC,都会导致界面 3~5s 的卡顿。所以,此方法还需要优化。在上文中,也介绍过并发标记,理论上 GC 标记 和 isolate 是并发执行的,不会影响到用户交互。但是,GC 标记并不是整个流程都和 isolate 并发执行的,上文中也提到过 GcSafepointOperationScope,在 GC 标记之前,会通过 GcSafepointOperationScope 挂起除当前线程的所有 isolate 线程,直到当前 GC 方法执行结束,如果并发标记阶段,则是标记方法执行结束,上文中也提到过,GC 标记的主线程会等待所有根对象标记结束,所以根对象标记结束后,才会进入真正的并发标记阶段。因为大部分问题都是发生在 work_list_ 中的对象标记,我们是不是可以直接忽略根对象的标记,在根对象标记之后,才开启对象指针校验(这样就只能保证 work_list_ 中的对象标记,根对象标记还是存在问题,但是至少能减少问题出现的频次)。

于是通过 GCMarker 中 root_slices_finished_ 变量来判断根对象是否标记结束,结束之后,才开启对象指针校验。修改之后,确实不存在卡顿了,于是就开启了上线灰度。

但是上线后,并不是很理想,GC 问题还是存在。既然猜测是写入屏障消除导致的,干脆就大胆一点,直接把写入屏障消除这一优化给移除掉。写入屏障消除这一优化移除灰度上线之后,发现 GC 问题还是存在。此时,思绪万千,难道真的是 PageSpace::WriteProtect() 导致的,为了验证这一猜测,于是就在对象标记之前加入了 RELEASE_ASSERT,判断老年代分页内存是否真的被修改为了只读权限。

上线之后,果不其然,GC 问题的堆栈信息发生了改变,错误正是新加入的断言。这就说明,老年代分页内存确实被修改为了只读权限,此时去修改对象的标记位肯定是有问题。

当我们准备更近一步时,却因为高频 GC 问题的几台设备不再使用,失去了可用于灰度的设备,导致无法进一步去验证问题。也因为这几台高频 GC 问题设备的下线,GC 问题在 crash 占比中显得不那么重要,问题就这样淡出了我们的视线。不过还是希望后续能够找到根因,彻底解决此问题。

总结&感悟

通过对 DartVM GC 整个流程的梳理,才真正理解了什么是分代 GC,新生代和老年代在 GC 上是相互隔离的,使用着不同的 GC 算法,而老年代自身也存在两种 GC 算法 Mark-Sweep 和 Mark-Compact。通过对 GC 问题的定位,也让我们更加意识到日志的重要性,在不能复现问题的前提下,日志才是排查问题的重要线索。DartVM GC 流程中的埋点日志不仅能帮助我们来排查问题,也能反映出 Dart 代码中是否存在内存泄漏问题,例如对 GC 过程中 heap 的使用情况进行日志输出。后续,也希望能够将 GC 的日志进行持久化,便于回捞,更好地分析应用的内存使用情况和 GC 频率,为今后的应用性能优化提供思路和方向。


活动推荐:龙年红包封面即将在2月7日上线哦~

参考文献:

https://mrale.ph/dartvm/gc.html



往期回顾


1. 互动游戏团队如何将性能体验优化做到TOP级别|得物技术
2. 得物自动化平台执行器设计与实现
3. SpEL应用实战|得物技术
4. 得物自动化平台执行器设计与实现
5. 得物大模型平台接入最佳实践
6. R8疑难杂症分析实战 - 类反射篇|得物技术



*文/浮生若梦

关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:

继续滑动看下一个

DartVM GC 深度剖析|得物技术

浮生若梦 得物技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存