引言:V8 垃圾回收的基石与挑战
各位同仁,大家好。今天我们将深入探讨 V8 JavaScript 引擎中老年代垃圾回收的核心机制:增量标记(Incremental Marking)与并发清理(Concurrent Sweeping)。这两个技术是 V8 乃至现代高性能运行时实现低延迟、流畅用户体验的关键。
JavaScript 作为一种高级动态语言,其内存管理是自动的,由垃圾回收器(Garbage Collector, GC)负责。V8 引擎的 GC 系统是高度优化且复杂的,它遵循分代假设(Generational Hypothesis):大部分对象生命周期很短,而少数对象会存活较长时间。基于这一假设,V8 将堆内存划分为两个主要区域:
- 新生代(Young Generation / New Space):用于存放新创建的对象。这里通常采用Scavenge 算法(一种基于复制的算法,如 Cheney’s algorithm),效率高但会造成内存减半的浪费。Scavenge 算法的特点是暂停时间短,但会频繁执行。
- 老年代(Old Generation / Old Space):用于存放经过多次新生代回收后仍然存活的对象,以及直接分配的大对象。老年代的对象生命周期相对较长,因此其回收算法必须更注重效率和低暂停时间,因为涉及的内存量更大,传统方法会导致显著的停顿。
我们今天的焦点正是老年代的垃圾回收。新生代的 Scavenge 算法虽然会暂停 JavaScript 执行,但由于其处理的内存区域较小,暂停时间通常在几毫秒内,对用户体验影响有限。然而,老年代的回收则不然。老年代中的对象数量和总内存可能非常庞大,如果采用传统的“停顿全世界”(Stop-the-World, STW)的回收方式,可能会导致秒级的应用程序卡顿,这对于交互式应用来说是不可接受的。因此,如何尽可能地减少 STW 暂停,是 V8 老年代 GC 设计的核心挑战。
传统的 STW 标记-清除-整理算法
在深入增量标记和并发清理之前,我们先回顾一下老年代 GC 的传统基础:标记-清除-整理(Mark-Sweep-Compact)算法。理解其工作原理及局限性,有助于我们 appreciating 增量和并发的价值。
传统的 Mark-Sweep-Compact 算法通常在应用程序线程(即 JavaScript 执行线程,我们称之为 Mutator 线程)完全停止时执行,这便是“停顿全世界”。
1. 标记阶段 (Marking)
这是 GC 的第一步,目标是识别所有“活”对象(reachable objects),即那些仍然被应用程序引用的对象。
- 根对象 (Roots) 确定:GC 从一组已知的“根”对象开始遍历。这些根对象包括全局对象(如
window或global)、执行栈上的变量、寄存器中的值、以及被 V8 内部 C++ 代码直接引用的对象等。 - 图遍历:从根对象开始,GC 遍历对象图,沿着所有可达的引用指针,递归地访问每一个对象。
- 标记位 (Mark Bits):当一个对象被访问到时,GC 会在其内存头部设置一个“标记位”或将其状态设为“已标记”,表示该对象是“活”的。
示例:简化对象结构和标记
假设我们有如下简单的对象结构:
// 简化版 V8 对象头部
struct HeapObject {
uintptr_t map_pointer; // 指向描述对象类型的Map
uintptr_t mark_bits; // 用于GC标记的位
// ... 其他元数据
};
// 假设的V8堆对象
struct JSObject : HeapObject {
// ... JS对象的属性字段
HeapObject* field1;
HeapObject* field2;
};
// 简化版GC标记函数
void MarkObject(HeapObject* obj) {
if (obj == nullptr || obj->mark_bits & kMarkBitMask) {
// 已经标记或空对象,无需处理
return;
}
obj->mark_bits |= kMarkBitMask; // 设置标记位
// 将对象加入到待处理队列,以便后续遍历其引用
// 例如:mark_worklist.Push(obj);
}
// 模拟GC从根开始的标记过程
void PerformMarking() {
// 1. 初始化:清除所有对象的标记位 (或使用新的标记位,避免全堆遍历)
// 2. 遍历根对象
for (HeapObject* root : GetRoots()) {
MarkObject(root);
// ... 将root及其子对象加入标记工作列表
}
// 3. 广度优先或深度优先遍历标记工作列表
while (!mark_worklist.IsEmpty()) {
HeapObject* current = mark_worklist.Pop();
// 假设JSObject可以包含其他JSObject引用
if (JSObject* js_obj = dynamic_cast<JSObject*>(current)) {
MarkObject(js_obj->field1);
MarkObject(js_obj->field2);
// ... 遍历所有指针字段
}
}
}
在传统的 STW 标记中,整个 PerformMarking 过程会一次性完成,期间 JavaScript 无法执行。
2. 清除阶段 (Sweeping)
标记阶段结束后,所有未被标记的对象都是“垃圾”。清除阶段的任务是遍历整个堆内存,回收这些未标记对象的空间,并将它们添加到空闲列表中,供后续的内存分配使用。
// 简化版GC清除函数
void PerformSweeping() {
for (HeapPage* page : GetAllHeapPages()) { // 遍历所有内存页
for (HeapObject* obj = page->FirstObject(); obj != page->EndOfPage(); obj = obj->NextObject()) {
if (!(obj->mark_bits & kMarkBitMask)) {
// 对象未被标记,是垃圾,回收其空间
// 将obj的内存区域添加到page的空闲列表中
page->AddFreeSpace(obj, obj->size);
} else {
// 清除标记位,为下一次GC做准备
obj->mark_bits &= ~kMarkBitMask;
}
}
}
}
清除阶段同样是 STW 的,需要遍历大量内存,也会造成显著暂停。
3. 整理阶段 (Compaction)
标记和清除可能会导致堆内存碎片化。例如,连续的几个对象被回收后,留下了小块的空闲区域,这些区域可能不足以容纳大的新对象,即使总空闲内存充足。整理阶段旨在解决这个问题,它会移动“活”对象,使它们紧密排列,从而形成更大的连续空闲区域。
- 对象移动:整理器会决定如何移动对象,通常是将其移动到堆内存的起始端或某个连续区域。
- 指针更新:当对象被移动后,所有指向该对象的引用(指针)都必须被更新,指向其新的内存地址。这是整理阶段最复杂和耗时的部分。
整理阶段通常是代价最高的 STW 阶段,因为它不仅要遍历对象,还要移动它们并更新所有指向它们的指针。V8 不会在每次老年代 GC 时都进行整理,通常是当碎片化程度达到一定阈值或内存不足时才触发。
为何 STW 是一个问题
Mark-Sweep-Compact 算法的每一步都需要暂停 JavaScript 应用程序的执行。在现代 Web 应用、Node.js 服务端应用中,堆内存可能达到数百兆甚至数 GB。在如此庞大的内存上执行上述操作,即使是优化过的 C++ 代码,也可能需要几十毫秒甚至几百毫秒。对于追求流畅体验的用户来说,任何超过 50-100 毫秒的卡顿都是可以感知的,可能导致应用无响应、动画卡顿、输入延迟等问题。
因此,V8 的 GC 团队(著名的 Orinoco 项目)致力于将这些 STW 阶段分解、并行化或并发化,以最大限度地减少主线程的停顿时间。增量标记和并发清理正是这一努力的成果。
增量标记 (Incremental Marking):化整为零的艺术
增量标记的核心思想是将一个漫长且不可中断的标记阶段,分解成许多小的、可中断的标记“步”(steps)。这些小步可以穿插在 JavaScript 代码执行之间,从而显著减少单次 STW 暂停的最长时间。
核心思想:将标记工作分解为小块
想象一下,你有一项需要花费一小时完成的任务。如果你必须一次性完成,期间不能做任何其他事情,那么你就会被“停顿”一小时。但如果你可以将任务分解成 60 个一分钟的小块,每完成一小块就可以休息一下或处理其他事情,那么你单次被“停顿”的时间就大大减少了。增量标记就是这个原理。
在 V8 中,GC 标记不再是一次性完成。当 GC 认为需要进行老年代回收时,它会启动一个增量标记周期。这个周期会持续一段时间,期间 JavaScript 线程会周期性地暂停一小段时间,执行一小段标记工作,然后继续执行 JavaScript。
三色抽象 (Tri-color Abstraction)
为了在 Mutator 线程(JavaScript 线程)运行的同时进行标记,GC 必须有一种机制来跟踪对象的状态。三色抽象是并发和增量 GC 的理论基础,它将堆中的对象分为三种颜色:
- 白色 (White):对象尚未被 GC 访问,可能是垃圾(在标记结束时),也可能是还未被扫描到的活对象。
- 灰色 (Grey):对象已被 GC 访问,但其所有子引用尚未被扫描。灰色对象位于 GC 的工作队列中,等待被进一步处理。
- 黑色 (Black):对象已被 GC 访问,并且其所有子引用也已被扫描。黑色对象是确认的活对象。
三色不变性 (Tri-color Invariant):
为了确保增量标记的正确性,必须维护一个关键的不变性:不允许黑色对象直接指向白色对象。
如果一个黑色对象指向了一个白色对象,这意味着:
- 黑色对象已经扫描完毕,其引用都已处理。
- 白色对象尚未被扫描。
如果此时 Mutator 线程将一个黑色对象的一个引用从灰色或黑色对象改为白色对象,那么这个白色对象就可能在标记结束时被错误地回收,因为它看起来是不可达的。
为了维护三色不变性,当 Mutator 线程在标记过程中修改对象引用时,必须采取特殊措施。这就是写屏障的作用。
表格:对象状态与含义
| 颜色 | 含义 | GC 队列中的位置 |
|---|---|---|
| 白色 | 未被访问,可能为垃圾或待扫描的活对象 | 无 |
| 灰色 | 已被访问,但其子引用尚未扫描 | 工作队列 |
| 黑色 | 已被访问,且其所有子引用均已扫描,确认是活对象 | 无 |
写屏障 (Write Barriers / Mutator Barriers)
当增量标记正在进行时,JavaScript 线程(Mutator)仍然可以创建新对象、修改现有对象的字段。如果 Mutator 将一个黑色对象指向的引用修改为指向一个白色对象(而该白色对象是可达的,只是尚未被标记),那么这个白色对象就可能永远不会被标记到,最终被错误地回收。
为了防止这种情况,V8 引入了写屏障。写屏障是一小段代码,它在每次修改堆对象内部指针时被触发。它的任务是维护三色不变性。
写屏障的工作原理:
当 Mutator 线程执行类似 object.field = new_value 的操作时:
- 如果
object是黑色对象,new_value是白色对象。 - 写屏障检测到这个状态,并立即将
new_value染成灰色(加入 GC 的工作队列),或者将object重新染成灰色,以便 GC 重新扫描object。
最常见的策略是,如果被修改的字段指向了一个白色对象,则将这个白色对象添加到 GC 的灰色工作列表中。这样,GC 就能确保在后续的标记步骤中处理这个对象。
示例:简化版 StorePointerBarrier
// 假设这是V8内部的堆对象定义
class HeapObject {
public:
// ... 其他字段
uintptr_t gc_state_bits_; // 包含标记位等GC状态信息
// 获取对象当前的GC标记状态
bool IsWhite() const { return !(gc_state_bits_ & kMarkBitMask); }
bool IsGrey() const { return (gc_state_bits_ & kGreyBitMask) && !(gc_state_bits_ & kMarkBitMask); } // 简化示意,实际更复杂
bool IsBlack() const { return (gc_state_bits_ & kMarkBitMask) && !(gc_state_bits_ & kGreyBitMask); } // 简化示意
// 设置对象为灰色 (加入GC工作列表)
void MakeGrey() {
gc_state_bits_ |= kGreyBitMask; // 标记为灰色
// ... 将此对象加入GC的工作队列
g_gc_mark_worklist.Push(this);
}
// 假设这是V8内部用于设置对象字段的函数
void SetField(HeapObject** field_address, HeapObject* new_value) {
// 实际的内存写入操作
*field_address = new_value;
// --- 写屏障逻辑开始 ---
if (g_gc_is_marking_in_progress) { // 只有在GC标记进行时才需要写屏障
// `object` 指的是 `this` (即 field_address 所属的对象)
// `new_value` 是被引用的新对象
// 规则:如果一个黑色对象指向了一个白色对象,将白色对象染灰。
// 简化判断:如果`new_value`是白色,且`this`已经是黑色,则将`new_value`染灰。
// 实际V8的屏障更复杂,通常是检查被写入的指针是否指向新生代,或者是否是白色。
if (this->IsBlack() && new_value != nullptr && new_value->IsWhite()) {
new_value->MakeGrey(); // 将白色对象染灰,加入GC工作队列
}
}
// --- 写屏障逻辑结束 ---
}
};
写屏障引入了额外的运行时开销,因为每次指针写入都需要执行额外的检查。但这种开销通常很小且可预测,远低于 STW 暂停带来的影响。
工作机制详解
- GC 周期开始:当老年代内存分配达到阈值或新生代 GC 晋升了大量对象到老年代时,V8 会启动一个增量标记周期。
- 根对象扫描 (Initial Marking):增量标记从扫描根对象开始,将所有直接从根可达的对象标记为灰色,并加入到 GC 的工作列表中。这一步通常是 STW 的,但持续时间非常短。
- 增量标记步 (Incremental Steps):此后,V8 会在 JavaScript 执行的间隙或特定时间点(例如,在某个函数调用结束时,或者分配内存时)执行一小段标记工作。
- 从灰色工作列表中取出一个灰色对象。
- 将其标记为黑色。
- 遍历其所有引用的对象。如果被引用的对象是白色,则将其标记为灰色并加入工作列表。
- 重复此过程,直到完成预设的工作量或工作列表为空。
- 最终标记 (Final Marking):当增量标记接近完成时,会有一个最终的 STW 阶段。这个阶段会处理所有在增量标记期间被写屏障染灰的对象,并确保所有可达对象都被标记。由于大部分工作已在增量阶段完成,这个最终 STW 阶段也相对较短。
调度与暂停点
V8 有一个复杂的调度器来决定何时执行增量标记步。它通常会在以下时机触发:
- 内存分配时:每次分配一定量内存后,V8 可能会执行一小段增量标记。
- 空闲时间:浏览器环境中,当用户不活跃时,V8 会利用空闲时间执行更多 GC 工作。
- WebAssembly 执行时:由于 WebAssembly 无法被 GC 暂停,V8 会在其模块执行前后进行 GC 步进。
优势与代价
优势:
- 显著减少最大 STW 暂停时间:这是最主要的优势。通过将长时间的标记工作分解为小块,应用程序的响应能力大大提高。
- 更好的用户体验:减少卡顿,使动画更流畅,交互更及时。
代价:
- 写屏障的运行时开销:每次修改堆指针都需要执行额外的检查。
- 标记周期变长:由于标记工作是间歇性进行的,整个标记周期的时间会比一次性 STW 标记更长。
- 内存开销:需要额外的空间来存储标记位和工作队列。
- 浮动垃圾 (Floating Garbage):在增量标记周期内,某些对象可能在被标记为活对象后立即变得不可达。这些对象在当前周期内无法被回收,只能等到下一个 GC 周期。
并发清理 (Concurrent Sweeping):后台的静默工作者
增量标记解决了标记阶段的 STW 问题,但清除阶段仍然是一个问题。如果清除阶段仍然是 STW 的,那么增量标记带来的好处就会大打折扣。并发清理的目标是将清除工作从主线程转移到后台线程,使其与 JavaScript 执行并行进行,从而彻底消除清除阶段的 STW 暂停。
核心思想:将清除工作移至后台线程
在并发清理中,一旦标记阶段(增量标记和最终标记)完成,GC 已经识别出所有活对象和死对象。清除线程(Sweeper threads)可以在后台开始工作,遍历堆内存,回收死对象占用的空间,而主线程则可以立即恢复执行 JavaScript 代码。
V8 的堆内存组织
为了理解并发清理,我们需要了解 V8 堆内存的基本组织结构。V8 的老年代被划分为一系列的页面(Pages)。
MemoryChunk:是 V8 内存管理的基本单位,通常大小为 1MB。Page:MemoryChunk的一个子类,是 V8 堆中管理内存的逻辑单元。一个老年代页面包含一系列对象。PagedSpace:由多个Page组成,如老年代空间 (OldSpace)。
每个 Page 都有其自己的元数据,包括用于 GC 标记的标记位 (Mark Bits)。标记位通常存储在一个独立的位图中,每个位对应页面中的一个对象或一个固定大小的内存块。
在标记阶段,这些标记位会被设置,以指示页面中的哪些对象是活的。清除线程会读取这些标记位来判断哪些内存可以回收。
清理线程的工作流程
- 标记完成,清理开始:一旦标记阶段结束(所有活对象都被标记),V8 会启动或唤醒一个或多个后台清理线程。主线程随后可以立即恢复执行 JavaScript。
- 页面分配:清理线程会从一个共享队列中获取待清理的
Page。 - 扫描标记位:对于分配到的每个
Page,清理线程会遍历其所有对象(或固定大小的内存块),读取对应的标记位。- 如果对象的标记位被设置(活对象),则清除该标记位,为下一次 GC 做准备。
- 如果对象的标记位未被设置(死对象),则将其占用的内存区域添加到该页面的空闲列表 (Free List)中。
- 构建空闲列表:每个
Page都维护一个或多个空闲列表,用于管理其内部的可用内存块。清理线程将所有被回收的死对象空间合并成更大的空闲块,并将其组织到这些空闲列表中。当主线程需要分配内存时,会首先尝试从这些空闲列表中获取。 - 页面完成:当一个
Page被完全清理后,它就可以被认为“准备就绪”,其空闲列表可供主线程使用。
示例:简化版 SweepPage 逻辑
// 假设这是V8内部的内存页结构
class Page {
public:
// ... 页面元数据
MarkBitVector* mark_bits_; // 指向该页面的标记位图
FreeList* free_list_; // 该页面的空闲列表
HeapObject* GetFirstObject() { /* ... */ return nullptr; }
HeapObject* GetNextObject(HeapObject* current) { /* ... */ return nullptr; }
size_t GetObjectSize(HeapObject* obj) { /* ... */ return 0; }
void AddFreeSpace(Address start, size_t size) {
free_list_->Add(start, size); // 将空闲块添加到空闲列表
}
};
// 简化版并发清理线程函数
void ConcurrentSweeperThreadFunc() {
while (g_gc_is_sweeping_in_progress) {
Page* page_to_sweep = g_pending_sweep_pages.Pop(); // 从共享队列获取一个页面
if (page_to_sweep == nullptr) {
// 没有更多页面需要清理,等待或退出
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
Address current_address = page_to_sweep->GetFirstObjectAddress();
Address last_free_start = nullptr;
size_t last_free_size = 0;
while (current_address < page_to_sweep->GetEndOfPageAddress()) {
// 假设我们能从地址获取对象和大小
HeapObject* obj = reinterpret_cast<HeapObject*>(current_address);
size_t obj_size = page_to_sweep->GetObjectSize(obj);
if (page_to_sweep->mark_bits_->IsMarked(obj)) {
// 是活对象,清除标记位,准备下次GC
page_to_sweep->mark_bits_->ClearMark(obj);
// 如果之前有累积的空闲块,现在将其添加到空闲列表
if (last_free_start != nullptr) {
page_to_sweep->AddFreeSpace(last_free_start, last_free_size);
last_free_start = nullptr;
last_free_size = 0;
}
} else {
// 是死对象,累积空闲空间
if (last_free_start == nullptr) {
last_free_start = current_address;
}
last_free_size += obj_size;
}
current_address += obj_size;
}
// 处理页面末尾可能存在的空闲块
if (last_free_start != nullptr) {
page_to_sweep->AddFreeSpace(last_free_start, last_free_size);
}
// 标记页面为已清理完成
page_to_sweep->MarkAsSwept();
}
}
主线程与清理线程的协同
并发清理需要主线程和清理线程之间的紧密协同:
- 同步机制:
- 页面队列:主线程将所有需要清理的
Page提交到一个共享队列。清理线程从这个队列中取出Page进行处理。 - 完成通知:清理线程在完成一个
Page的清理后,会通知主线程该Page的空闲列表已准备好。
- 页面队列:主线程将所有需要清理的
- 内存分配与空闲列表的交互:
- 当主线程需要分配老年代内存时,它会首先尝试从那些已经完成清理的
Page的空闲列表中获取。 - 如果所有已清理的
Page都无法满足分配需求,并且还有Page正在被清理线程处理,主线程可能会短暂地“等待”某个清理线程完成其当前Page的清理,或者在极端情况下,主线程可能不得不自己执行一小部分清理工作以获取内存。 - 为了减少等待,V8 通常会预留一些“未清理”的内存,或者在并发清理开始时,主线程会自己快速清理少量页面以应对紧急分配。
- 当主线程需要分配老年代内存时,它会首先尝试从那些已经完成清理的
AllocationPage和SweepingPage:V8 可能会将页面分为正在被分配的页面和正在被清理的页面。主线程从AllocationPage分配,而清理线程处理SweepingPage。
优势与代价
优势:
- 彻底消除清除阶段的 STW 暂停:这是并发清理最重要的贡献。主线程可以在清除工作进行时持续执行 JavaScript。
- 提高应用程序响应速度:由于消除了清理阶段的停顿,应用程序在 GC 期间的整体流畅度大大提升。
代价:
- 复杂性增加:并发操作总是会引入线程同步、竞态条件等复杂性。
- 内存开销:在清理期间,旧的空闲列表(尚未被回收的内存)和新的空闲列表(已回收并组织好的内存)可能共存。为了避免主线程等待,可能还需要额外的内存缓冲区。
- CPU 资源消耗:清理线程需要额外的 CPU 核心来执行。虽然它们运行在后台,但仍然会与主线程竞争 CPU 资源,尤其是在 CPU 核心数量有限的设备上。
增量标记与并发清理的协同作用
增量标记和并发清理并非孤立存在,它们共同构成了 V8 强大且低延迟的老年代 GC 系统。它们是 V8 的 Orinoco 项目(一个旨在降低 GC 暂停时间的长期项目)的核心组成部分。
协同流程概览:
- 触发 GC:当老年代内存使用量达到阈值时,V8 决定启动老年代 GC 周期。
- 根对象扫描 (STW):主线程短暂暂停,扫描根对象,将直接可达的对象标记为灰色,并启动增量标记。
- 增量标记 (Incremental Marking):主线程恢复 JavaScript 执行。在 JavaScript 运行期间,GC 调度器会周期性地暂停主线程一小段,执行标记工作(从灰色对象工作队列中取出对象,标记为黑色,将其白色子对象染灰)。写屏障在此阶段至关重要,它确保在 Mutator 修改对象引用时,三色不变性得以维护。
- 并发标记 (Concurrent Marking – 补充说明):值得一提的是,V8 进一步优化了增量标记,引入了并发标记。这意味着除了主线程间歇性地执行标记步外,还会有后台线程与主线程并行地执行大部分标记工作。这进一步减少了主线程在标记阶段的停顿时间。
- 最终标记 (Final Marking – STW):当大部分标记工作完成时,主线程会短暂暂停,执行最终标记,处理剩余的灰色对象,并确保所有可达对象都被标记。这是标记阶段的最后一个 STW 暂停。
- 并发清理 (Concurrent Sweeping):最终标记一结束,主线程立即恢复 JavaScript 执行。此时,一个或多个后台清理线程开始工作,它们遍历标记好的页面,回收死对象内存,并将其添加到各自页面的空闲列表中。这个过程与 JavaScript 执行是并行的,因此对主线程没有 STW 暂停。
- 并发整理 (Concurrent Compaction – 补充说明):如果需要整理,V8 也会尝试以并发或并行的方式进行。例如,它可能会使用并行整理(多个线程同时移动不同区域的对象)或并发整理(在移动对象时,使用读屏障和转发表来处理主线程对旧地址的访问)。
代码示例:GC 流程简化图示
// 简化GC控制器
class V8_GC_Controller {
public:
enum GCState {
IDLE,
INCREMENTAL_MARKING,
FINAL_MARKING,
CONCURRENT_SWEEPING,
COMPACTING
};
GCState current_state_ = IDLE;
std::vector<Page*> pages_to_sweep_;
std::vector<std::thread> sweeper_threads_;
void StartOldGenGC() {
if (current_state_ != IDLE) return;
// 1. 初始标记 (STW)
current_state_ = INCREMENTAL_MARKING;
// GlobalPauseJSExecution(); // 模拟JS暂停
ScanRootsForInitialMarking();
// ResumeJSExecution(); // 模拟JS恢复
// 启动后台并发标记线程 (如果有)
// 2. 调度增量标记步 (穿插在JS执行中)
// JS执行时,会周期性调用PerformIncrementalMarkingStep()
}
void PerformIncrementalMarkingStep() {
if (current_state_ == INCREMENTAL_MARKING) {
// GlobalPauseJSExecutionForShortPeriod(); // 模拟短暂停
// ... 处理一小部分标记工作 (从mark_worklist中取出对象标记)
// ResumeJSExecution();
if (MarkingIsAlmostComplete()) {
FinalizeMarking();
}
}
}
void FinalizeMarking() {
// 3. 最终标记 (STW)
current_state_ = FINAL_MARKING;
// GlobalPauseJSExecution(); // 模拟JS暂停
// ... 完成所有剩余的标记工作
// CollectAllPagesForSweeping(); // 收集所有待清理的页面
// ResumeJSExecution(); // 模拟JS恢复
// 4. 启动并发清理
current_state_ = CONCURRENT_SWEEPING;
for (int i = 0; i < kNumSweeperThreads; ++i) {
sweeper_threads_.emplace_back(ConcurrentSweeperThreadFunc);
}
}
void StopConcurrentSweeping() {
// ... 当所有页面清理完成时
// JoinSweeperThreads();
current_state_ = IDLE;
}
// 主线程内存分配函数可能会检查空闲列表,如果不足则可能等待或触发清理
HeapObject* AllocateOldSpaceObject(size_t size) {
// 尝试从已清理页面的空闲列表分配
// 如果不足,可能触发清理线程加速,或主线程进行少量清理
// ...
return nullptr;
}
};
// 全局变量来模拟GC状态
bool g_gc_is_marking_in_progress = false;
MarkWorklist g_gc_mark_worklist;
bool g_gc_is_sweeping_in_progress = false;
PageQueue g_pending_sweep_pages;
// 在主线程的循环中:
// while (true) {
// ExecuteJavaScript();
// v8_gc_controller.PerformIncrementalMarkingStep(); // 周期性执行增量标记
// // ... 可能有其他任务
// }
通过这种协同工作,V8 极大地降低了老年代 GC 对主线程的干扰,将大部分耗时工作分散到多个小步或后台线程中。
性能考量、权衡与工程实践
增量标记和并发清理虽然带来了显著的性能提升,但它们并非没有代价。任何复杂的系统设计都涉及到权衡。
吞吐量与延迟的平衡
- 延迟 (Latency):指 GC 暂停的最长时间。增量标记和并发清理的目标就是最小化这个值,从而提高应用程序的响应性。
- 吞吐量 (Throughput):指单位时间内完成的有效工作量。增量和并发 GC 通常会增加 GC 的总 CPU 开销(例如,写屏障的额外指令,线程同步的开销,以及可能更长的 GC 周期),这可能略微降低整体的 JavaScript 执行吞吐量。
V8 在设计上优先考虑了低延迟,因为对于用户交互性强的应用来说,感知到的卡顿比微小的吞吐量下降更重要。
内存与 CPU 资源消耗
- 内存开销:
- 标记位图:需要额外的内存来存储所有对象的标记状态。
- GC 工作队列:灰色对象需要存储在队列中。
- 浮动垃圾:增量标记期间可能产生浮动垃圾,导致在当前 GC 周期结束时堆内存使用量略高于 STW GC。
- 空闲列表:并发清理期间,可能需要额外的空间来管理空闲列表,或者在清理完成前保留旧的空闲结构。
- CPU 开销:
- 写屏障:每次指针写入的少量额外指令。
- 多线程管理:启动、同步和调度清理线程需要 CPU 资源。
- 上下文切换:主线程和 GC 线程之间的频繁切换可能带来开销。
这些额外的开销是为换取低延迟所付出的代价。现代 CPU 通常拥有多个核心,使得并发清理能够有效利用空闲核心,减少对主线程的竞争。
V8 配置选项对 GC 的影响
V8 提供了许多命令行标志,允许开发者和研究人员观察或调整 GC 行为。例如:
--expose-gc:在 JavaScript 中暴露gc()函数,允许手动触发 GC(主要用于测试)。--print-gc-details:打印详细的 GC 日志,包括暂停时间、内存使用量等。--trace-gc:更详细的 GC 跟踪信息。--trace-mark-compaction:跟踪标记和整理过程。
通过这些工具,开发者可以深入了解自己的应用在 V8 GC 下的行为,并进行优化。例如,频繁创建大量短生命周期对象可能导致新生代 GC 频繁,进而可能触发老年代 GC。通过优化数据结构和算法,减少不必要的对象创建,可以有效减轻 GC 压力。
持续演进
V8 垃圾回收系统是一个不断演进的领域。从最初的 STW 标记-清除,到引入增量标记和并发清理,V8 团队始终致力于降低 GC 暂停时间,提高 JavaScript 应用程序的性能和响应性。
增量标记和并发清理是 V8 GC 发展至今的两个关键里程碑,它们将老年代的回收从一个长时间的停顿事件,转化为了一个由短暂停和后台工作组成的、对用户体验影响极小的过程。这些技术以及后续的并发标记、并发整理等,共同奠定了现代高性能 JavaScript 运行时的基础,使得 JavaScript 能够在浏览器、服务器乃至桌面应用等各种场景下,提供流畅、高效的用户体验。V8 GC 的未来仍将继续探索更智能的调度、更高效的内存管理以及更少的资源消耗,以适应日益增长的复杂应用需求。