一文带你深入理解flutter垃圾回收机制
垃圾回收
Dart VM拥有两代的分代垃圾回收器。新一代通过并行的、停止世界的半空间清理器进行收集。老一代通过并发-标记-并发-清扫或并发标记-并行-压缩进行收集。
对象表示
对象指针指向立即对象或堆对象,通过指针低位的标签区分。Dart VM只有一种立即对象,即 Smis(小整数),其指针的标签为0。堆对象的指针标签为1。Smi指针的高位是其值,堆对象指针的高位是其地址的最高有效位(最低有效位总是0,因为堆对象总是大于2字节对齐)。
标签0允许对 Smis 进行许多操作而无需去标签和重新标签。
标签1对堆对象访问没有影响,因为去除标签可以折叠到加载和存储指令的偏移中。
堆对象总是以双字增量分配。旧空间中的对象保持双字对齐(地址 % 双字 == 0),新空间中的对象保持偏离双字对齐(地址 % 双字 == 字)。这允许在不比较边界地址的情况下检查对象的年龄,避免了对堆位置的限制,并避免了从线程局部存储加载边界。此外,清理器可以通过单个分支快速跳过即时和旧对象。
指针 | 引用 |
---|---|
0x00000002 | 小整数1 |
0xFFFFFFFE | 小整数-1 |
0x00A00001 | 位于0x00A00000的堆对象,在老空间 |
0x00B00005 | 位于0x00B00004的堆对象,在新空间 |
堆对象有一个单字头部,编码了对象的类、大小和一些状态标志。
在64位架构上,堆对象的头部还包含一个32位身份哈希字段。在32位架构上,堆对象的身份哈希存储在单独的哈希表中。
句柄
Dart VM的GC是精确的和可移动的。
如果垃圾回收发生时精确知道哪些是堆中的指针,哪些不是,那么GC被称为“精确的”。 例如,在编译的 Dart 代码中,VM 跟踪哪些堆栈槽包含对象指针,哪些包含未装箱的值(对象堆内存)。这与“保守”的收集器相反,后者认为任何指针大小的值可能是堆中的指针,尽管它可能只是一个未装箱的值。
在“可移动”的GC中,对象的地址可能会改变,需要更新指向该对象的指针。在 Dart VM中,对象可以在清理或压缩期间移动。可移动的GC必须是精确的GC:如果保守GC更新了一个不能保证是指针的值,当该值实际上不是指针时,它会破坏执行。
VM不知道哪些堆栈槽、全局变量或外部语言中的对象字段包含指向 Dart 堆的指针,包括VM 自己用 C++ 实现的运行时。为了保持GC的精确性,外部语言通过“句柄”间接引用 Dart 对象。句柄可以被认为是指向指针的指针。 它们从 VM 分配,并在收集期间被GC访问(可能更新)句柄中包含的指针。
句柄在新生代的作用:
-
对象复制:
- 新生代对象使用半空间清理器进行垃圾回收,通过复制算法将存活对象从一个半空间移动到另一个半空间。
- 在对象移动时,句柄可以确保引用关系的更新,使得原引用仍然指向新的对象位置。
-
高效管理:
- 句柄表提供了一个集中管理对象引用的方式,通过更新句柄表可以快速跟踪对象之间的引用关系,避免引用丢失。
句柄在老生代的作用:
-
压缩:
- 老生代对象使用并发标记-并行-压缩(Mark-Compact)进行垃圾回收,在压缩阶段,存活对象会被移动到堆的一端。
- 在压缩过程中,句柄可以确保引用关系的更新,使得原引用指向对象的新位置。
-
持久管理:
- 老生代中的对象通常生命周期较长,句柄提供了一种持久管理对象引用的机制,可以随着对象的移动或清理持续更新引用关系。
对象移动时,可以改变句柄,为什么不直接改变指针?
-
直接在应用代码中使用对象指针意味着指针分散在整个程序和堆栈中。这些指针的存在可能在任何地方,包括局部变量、全局变量、数据结构中等。如果对象移动,运行时系统需要找到所有指向该对象的指针并更新它们,这在��术上是非常具有挑战性的,也极易出错。
-
使用句柄系统,所有指向对象的引用都通过句柄间接进行。这样,GC 只需要更新句柄中存储的指针。句柄的管理是集中的,通常存储在一个特定的表或区域中,这使得更新操作更简单、更安全。
安全点
任何可以分配、读取或写入堆的非GC线程或任务被称为“变异器”(因为它可以变异对象图)。
一些GC阶段要求堆不被变异器使用;我们称这些操作为“安全点操作”。安全点操作的例子包括在并发标记开始时标记根,以及整个清理过程。
要执行这些操作,所有变异器需要暂时停止访问堆;我们说这些变异器已经达到了“安全点”。达到安全点的变异器不会恢复访问堆(离开安全点)直到安全点操作完成。除了不访问堆外,处于安全点的变异器必须不持有任何指向堆的指针,除非这些指针可以被GC访问。对于 VM 运行时中的代码,这最后一条性质意味着只持有句柄,不持有 ObjectPtr 或 UntaggedObject。可能进入安全点的地方包括分配、栈溢出检查,以及在编译代码和运行时以及本地代码之间的转换。
请注意,变异器可以在没有被挂起的情况下处于安全点。它可能正在执行不访问堆的长任务。然而,它需要等待任何安全点操作完成,以便离开其安全点并恢复访问堆。
因为安全点操作排除了 Dart 代码的执行,所以它有时用于只需要这个属性的非GC任务。例如,当后台编译完成并想要安装其结果时,它使用安全点操作来确保没有 Dart 执行看到安装过程中的中间状态。
为什么进入安全点的地方是分配、栈溢出检查,以及在编译代码和运行时以及本地代码之间的转换?
在垃圾回收(GC)中设置安全点的目的是确保在执行GC关键操作时能够同步所有运行线程,防止它们修改堆内存。这些安全点通常设置在程序执行的关键交互点,这些点被选中是因为它们是线程与堆进行交互的重要时刻,或者是可能引起堆状态改变的时刻。
- 分配(Allocation)
- 当代码尝试在堆上分配新对象时,它是改变堆状态的直接行为。在对象分配过程中设置安全点可以确保在进行此类操作时,GC可以安全地暂停应用线程,进行必要的堆扫描或压缩,从而防止在GC计算和对象实际分配之间出现状态不一致。
- 栈溢出检查(Stack Overflow Checks)
- 栈溢出检查是程序运行中常规的安全检查,用来确定是否有足够的栈空间来执行下一操作。如果栈空间不足,可能需要进行扩展或异常处理。在栈溢出检查点设置安全点允许GC在这一刻暂停程序执行,确保在执行栈调整或处理递归调用深度问题时不会干扰堆的整合性。
- 编译代码和运行时以及本地代码之间的转换
- 在编译的代码、运行时环境及本地代码(例如C或C++编写的库)之间的转换是程序执行中的复杂交互点。这些转换点可能涉及调用堆管理逻辑、内存分配或释放等操作,同时也可能涉及从托管环境到非托管环境的资源管理转换。
- 在这些转换点设置安全点,可以在进行跨语言或跨环境的调用前同步GC状态,确保所有内存操作都在GC的控制和监视下进行,避免因环境切换导致的资源管理错误。
综合理由
- 响应GC需求:安全点允许GC系统在需要时迅速响应,如启动完整的垃圾回收周期或执行堆的压缩。这些操作要求在执行时没有其他线程修改堆。
- 维持程序的健壮性:通过在这些关键点设置安全点,GC系统可以在不影响程序整体性能的前提下,维护堆的健康状态和应用的稳定性。
新生代
新一代通过并行的、停止世界的半空间清理器进行收集是 Dart VM 中的一种垃圾回收机制,用于管理新生代对象。它的主要特点和运作机制如下:
新生代通过并行的、停止世界的半空间[清理器]:
-
新生代堆的划分: 新生代堆被划分为两个半空间:
from-space
和to-space
。新创建的对象通常分配到from-space
。 -
停止世界: 当
from-space
填满或达到某个阈值时,垃圾回收器会触发停止世界操作,即暂停程序的正常执行,进行垃圾回收。 -
并行处理: 在停止世界的状态下,半空间清理器会并行运行,即多个线程同时执行垃圾回收操作,以提高垃圾回收速度。
-
复制算法: 清理器通过遍历
from-space
,将其中存活的对象复制到to-space
,未被引用的对象则被清理。完成后,to-space
中的对象成为新的新生代堆。 -
空间切换: 清理完成后,
to-space
成为新的from-space
,而之前的from-space
则成为新的to-space
。这种切换可以重复进行,使得垃圾回收机制持续运作。
优点:
-
提高效率: 并行的清理器能够在停止世界的情况下快速回收新生代对象,减少程序暂停时间。
-
内存利用优化: 半空间清理器通过复制算法避免内存碎片化,使得新生代堆的内存利用更高效。
-
持续管理: 通过半空间的切换机制,新生代堆可以持续为新对象提供空间,并定期回收短生命周期对象。
并行清理
FLAG_scavenger_tasks(默认值为2)工作器在单独的线程上启动。每个工作器竞争处理根集的部分(包括记忆集)。当工作器将一个对象复制到 to-space 时,它从工作器本地的碰撞分配(顺序分配)区域分配。同一个工作器将处理复制的对象。当工作器将一个对象提升到老空间时,它从工作器本地的空闲列表分配,该空闲列表对大型空闲块使用碰撞分配。提升的对象被添加到实现工作窃取的工作列表中,因此其他工作器可能处理提升的对象。对象撤离后,工作器使用比较和交换来安装从空间对象头部的转发指针。如果它输掉了比赛,它会取消分配刚刚分配的 to-space 或老空间对象,并使用赢家的对象更新它正在处理的指针。工作器运行直到所有工作集被处理完毕,每个工作器都处理了其 to-space 对象和其本地的提升工作列表部分。
在描述的并行清理过程中,涉及到的是新生代垃圾回收的一个特定实现,通常称为半空间清理或Scavenge。这个过程通过并行化提高垃圾回收的效率,减少应用暂停时间。以下是这个过程的概括总结,旨在使其更通俗易懂:
并行清理基本流程
-
工作器启动与分配:
- 系统配置有默认两个工作线程(也称为工作器),这些工作器在单独的线程上启动并执行清理任务。
- 每个工作器负责处理一部分根集和记忆集。根集包含所有从全局变量、活跃线程的栈等地方引用的对象,而记忆集则包含老生代对象对新生代对象的引用。
-
对象的处理:
- 当工作器发现需要清理的对象时,它会将这些对象从当前空间(from-space)复制到目标空间(to-space)。这一过程使用的是本地碰撞分配,即从一个连续的内存块中分配空间,以优化内存分配速度。
- 如果某个对象因为年龄或大小需要被提升到老空间,工作器则从本地的空闲列表中为这些对象分配空间。这些空闲列表专门处理大型空闲块,使得内存分配效率更高。
-
工作窃取机制:
- 提升的对象被放入一个可以进行工作窃取的工作列表中,这意味着如果某个工作器完成了自己的任务,它可以从其他工作器的列表中"窃取"任务继续执行,从而保持所有工作器的高效运行。
-
对象撤离与转发指针:
-
对象撤离(Evacuation)通常指在垃圾回收过程中将对象从其原始位置移动到新位置的行为。这种情况发生在多种情况下,例如:
- 新生代到老生代的提升:在分代垃圾回收中,经常存活的对象从新生代(经常进行GC的区域)被移动到老生代(较少进行GC的区域)。
- 内存压缩:在堆内存压缩时,为了减少内存碎片,存活的对象被移动到堆的一端。
-
转发指针(Forwarding Pointer)
- 当对象被移动到新位置时,为了保持对该对象的所有现有引用的有效性,需要一种机制来记录对象的新地址。这就是转发指针的作用。转发指针被设置在对象的原始内存位置,指向对象在堆上的新位置。这样,任何后续试图访问原始位置的操作都可以通过转发指针被重定向到正确的位置。
- 工作完成:
- 工作器将继续执行,直到所有的对象都被处理完毕,包括那些被复制到to-space和被提升到老空间的对象。
这个并行清理过程通过多个工作器并行执行任务、使用碰撞分配提高效率以及通过工作窃取机制保持工作器不会空闲,共同优化了新生代的垃圾回收效率。这种方法有效减少了垃圾回收对应用运行的影响,使得应用可以在GC发生时继续高效运行。
记忆集
记忆集(remembered set)是在垃圾回收中用于辅助标记阶段的数据结构。在垃圾回收器执行标记-清除或标记-压缩等算法时,需要遍历堆中的对象,并标记哪些对象是可达的,哪些是不可达的。记忆集的作用是帮助标记阶段快速定位可能指向新生代对象的旧生代对象。
具体来说,记忆集通常用于新生代的部分垃圾回收算法,如部分压缩(partial compaction)或增量标记(incremental marking)。在这些算法中,为了避免遍历整个堆,标记阶段只需要遍历根集以及可能包含指向新生代对象的旧生代对象。记忆集记录了这些��能包含指针的旧生代对象的信息,以便在标记阶段快速定位并标记它们。
记忆集通常以某种形式的数据结构(如哈希表、位图等)来实现,它记录了旧生代对象中哪些字段可能包含指向新生代对象的指针。在标记阶段,垃圾回收器会首先遍历根集,然后根据记忆集的信息,快速定位并标记可能指向新生代对象的旧生代对象。
总的来说,记忆集是垃圾回收中的一个重要辅助数据结构,它能够帮助提高标记阶段的效率,减少不必要的遍历和标记,从而提升整体的垃圾回收性能。
老生代
并发标记
整体流程
并发标记是垃圾回收(GC)过程中一种关键的技术,用于减少应用暂停时间,特别是在需要处理大量数据和复杂对象图的场景中。在并发标记过程中,垃圾回收器的标记阶段与应用程序代码(变异器)同时运行。这样可以大幅度降低GC引起的停顿时间,但同时也带来了一系列的挑战和知识点需要理解:
-
数据竞争与一致性
- 数据竞争:由于标记器和变异器同时运行,变异器对内存的写入可能会与标记器的读取操作发生竞争,从而导致数据的不一致性。
- 写入障碍(Write Barrier):为解决数据竞争问题,引入写入障碍来确保在变异器写入引用时,相应的对象可以正确标记,防止被错误地回收。
-
写入障碍的具体类型
- 增量标记障碍:在对象引用发生变化时,如果引用的目标对象未被标记,此障碍确保目标对象被标记。
- 代际障碍:用于处理跨代引用的问题,确保引用到新生代对象的老生代对象被正确标记。
-
标记根对象
- 在并发标记开始时,必须先标记所有根对象,这包括全局变量、活跃线程的堆栈中的引用等。
-
安全点(Safe Points)
- 安全点设置:并发标记需要在某些特定的点,如函数调用或循环迭代点,设置安全点,变异器在这些点上同步其状态,确保内存的一致性。
- 作用:安全点确保在执行关键GC操作时,所有线程都到达一致的状态,不会操作堆内存。
-
黑色、灰色和白色对象
- 在标记过程中,对象会被分成三种颜色:
- 白色:未被访问的对象。
- 灰色:已被访问但其引用的对象尚未完全访问的对象。
- 黑色:已被访问,且所有引用的对象也已被访问的对象。
- 标记完成后的处理
- 在标记阶段结束后,GC将处理所有从根可达的对象,清理所有未标记的对象,此阶段可能会短暂停止世界(STW)以完成清理。
写入障碍
由于变异器和标记器同时运行,变异器可能将一个未标记的对象(TARGET)的指针写入已经标记并访问过的对象(SOURCE)中,导致TARGET被错误地回收。为了防止这种情况,写入障碍检查存储是否创建了从老空间对象到未标记的老空间对象的指针,并且对于这种存储标记目标对象。 我们忽略从新空间对象的指针,因为我们将新空间对象视为根,并将在标记结束时重新访问它们以完成标记。我们忽略源对象的标记状态,以避免确保对头部和插槽的访问重新排序不能导致跳过标记的昂贵内存屏障,并假设在标记期间访问的对象可能在标记结束时仍然活跃。
对象引用关系示例描述:
以下是一个关于并发标记阶段中 TARGET 和 SOURCE 引用关系变动的示例,以及写入障碍机制如何处理这种情况:
-
对象创建:
- 程序启动时,创建了两个老生代对象
source_obj
和target_obj
。 source_obj
被标记器标记为存活对象,并在标记阶段中被访问过。target_obj
尚未被标记。
- 程序启动时,创建了两个老生代对象
-
引用关系变动:
- 在并发标记阶段中,程序(变异器)修改了
source_obj
的引用关系,将其内部某个属性指向了target_obj
。 - 这种情况下,
target_obj
尚未被标记器标记为存活对象。
- 在并发标记阶段中,程序(变异器)修改了
-
可能的问题:
- 由于标记器和变异器并发运行,标记表未能及时更新引用关系,这可能导致
target_obj
在后续垃圾回收阶段中被错误回收。
- 由于标记器和变异器并发运行,标记表未能及时更新引用关系,这可能导致
-
写入障碍的处理:
- 为防止这种情况发生,写入障碍检查了
source_obj
和target_obj
之间的引用关系,发现存在从source_obj
到target_obj
的指针。 - 写入障碍机制随后将
target_obj
标记为存活对象,确保其不会在后续垃圾回收阶段中被错误回收。
- 为防止这种情况发生,写入障碍检查了
-
后续过程:
- 标记阶段结束后,垃圾回收器根据标记表清理未被标记的对象,并保留了
source_obj
和target_obj
,使得引用关系保持完整。
- 标记阶段结束后,垃圾回收器根据标记表清理未被标记的对象,并保留了
障碍等同于
StorePoint(RawObject* source, RawObject** slot, RawObject* target) {
*slot = target;
if (target->IsSmi()) return;
if (source->
IsOldObject() && !source->IsRemembered() && target->IsNewObject()) {
source->SetRemembered();
AddToRememberedSet(source);
} else if (source->IsOldObject() && target->IsOldObject() && !target->IsMarked() && Thread::Current()->IsMarking()) {
if (target->TryAcquireMarkBit()) {
AddToMarkList(target);
}
}
}
但我们将代际和增量检查结合了一个移位和掩码。
enum HeaderBits {
...
kOldAndNotMarkedBit, // Incremental barrier target.
kNewBit, // Generational barrier target.
kOldBit, // Incremental barrier source.
kOldAndNotRememberedBit, // Generational barrier source.
...
};
static const intptr_t kGenerationalBarrierMask = 1 << kNewBit;
static const intptr_t kIncrementalBarrierMask = 1 << kOldAndNotMarkedBit;
static const intptr_t kBarrierOverlapShift = 2;
COMPILE_ASSERT(kOldAndNotMarkedBit + kBarrierOverlapShift == kOldBit);
COMPILE_ASSERT(kNewBit + kBarrierOverlapShift == kOldAndNotRememberedBit);
StorePointer(RawObject* source, RawObject** slot, RawObject* target) {
*slot = target;
if (target->IsSmi()) return;
if ((source->header() >> kBarrierOverlapShift) &&
(target->header()) &&
Thread::Current()->barrier_mask()) {
if (target->IsNewObject()) {
source->SetRemembered();
AddToRememberedSet(source);
} else {
if (target->TryAcquireMarkBit()) {
AddToMarkList(target);
}
}
}
}
StoreIntoObject(object, value, offset)
str value, object#offset
tbnz value, kSmiTagShift, done
lbu tmp, value#headerOffset
lbu tmp2, object#headerOffset
and tmp, tmp2 LSR kBarrierOverlapShift
tst tmp, BARRIER_MASK
bz done
mov tmp2, value
lw tmp, THR#writeBarrierEntryPointOffset
blr tmp
done:
数据竞争
头部和插槽的操作使用宽松排序,不提供同步。
并发标记以获取-释放操作开始,因此在标记开始之前的变异器的所有写入对标记器都是可见的。
对于在标记开始之前创建的老空间对象,在每个插槽中标记器可以看到标记开始时的值或在插槽中排序的任何后续值。任何包含指针的插槽将继续包含对象生命周期内的有效指针,因此无论标记器看到哪个值,它都不会将非指针解释为指针。(这里有趣的情况是数组截断,其中数组中的某些插槽将成为填充对象的头部。我们确保这对并发标记是安全的,通过确保填充对象的头部看起来像一个 Smi。)如果标记器看到一个旧值,我们可能会失去一些精确度并保留一个死对象,但我们仍然正确,因为新值已经由变异器标记。
对于在标记开始后创建的老空间对象,标记器可能会看到未初始化的值,因为插槽上的操作未同步。为了防止这种情况,在标记期间我们分配老空间对象为黑色(已标记),这样标记器就不会访问它们。
新空间对象和根只在安全点期间被访问,安全点建立同步。
当变异器的标记块变满时,它通过获取-释放操作转移到标记器,因此标记器将看到块中的存储。
知识点
在讨论并发标记阶段中的数据竞争和内存操作时,涉及到的主要知识点包括:
- 并发标记和变异器的同步
-
获取-释放操作:在并发标记开始时,使用获取-释放模型确保变异器(即修改堆内存的代码,如应用代码)在标记开始前的所有写入对标记器(即垃圾回收器的一部分,负责标记可达对象)都是可见的。这种同步机制是为了保证标记器在标记过程中可以看到一个一致和正确的堆状态。
- 在标记开始时,通过释放操作确保所有变异器(即可能改变对象引用的代码路径)的状态对标记器可见。
- 标记器通过获取操作开始其过程,确保看到所有至关重要的内存状态是最新的,从而正确地识别所有可达的对象。
- 内存操作的宽松排序
- 宽松排序:在对对象的头部和插槽(字段)进行操作时使用宽松的内存排序,这意味着这些操作在多线程环境中不保证同步,可能会引起竞争条件。这通常用于提高性能,但必须通过其他机制来保证数据的一致性和正确性。
- 对象生命周期内有效的指针
- 指针的有效性:确保任何包含指针的插槽在对象生命周期内始终包含有效的指针。这防止了标记器错误地将有效对象的引用视为垃圾。
- 安全点
- 安全点访问:新空间对象和根对象(如全局变量、活动线程的堆栈引用)只在安全点期间被访问,这是系统中预定义的同步点,所有线程在这些点上同步它们对共享资源的访问。
- 标记堆栈的管理
- 标记块的转移:当变异器的标记块(用于存储即将标记为可达的对象的临时区域)变满时,通过获取-释放操作将其内容转移到标记器。这确保了标记器能够看到变异器可能添加到标记块中的所有最新修改。
- 对象的黑色标记
- 黑色标记:为了防止标记器在标记过程中看到未初始化的值,新创建并在标记开始后分配的老空间对象立即标记为黑色(已标记),这样标记器就不会访问它们。
- 数组截断和内存填充
- 数组截断:处理数组截断的情况,其中数组的部分可能会变成填充对象的头部。保证这些头部看起来像一个小整数(Smi),确保并发标记过程中的安全性。
- 头部和插槽的操作
- 头部:包含GC标记位,在GC过程中,通过操作头部的标记位来标记对象是否为可达状态,决定是否需要回收。
- 插槽(Slot)
- 数据存储和访问:插槽存储对象的实际数据,如整数、引用其他对象的指针等。操作插槽即是更新或读取这些数据。
- 维护引用关系:对于引用类型的数据,插槽操作包括更新和维护对象间的引用关系,这对于垃圾回收器识别和处理可达对象是必需的。
- GC写入障碍:在引用字段发生变化时,通过写入障碍来维护GC的标记和跟踪系统,确保内存的一致性和正确的回收决策。
写入障碍消除
每当有存储进入堆时,container.slot = value
,我们需要检查存储是否创建了GC需要知道的引用。
代际写入障碍,清理器需要的,检查是否container
是老的并且不在记忆集中,并且value
是新的,当这种情况发生时,我们必须将 container
插入到记忆集中。
增量标记写入障碍,标记器需要的,检查是否container
是老的,并且value
是老的并且未标记,并且正在进行标记,当这种情况发生时,我们必须将 value
插入到标记工作列表中。
当编译器可以证明这些情况不会发生,或者由运行时补偿时,我们可以消除这些检查。编译器可以证明这一点当
value
是一个常量。常量总是老的,并且即使我们通过container
失败标记它们,它们也会通过常量池标记。value
的静态类型是 bool。bool 类型的所有可能值(null、false、true)都是常量。- 已知
value
是一个 Smi。Smi 不是堆对象。 container
是和value
相同的对象。如果GC看到自引用,GC永远不需要保留额外的对象,所以忽略自引用不会导致我们释放一个可达对象。- 已知
container
是新对象或已知是老对象并且在记忆集中且如果标记正在进行则已标记。
如果 container
是分配的结果(而不是堆加载),并且在分配和存储之间没有可以触发GC的指令,我们可以知道 container
满足最后一个属性。这是因为分配存根确保 AllocateObject 的结果要么是新空间对象(常见情况,碰撞指针分配成功),要么已被预先添加到记忆集和标记工作列表中(不常见情况,进入运行时分配对象,可能触发GC)。
container <- AllocateObject
<intructions that do not trigger GC>
StoreInstanceField(container, value, NoBarrier)
当 container
是分配结果,并且在分配和存储之间没有能够创建额外 Dart 帧的指令时,我们可以进一步消除屏障。这是因为在垃圾回收后,任何退出帧下方的旧空间对象都会被预先添加到记忆集和标记工作列表中(Thread::RestoreWriteBarrierInvariant)。
container <- AllocateObject
<instructions that cannot directly call Dart functions>
StoreInstanceField(container, value, NoBarrier)
知识点
写入障碍是一种机制,用于确保跨��引用和标记的正确性,从而维持堆的正确性。这里的优化目标是减少不必要的写入障碍操作,以提高程序的性能。以下是关键知识点的详细解释:
-
代际写入障碍 (Generational Write Barrier)
- 应用场景:当一个老生代对象(container)引用一个新生代对象(value)时,需要通过代际写入障碍确保引用关系被正确地追踪。这是因为新生代对象更频繁地被回收,如果老生代对象只引用新生代对象而未被追踪,可能会导致垃圾回收器错误地回收这些新生代对象。
- 操作:将老生代对象加入到一个所谓的“记忆集”中,这样垃圾回收器在处理新生代对象时可以知道哪些老生代对象持有对它们的引用。
-
增量标记写入障碍 (Incremental Marking Write Barrier)
- 应用场景:在增量标记过程中,如果一个老生代对象被标记,并且它指向一个未标记的另一个老生代对象,需要确保这个引用被追踪。
- 操作:确保引用的目标对象被加入到标记工作列表中,以便进一步处理。
-
写入障碍的消除
- 条件:在某些情况下,写入障碍可以被消除,这依赖于编译器的优化或运行时的某些保证。
- 常量引用:如果value是常量(如布尔值、小整数等),则不需要写入障碍,因为这些值通常是预先定义的且始终存在。
- 对象自引用:如果container和value是同一个对象,自引用不会引入新的引用关系,因此不需要额外的障碍。
- 新对象分配:新分配的对象(container)在分配后立即被视为可达,因此在一些情况下,对其进行的初始字段设置可能不需要写入障碍。
标记-清扫
所有对象在其头部都有一个称为标记位的位。在收集周期开始时,所有对象的这个位都是清除的。
在标记阶段,收集器访问每个根指针。如果目标对象是老空间对象且其标记位未设置,标记位将被设置并将目标添加到标记堆栈(灰色集)。然后,收集器移除并访问标记堆栈中的对象,标记更多老空间对象并将它们添加到标记堆栈中,直到标记堆栈为空。此时,所有可达对象的标记位都被设置,所有不可达对象的标记位都被清除。
在清扫阶段,收集器访问每个老空间对象。如果标记位未设置,对象的内存被添加到空闲列表中,以用于未来的分配。否则,对象的标记位被清除。如果某个页面上的每个对象都是不可达的,该页面将被释放给操作系统。
新空间作为根
我们不标记新空间对象,忽略指向新空间对象的指针;而是将新空间中的所有对象视为根集的一部分。
这样做的好处是使两个空间的收集更加独立。特别是,并发标记永远不需要解引用新空间中的任何内存,避免了几个数据竞争问题,并避免了在开始清理时需要暂停或以其他方式与并发标记同步。
它的缺点是没有单一的收集能收集所有垃圾。一个不可达的老空间对象如果被一个不可达的新空间对象引用,将不会被收集,直到一个清理首先收集了新空间对象,并且具有跨代循环的不可达对象将不会被收集,直到整个子图被提升到老空间。增长政策必须小心确保它不进行老空间收集而不交错新空间收集(指的是避免只对老空间进行垃圾回收,而忽略新空间的垃圾回收),例如当程序主要执行直接进入老空间的大型分配时,或者老空间可以积累这种浮动垃圾并无限制地增长。
可能存在的内存泄漏示例:
- 浮动垃圾示例:假设新空间中有对象 A,其引用了老空间中的对象 B。如果 A 和 B 都变得不可达,且 GC 仅对老空间进行收集,B 将成为浮动垃圾,直到新空间收集时 A 被回收。
- 代际循环示例:新空间对象 C 引用老空间对象 D,而 D 又引用 C,形成代际循环。如果 GC 只收集老空间,那么 D 将无法被收集,直到新空间收集时 C 被回收。
- 内存增长示例:一个应用主要进行大量直接进入老空间的分配,导致老空间不断增长。如果 GC 不进行交错的新空间收集,老空间将积累浮动垃圾并持续增长,最终导致内存占用过高。
标记-压缩
Dart VM 包括一个滑动压缩器。转发表通过将堆分成块并记录每个块的目标地址和每个存活双字的位向量来紧凑地表示。通过保持堆页面对齐,可以通过掩码对象访问任何对象的页面头部,从而以常数时间访问表。
标记-压缩算法是一种广泛使用的内存回收技术,特别适用于需要最小化内存碎片的环境中。在 Dart VM 中,这种算法通过一个称为滑动压缩器的机制实现。以下是对 Dart VM 中标记-压缩算法的操作方式的详细解释:
滑动压缩器的工作原理
-
标记阶段:
- 在垃圾回收过程的标记阶段,所有存活的对象被识别并标记。这通常通过从根集出发,递归访问所有可达的对象来完成。
-
压缩阶段:
- 压缩阶段开始时,Dart VM 的滑动压缩器将堆内存划分为多个块。每个块的大小和界限都被定义,以便有效地管理内存移动。
-
转发表的使用:
- 转发表是压缩过程中的关键数据结构。它记录了每个块的目标地址和一个位向量,后者标记了块中哪些双字(64位或双32位数据单元)是存活的。
- 通过转发表,当对象需要被移动到新的内存位置时,系统可以快速查找目标地址,并将对象从当前位置移动到新位置。
-
对象访问和页面对齐:
- Dart VM 保持堆页面对齐,这意味着堆的每一页都按照一定的内存界限对齐,从而优化内存访问速度。
- 对象访问通过掩码操作进行优化。掩码操作使得从任何指向对象的指针中,可以直接访问到对象所在页面的头部信息。这种方法提高了内存访问的效率,因为它可以避免对整个堆进行全面扫描来查找对象。
-
效率和性能:
- 标记-压缩算法通过压缩堆内存来减少内存碎片,使得连续的空闲内存块更大,更易于管理。这对于长时间运行的应用程序尤其重要,因为它可以防止性能随时间下降。
- 该算法的实现确保了对堆的操作尽可能高效,尤其是在内存访问和对象移动方面。
通过上述方法,Dart VM 的标记-压缩垃圾回收算法有效地管理内存,优化了资源的使用,同时减少了因内存碎片而引起的性能问题。这种算法是现代编程语言虚拟机中常见的一种高效内存管理技术。
终结器
垃圾收集器(GC)意识到有两种类型的对象用于运行终结器:
FinalizerEntry
(终结器条目)Finalizer
(终结器基类,包括FinalizerBase
、_FinalizerImpl
、_NativeFinalizer
)
一个 FinalizerEntry
包含 value
(值),可选的 detach
键和 token
(一个指向 finalizer
的引用),以及 external_size
(外部大小)。一个条目只会弱引用 value
、detach
键和 finalizer
(类似于 WeakReference
弱引用其目标)。
一个 Finalizer
包含所有条目,一个收集值的条目列表,以及对隔离区的引用。
当一个条目的值被 GC 时,该条目被添加到已收集列表中。如果任何条目被移动到已收集列表,将发送一条消息调用终结器在该列表上的所有条目上调用回调。对于本地终结器,GC 中会立即调用本地回调。然而,我们仍然会向本地终结器发送消息以清理所有条目和分离。
当用户分离一个终结器时,条目的 token
被设置为条目本身,并从所有条目集中移除。这确保如果条目已经被移动到已收集列表,终结器不会被执行。
为了加速分离过程,我们使用从分离键到条目列表的弱映射。这确保条目可以被 GC。
清理器和标记器可以并行处理终结器条目。并行任务在已收集条目列表的头部使用原子交换,确保没有条目丢失。当处理条目时,保证变异器线程会被停止。这确保我们不需要为将条目移动到终结器收集列表中设置障碍。Dart 也使用原子交换读取和替换已收集条目列表,确保在加载/存储之间不运行 GC。
当终结器收到处理终结对象的消息时,该消息会保持其活性。另一种设计是在终结器中预分配一个指向终结器的 WeakReference
,并发送该弱引用。这将以额外对象的成本为代价。
如果终结器对象本身被 GC,则不会为任何附件运行回调。
在隔离关闭时,将运行本地终结器,但常规终结器则不会运行。
转载自:https://juejin.cn/post/7364274025277538355