尊敬的各位同仁,各位对高性能运行时和内存管理充满热情的工程师们,大家好。
今天,我们将深入探讨一个在现代高性能语言运行时中至关重要、且日益复杂的主题:Flutter(或更准确地说,其底层Dart VM)的并发垃圾回收(GC)。特别是,我们将聚焦于并行标记(Parallel Marking)与并发清理(Concurrent Sweeping)这两个核心阶段,以及它们在多线程环境下如何实现精妙的同步,以确保应用的流畅运行,同时高效回收内存。
垃圾回收器的设计是编程语言运行时最核心的挑战之一。在Flutter这样的UI框架中,流畅的用户体验是至高无上的。任何可感知的停顿,即使是毫秒级的,都可能破坏用户体验。传统的“Stop-The-World”(STW)式垃圾回收器,通过暂停所有应用线程来执行回收工作,无疑是流畅性的一大杀手。因此,并发和并行GC技术应运而生,它们旨在将大部分GC工作与应用线程(即“Mutator”线程,负责修改对象图)并行或并发执行,从而显著减少STW停顿的时间。
Dart VM 的垃圾回收器正是这一趋势的典范。它采用分代(Generational)策略,将堆内存划分为新生代(New Space)和老生代(Old Space)。新生代通常采用复制(Copying)算法,如Cheney算法,以其高效的存活对象复制和死亡对象批量回收特性,在短生命周期对象众多的场景下表现出色。而老生代,由于对象生命周期较长,且通常占用较大内存,更适合采用标记-清除(Mark-Sweep)算法。为了最小化STW时间,Dart VM 在老生代的标记和清除阶段引入了并发和并行的机制。
本讲座将围绕以下几个核心问题展开:
- 为什么需要并发和并行GC?
- Dart VM 老生代GC的基本流程与核心算法。
- 并行标记阶段:如何利用多核优势,以及同步的挑战与解决方案。
- 并发清理阶段:如何在应用线程运行时回收内存,以及同步的挑战与解决方案。
- 跨阶段与全局同步机制:确保GC各阶段协同工作。
- 性能考量与权衡。
1. 垃圾回收的必要性与传统GC的局限性
在现代编程语言中,手动内存管理(如C/C++中的malloc/free)虽然提供了极致的控制,但也极易引入内存泄漏、野指针、双重释放等严重问题,导致程序崩溃或数据损坏。垃圾回收器的出现,旨在自动化内存管理,让开发者可以专注于业务逻辑而非底层资源分配与释放。
然而,传统的“Stop-The-World”(STW)垃圾回收器,如早期的Mark-Sweep或Mark-Compact算法,在执行GC时会暂停所有应用线程。对于小型堆或低吞吐量应用,这种停顿可能不明显。但对于拥有大堆内存、高对象分配速率且对延迟敏感的Flutter应用来说,即使是几十毫秒的停顿,也足以让用户感觉到UI卡顿,这在游戏、动画或实时交互应用中是不可接受的。
| GC类型 | 优点 | 缺点 | STW时间 | 适用场景 |
|---|---|---|---|---|
| Stop-The-World (STW) | 实现简单,内存回收彻底。 | 导致应用停顿,影响用户体验。 | 随堆大小和存活对象数量线性增长。 | 对延迟不敏感、堆较小的应用。 |
| 并发 (Concurrent) | GC线程与应用线程并行运行,减少STW时间。 | 实现复杂,需要精密的同步机制;可能增加内存开销。 | 显著减少,通常仅在关键阶段暂停。 | 对延迟敏感、堆较大的应用。 |
| 并行 (Parallel) | 利用多核CPU加速GC工作,缩短STW时间。 | 实现复杂,需要精密的同步机制。 | 显著减少,但仍是STW。 | 吞吐量要求高、多核CPU环境。 |
为了克服STW的局限性,现代GC发展出了并发(Concurrent)和并行(Parallel)技术。并发GC允许GC线程与应用线程同时运行,而并行GC则利用多个GC线程(或辅助GC的应用线程)同时执行GC任务,以缩短STW时间。Dart VM 老生代GC正是这两种技术的结合体。
2. Dart VM 老生代GC的基础:标记-清除算法
Dart VM 的老生代垃圾回收器主要采用标记-清除(Mark-Sweep)算法,并在此基础上进行了优化,实现了并发标记和并行清理。
标记-清除算法的基本步骤:
- 标记 (Mark):从一组根对象(如局部变量、静态变量、线程栈上的对象等)开始,遍历所有可达对象。一旦对象被访问,就将其标记为“存活”。
- 清除 (Sweep):遍历整个堆内存。所有未被标记为“存活”的对象,都被认为是“垃圾”,其占用的内存将被回收,并添加到空闲列表中,以供后续分配使用。
为了支持并发和并行,这些步骤需要被细化,并引入复杂的同步机制。
对象头与标记位:
在 Dart VM 中,每个对象在内存中都有一个对象头(Object Header),其中包含类型信息、哈希码、锁定状态等,以及一个或多个用于GC目的的位字段,其中就包括“标记位”(Mark Bit)。
// 伪代码:Dart VM 对象头结构
struct ObjectHeader {
uintptr_t class_id; // 类型ID
uintptr_t hash_code; // 哈希码
uintptr_t flags; // 各种标志位,包括GC标记位
// ... 其他字段
};
// GC 标记位的操作(概念性)
#define MARK_BIT_MASK (1 << 0) // 假设标记位是 flags 的最低位
void set_mark_bit(ObjectHeader* header) {
header->flags |= MARK_BIT_MASK;
}
void clear_mark_bit(ObjectHeader* header) {
header->flags &= ~MARK_BIT_MASK;
}
bool is_marked(ObjectHeader* header) {
return (header->flags & MARK_BIT_MASK) != 0;
}
在标记阶段,GC会设置对象的标记位;在清除阶段,GC会检查标记位来判断对象是否存活。
3. 并行标记:并发遍历对象图的挑战与同步
并行标记是GC过程的关键一步,其目标是识别所有存活对象。在Dart VM中,为了减少STW时间,标记过程通常是并发的,并且可以利用多个GC线程(或辅助GC的mutator线程)并行执行。
3.1 标记阶段概述
Dart VM 采用经典的三色标记法(Tri-color Marking)来管理并发标记过程中的对象状态:
- 白色 (White):对象最初是白色,表示尚未被GC访问,可能是垃圾。
- 灰色 (Gray):对象已被GC访问,但其引用的对象尚未被扫描。灰色对象位于一个工作队列中。
- 黑色 (Black):对象及其所有引用对象都已被GC扫描完毕,确定为存活。
标记过程从根对象开始。根对象首先被染成灰色并放入工作队列。GC线程从工作队列中取出灰色对象,将其染成黑色,并将其引用的所有白色对象染成灰色并放入工作队列。这个过程持续进行,直到工作队列为空,所有可达对象都变为黑色或灰色(最终都会变为黑色)。
3.2 并行标记的工作流程
在并行标记中,多个GC线程同时从工作队列中取出灰色对象并进行处理。
-
初始化阶段 (STW):
- 暂停所有Mutator线程(短暂的STW)。
- 扫描所有GC根(栈、静态变量、全局句柄等),将它们引用的所有老生代对象染成灰色,并放入一个共享的标记工作队列(Marking Work Queue)中。
- 记录堆的当前状态,以支持并发标记期间的修改。
- 恢复Mutator线程,并发标记开始。
-
并发标记阶段 (Concurrent/Parallel):
- 多个GC辅助线程(或Mutator线程在空闲时协助)从共享的标记工作队列中获取灰色对象。
- 对于每个取出的灰色对象:
- 将其染成黑色。
- 遍历其所有字段(指针),如果字段指向一个白色老生代对象:
- 将其染成灰色。
- 将其添加到标记工作队列中。
- 此阶段,Mutator线程可以继续运行,分配新对象,并修改对象引用。为了维护三色不变性,需要写入屏障。
-
最终标记阶段 (STW):
- 再次暂停所有Mutator线程(短暂的STW)。
- 处理剩余的标记工作队列(通常很小)。
- 扫描自并发标记开始以来被修改的根(如线程栈),确保所有可达对象都被标记。
- 处理写入屏障记录下的所有修改,确保所有新引用到的白色对象都被正确标记。
- 标记阶段结束,所有可达对象都已标记为黑色。
3.3 同步挑战与解决方案
在并行标记阶段,多个线程同时访问和修改共享数据结构,这带来了严峻的同步挑战。
3.3.1 共享标记工作队列的同步
多个GC线程需要从同一个工作队列中获取任务(灰色对象),并且将新的灰色对象放入队列。这需要队列操作是线程安全的。
解决方案:
- 锁 (Locks/Mutexes):最直接的方式是使用互斥锁保护队列的入队和出队操作。然而,高并发下频繁的锁竞争会导致性能瓶颈。
- 无锁数据结构 (Lock-free Data Structures):使用原子操作(如Compare-And-Swap, CAS)实现无锁队列,可以减少锁竞争。例如,一个基于链表的无锁队列,其头尾指针的修改可以通过CAS来保证原子性。
- 工作窃取 (Work Stealing):每个GC线程维护一个本地的灰色对象队列。当本地队列为空时,它会尝试从其他GC线程的队列尾部“窃取”任务。这减少了对中央共享队列的竞争,提高了并行度。Dart VM 可能结合使用这两种策略,例如有一个全局共享队列用于初始根对象,然后各个GC线程维护自己的局部队列,并支持工作窃取。
伪代码:共享标记工作队列(概念性)
// 伪代码:MarkingWorkQueue
struct MarkingWorkQueue {
// 可能是基于链表、数组或双端队列
// 为了简化,这里表示一个抽象的线程安全队列
std::mutex queue_mutex;
std::vector<ObjectHeader*> objects_to_mark; // 存储灰色对象
void enqueue(ObjectHeader* obj) {
std::lock_guard<std::mutex> lock(queue_mutex);
objects_to_mark.push_back(obj);
}
ObjectHeader* dequeue() {
std::lock_guard<std::mutex> lock(queue_mutex);
if (objects_to_mark.empty()) {
return nullptr;
}
ObjectHeader* obj = objects_to_mark.back();
objects_to_mark.pop_back();
return obj;
}
bool is_empty() {
std::lock_guard<std::mutex> lock(queue_mutex);
return objects_to_mark.empty();
}
};
3.3.2 对象标记位的同步
多个GC线程可能同时尝试标记同一个白色对象为灰色(当其被不同的灰色对象引用时)。标记位本身通常只需要一个原子写操作(设置位),但需要确保这个操作是原子的,以避免数据竞争。
解决方案:
- 原子位操作 (Atomic Bit Operations):大多数CPU架构都提供原子操作指令,可以安全地设置或清除内存中的单个位。
- 锁 (Locks):在某些情况下,如果对象头足够大,或者涉及多个位的修改,可能需要更粗粒度的锁。但对于单个标记位,原子操作通常是首选。
伪代码:并行标记函数(简化)
// 伪代码:并行标记辅助函数
void parallel_mark_worker(MarkingWorkQueue* queue) {
ObjectHeader* current_obj;
while ((current_obj = queue->dequeue()) != nullptr) {
// 标记为黑色 (概念上,在处理完其引用后才真正变为黑色)
// 实际上,从队列取出后即可视为“处理中”,避免重复入队
// 遍历对象的所有引用字段
// 假设 get_references 返回一个指向所有引用对象指针的迭代器
for (ObjectHeader* referenced_obj : get_references(current_obj)) {
if (referenced_obj == nullptr) continue; // 空引用跳过
// 检查是否是老生代对象且未被标记
// 注意:is_marked(referenced_obj) 在并发环境中需要注意竞争
// 更好的方式是尝试原子地设置标记位并检查旧值
if (is_old_space_object(referenced_obj)) {
// 尝试原子地将 referenced_obj 从白色标记为灰色
// 这是一个关键的原子操作
bool was_white = atomic_compare_and_set_mark_bit(referenced_obj, false, true); // 假设 false=white, true=gray
if (was_white) {
// 成功从白色变为灰色,入队
queue->enqueue(referenced_obj);
}
}
}
// current_obj 及其所有引用已处理,可以认为是黑色了
// 实际上,三色标记法更复杂,这里仅为简化演示
}
}
3.3.3 写入屏障 (Write Barrier)
并行标记最复杂的挑战在于Mutator线程在GC运行时修改对象图。如果Mutator线程将一个黑色对象指向一个白色对象,那么这个白色对象就可能在GC不知情的情况下变为可达,从而被错误地回收。这就是著名的“三色不变性”问题:不允许黑色对象直接引用白色对象。
为了维护三色不变性,Dart VM 使用了写入屏障 (Write Barrier)。写入屏障是一段小段代码,它在每次Mutator线程修改对象引用时执行。
写入屏障的原理:
当Mutator线程执行 parent.field = child 这样的操作时,写入屏障会检查 parent 和 child 的颜色。
如果 parent 是黑色,child 是白色,那么:
- 将
child染成灰色,并将其加入标记工作队列。 - 或者,将
parent染成灰色(更保守,但有时更简单)。 - 或者,记录下这个
(parent, child)对,待GC最终标记阶段处理。
Dart VM 通常采用的写入屏障策略是“Remembered Set”或“Card Table”的一种变体,或者直接将新的引用目标对象染灰并入队。对于老生代GC,更常见的策略是“Snapshot-at-the-Beginning”或“Incremental Update”。Dart VM 的写入屏障通常会将新指向的对象(如果它是白色且位于老生代)标记为灰色并入队,或者记录下这个引用变更。
伪代码:写入屏障(概念性)
// 伪代码:Mutator线程的写入屏障
void write_barrier(ObjectHeader* parent, ObjectHeader* old_child, ObjectHeader* new_child) {
if (GC_IS_MARKING_CONCURRENTLY && is_old_space_object(parent)) {
// 假设 GC_IS_MARKING_CONCURRENTLY 是一个全局标志,表示GC正在并发标记
// 并且 parent 是一个老生代对象 (因为只有老生代才需要写屏障)
if (new_child != nullptr && is_old_space_object(new_child)) {
// 如果新引用的对象是老生代对象
// 尝试原子地将 new_child 从白色标记为灰色
bool was_white = atomic_compare_and_set_mark_bit(new_child, false, true);
if (was_white) {
// 如果 new_child 原来是白色,现在被黑色 parent 引用,那么它必须被标记为灰色
// 将 new_child 加入到标记工作队列
// gc_marking_work_queue.enqueue(new_child);
// 实际可能是一个更轻量级的记录,例如放入一个Remembered Set
record_potential_root_for_new_child(new_child);
}
}
}
}
// Mutator线程修改引用时调用
void set_object_field(ObjectHeader* parent, int field_offset, ObjectHeader* new_value) {
ObjectHeader* old_value = parent->fields[field_offset]; // 假设字段是数组访问
write_barrier(parent, old_value, new_value);
parent->fields[field_offset] = new_value;
}
写入屏障的开销是不可避免的,但它通过将GC工作分散到Mutator线程的每次写操作中,避免了长时间的STW停顿。Dart VM 会精心优化写入屏障的实现,使其尽可能高效。
4. 并发清理:Mutator线程运行时回收内存的艺术与同步
标记阶段完成后,所有可达对象都已被标记。现在,GC需要回收那些未被标记的垃圾对象所占用的内存。并发清理(Concurrent Sweeping)旨在Mutator线程继续运行的同时,将未标记的内存块重新添加到空闲列表,以供后续分配。
4.1 清理阶段概述
-
准备阶段 (通常紧随最终标记的STW):
- 在最终标记阶段结束后,GC知道哪些对象是存活的,哪些是垃圾。
- GC可能会做一些预处理,例如将堆划分为更小的区域(Pages/Blocks),并为每个区域维护一个状态,指示它是否包含垃圾。
-
并发清理阶段 (Concurrent):
- GC辅助线程遍历堆内存。
- 对于每个对象:
- 如果它被标记为存活(黑色),则保持不变。
- 如果它未被标记(白色),则将其占用的内存块添加到相应的空闲列表(Free List)中。
- Mutator线程可以同时运行,分配新对象。新对象的分配需要从空闲列表中获取内存。
-
重置标记位 (Reset Mark Bits):
- 在清理完成后,为了准备下一轮GC,所有存活对象的标记位需要被清除。这通常也在并发清理阶段完成,或者在一个短暂的STW阶段完成。
4.2 同步挑战与解决方案
并发清理的核心挑战在于GC线程在回收内存的同时,Mutator线程可能正在申请内存,并且可能访问GC正在清理的内存区域。
4.2.1 空闲列表 (Free List) 的同步管理
当GC线程发现一块未标记的内存时,它需要将其添加到空闲列表中。当Mutator线程需要分配新对象时,它需要从空闲列表中获取一块内存。这意味着空闲列表是GC线程和Mutator线程共享的关键数据结构,必须进行同步访问。
解决方案:
- 锁 (Locks/Mutexes):对空闲列表的添加(GC)和获取(Mutator)操作使用互斥锁保护。为了减少竞争,可以采用分段锁(Per-Region Free Lists),即每个内存区域或大小的空闲列表都有自己的锁。
- 无锁数据结构 (Lock-free Data Structures):使用原子操作实现无锁空闲列表,例如通过CAS操作修改链表头指针。这通常需要更复杂的算法设计。
- 区域管理 (Page/Block Management):Dart VM 将堆划分为页(Pages)或块(Blocks)。每个页可能有一个页头,记录其空闲状态。GC线程可以独立地清理一个页,并在清理完成后将该页的空闲块添加到全局或局部空闲列表中。Mutator线程在分配时,优先从线程本地的空闲缓存中获取,如果不足,再从全局空闲列表中获取,减少对全局锁的竞争。
伪代码:空闲列表管理(概念性)
// 伪代码:FreeList (按大小分类的空闲块链表)
struct FreeList {
std::mutex list_mutex;
ObjectHeader* head; // 指向空闲块链表的头部
// 将一个内存块添加到空闲列表
void add_free_block(ObjectHeader* block, size_t size) {
// 在实际VM中,可能按大小将块放入不同的FreeList
// 简化起见,这里直接加入
std::lock_guard<std::mutex> lock(list_mutex);
block->next_free = head; // 假设ObjectHeader有一个next_free字段
head = block;
}
// 从空闲列表获取一个指定大小的内存块
ObjectHeader* allocate_block(size_t required_size) {
std::lock_guard<std::mutex> lock(list_mutex);
ObjectHeader* current = head;
ObjectHeader* prev = nullptr;
// 遍历寻找合适的块
while (current != nullptr) {
// 假设 get_block_size(current) 返回块的实际大小
if (get_block_size(current) >= required_size) {
// 找到合适的块,从链表中移除
if (prev) {
prev->next_free = current->next_free;
} else {
head = current->next_free;
}
// 可能需要将剩余部分分割成更小的空闲块
return current;
}
prev = current;
current = current->next_free;
}
return nullptr; // 没有找到足够的空闲块
}
};
4.2.2 并发清除与新对象分配的冲突
当GC线程正在清除一个内存区域时,Mutator线程可能尝试在该区域分配新对象。这可能导致GC线程将Mutator刚刚分配的内存块错误地添加到空闲列表,或者Mutator线程从一个尚未完全清理的区域分配到脏内存。
解决方案:
- 分配器与清除器协同 (Allocator-Sweeper Cooperation):
- Bump-pointer Allocation (碰撞指针分配):对于新生代和某些特定区域,Mutator线程可以从一个预分配的大块内存中以碰撞指针方式快速分配。当这个块用完时,它会向GC申请一个新的块。
- Thread-Local Allocation Buffers (TLABs):每个Mutator线程维护一个私有的内存区域(TLAB)。新对象首先在TLAB中分配,这避免了对全局空闲列表的竞争。当TLAB用完时,Mutator线程会从全局空闲列表中获取一个新的TLAB,此时需要同步。
- GC线程优先分配新页:在并发清理过程中,Mutator线程的分配请求可能会被引导到尚未被GC清理的“干净”内存页,或者GC会预先分配一些新的、干净的页供Mutator使用。只有当这些选项都用尽时,Mutator才会尝试从正在被清理的空闲列表中获取内存。
- 页状态管理 (Page State Management):每个内存页可以有不同的状态,例如“正在清理”、“已清理,包含空闲块”、“已满”。Mutator在分配时会根据这些状态选择合适的页。GC清理页时,会更新页的状态。
伪代码:并发清理辅助函数(简化)
// 伪代码:并发清理辅助函数
void concurrent_sweep_worker(Heap* heap, FreeList* free_lists_by_size[]) {
// 遍历堆中的所有内存页
for (HeapPage* page : heap->get_all_pages()) {
// 确保同一时间只有一个GC线程清理这个页
std::lock_guard<std::mutex> page_lock(page->sweep_mutex);
// 遍历页中的所有对象槽
ObjectHeader* current_obj = page->start_address;
while (current_obj < page->end_address) {
if (is_marked(current_obj)) {
// 存活对象,清除标记位准备下一轮GC
clear_mark_bit(current_obj);
// 移动到下一个对象
current_obj = get_next_object(current_obj);
} else {
// 垃圾对象,回收内存
size_t obj_size = get_object_size(current_obj);
// 将该内存块添加到空闲列表
free_lists_by_size[get_free_list_index(obj_size)]->add_free_block(current_obj, obj_size);
// 移动到下一个对象 (跳过已回收的块)
current_obj = (ObjectHeader*)((char*)current_obj + obj_size);
}
}
// 更新页状态为“已清理,包含空闲块”
page->state = PageState::SWEPT_AND_HAS_FREE_BLOCKS;
}
}
4.2.3 内存屏障 (Memory Barriers)
在多核处理器架构下,CPU的乱序执行和缓存一致性问题可能导致一个线程对内存的修改,在另一个线程中不可见,或者以错误的顺序可见。内存屏障(Memory Barrier或Memory Fence)用于强制CPU和编译器按照特定的顺序执行内存操作,确保不同线程之间内存视图的一致性。
在GC中,内存屏障在以下场景尤为重要:
- 标记位的可见性:当GC线程标记一个对象时,Mutator线程需要能立即看到这个标记位的改变,反之亦然(例如在写入屏障中)。
- 空闲列表的可见性:当GC线程将内存块添加到空闲列表时,Mutator线程需要能立即看到并使用这些新的空闲块。
- GC状态的可见性:GC的不同阶段切换(例如从标记到清理)通过全局GC状态变量控制。这些状态的改变需要对所有线程可见。
解决方案:
- 显式内存屏障指令:使用如
std::atomic_thread_fence(C++11) 或特定的CPU指令(如mfence,sfence,lfence)来强制内存同步。 - 原子操作:原子操作(如
std::atomic<T>::load/store/compare_exchange_weak/strong)本身就包含内存屏障语义(通常是acquire/release或seq_cst),可以保证操作的可见性和顺序性。
例如,一个GC线程在修改完空闲列表后,可能需要一个release屏障,以确保所有之前对空闲列表的修改都对其他线程可见。而Mutator线程在从空闲列表读取时,可能需要一个acquire屏障,以确保它能看到所有最新的空闲列表状态。
5. 跨阶段与全局同步机制
除了并行标记和并发清理内部的同步,GC的各个阶段之间以及GC与Mutator线程之间还需要更高层次的全局同步。
5.1 GC状态机与全局标志
Dart VM 运行时会维护一个全局的GC状态机,指示当前GC所处的阶段。Mutator线程和GC辅助线程会根据这个状态来调整它们的行为。
GC状态示例:
| 状态名称 | 描述 | Mutator行为 | GC辅助线程行为 |
|---|---|---|---|
| IDLE | GC空闲,等待下次触发。 | 正常运行,分配新对象。 | 睡眠或等待事件。 |
| MARK_INITIAL_STW | 初始标记,暂停所有Mutator。 | 暂停。 | 扫描根,初始化标记队列。 |
| MARK_CONCURRENT | 并发标记。 | 正常运行,执行写入屏障。 | 从标记队列取出对象并标记。 |
| MARK_FINAL_STW | 最终标记,暂停所有Mutator。 | 暂停。 | 处理剩余标记工作,处理写入屏障记录。 |
| SWEEP_CONCURRENT | 并发清理。 | 正常运行,从空闲列表分配。 | 遍历堆,回收未标记内存。 |
| SWEEP_FINAL_STW | 清理完成后的短暂STW(可选)。 | 暂停(可能不需要)。 | 重置标记位,更新堆统计。 |
这些状态的转换通常由一个主GC协调器线程控制,并通过原子变量或互斥锁来更新全局状态标志,确保所有线程都能看到最新的GC状态。
5.2 根扫描的同步
在GC的初始标记阶段和最终标记阶段,GC需要准确地扫描所有GC根。这些根包括:
- 线程栈:Mutator线程的局部变量可能引用堆对象。在STW阶段,GC可以安全地遍历所有线程的栈。
- 全局变量/静态变量:这些通常是固定地址,易于扫描。
- 句柄 (Handles):Dart VM 有一套句柄系统来管理对对象的引用,特别是从C++代码到Dart对象的引用。
在并发标记开始时,GC会获取一个“堆快照”来确定初始根集。在并发标记过程中,由于Mutator线程可能修改栈或全局变量,最终标记阶段需要再次扫描这些易变根,以捕获所有在并发阶段被引用的新对象。
5.3 Mutator线程的协作
在Dart VM中,Mutator线程并非完全被动。它们在某些情况下也会协助GC工作,例如:
- 辅助标记 (Marking Assistance):当Mutator线程尝试分配新对象,但发现堆空间不足,或者GC工作队列变得非常大时,它们可能会被要求暂停自己的业务逻辑,转而协助GC线程进行标记工作,直到GC压力缓解。这种机制被称为“Mutator Assistance”或“Marking Pressure”。
- 写入屏障:这是最直接的协作,Mutator线程每次修改引用时都会执行写入屏障。
这种协作机制有助于将GC的负担更均匀地分布到所有可用的CPU核心上,进一步减少专用GC线程的负担和STW时间。
6. 性能考量与权衡
并发和并行GC虽然带来了显著的延迟优势,但也伴随着一些性能上的权衡:
- 实现复杂度:并发GC的实现远比STW GC复杂,需要精通并发编程、内存模型和底层CPU架构。
- 内存开销:
- 写入屏障:每次写操作都会带来额外的CPU指令开销。
- 标记位/辅助数据结构:对象头中可能需要额外的位来存储GC状态,或者需要额外的辅助数据结构(如Remembered Set、Card Table)来跟踪引用变更。
- 空闲列表:维护空闲列表本身也需要一定的内存。
- CPU开销:GC线程与Mutator线程的并发执行意味着GC会消耗一定的CPU周期,即使GC是并发的,它仍然在后台运行,会争抢CPU资源。此外,同步机制(锁、原子操作、内存屏障)本身也会引入CPU开销。
- 吞吐量影响:并发GC可能会略微降低应用的整体吞吐量,因为它需要更多的CPU周期来执行GC工作,并且同步开销会增加。然而,这种吞吐量的降低通常可以接受,因为换来的是显著的用户体验提升。
- 设计复杂性:正确处理并发GC中的各种竞争条件、内存可见性问题和死锁风险是极具挑战性的。
Dart VM 的GC设计旨在找到延迟和吞吐量之间的最佳平衡点,以满足Flutter应用对高性能和流畅用户体验的需求。它通过分代GC、并行标记、并发清理以及Mutator协作等多种技术,共同构建了一个高效且响应迅速的内存管理系统。
结语
我们今天深入探讨了Flutter底层Dart VM的并发垃圾回收机制,特别是并行标记与并发清理在多线程环境下的同步挑战与解决方案。从三色标记法到写入屏障,从共享工作队列到空闲列表的原子操作,Dart VM的GC是现代高性能运行时工程的典范。它通过精密的算法设计和底层的同步原语,在保证内存安全的同时,极大地减少了GC停顿对用户体验的影响。理解这些机制,不仅能帮助我们更好地优化Flutter应用,也能让我们对现代编程语言运行时的复杂性和精妙之处有更深刻的认识。