V8 垃圾回收

2,293 阅读6分钟
原文链接: alinode.aliyun.com

垃圾回收器是一把十足的双刃剑。好处是简化程序的内存管理,内存管理无需程序员来操作,由此也减少了长时间运转的程序的内存泄漏。然而无法预期的停顿,影响了交互体验。本文从 V8 (node.js runtime) 的角度分析垃圾回收策略。

基本概念

垃圾回收器解决基本问题就是,识别需要回收的内存。一旦辨别完毕,这些内存区域即可在未来的分配中重用,或者是返还给操作系统。一个对象当它不是处于活跃状态的时候它就死了。一个对象处于活跃状态,当且仅当它被一个根对象或另一个活跃对象指向。根对象被定义为处于活跃状态,是浏览器或V8所引用的对象。比如说全局对象属于根对象,因为它们始终可被访问;浏览器对象,如DOM元素,也属于根对象,尽管在某些场合下它们只是弱引用。

堆的构成

在深入研究垃圾回收器的内部工作原理之前,首先来看看堆是如何组织的。V8将堆分为了几个不同的区域: 2015-11-17 下午3.09.08

新生区:大多数对象开始时被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。

老生指针区:包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活一段时间之后的对象都会被挪到这里。

老生数据区:这里存放只包含原始数据的对象(这些对象没有指向其他对象的指针)。字符串、封箱的数字以及未封箱的双精度数字数组,在新生区经历一次 Scavenge 后会被移动到这里。

大对象区:这里存放体积超过1MB大小的对象。每个对象有自己mmap产生的内存。垃圾回收器从不移动大对象。

Code区:代码对象,也就是包含JIT之后指令的对象,会被分配到这里。

Cell区、属性Cell区、Map区:这些区域存放Cell、属性Cell和Map,每个区域因为都是存放相同大小的元素,因此内存结构很简单。

如上图:在 node-v4.x 之后,区域进行了合并为:新生区,老生区,大对象区,Map区,Code区

有了这些背景知识,我们可以来深入垃圾回收器了。

识别指针

垃圾回收器面临的第一个问题是,如何才能在堆中区分指针和数据,因为指针指向着活跃的对象。大多数垃圾回收算法会将对象在内存中挪动(以便减少内存碎片,使内存紧凑),因此即使不区分指针和数据,我们也常常需要对指针进行改写。 V8采用了标记指针法:这种方法需要在每个指针的末位预留一位来标记这个字代表的是指针或数据。

写屏障

如果新生区中某个对象,只有一个指向它的指针,而这个指针恰好是在老生区的对象当中,我们如何才能知道新生区中那个对象是活跃的呢? 为了解决这个问题,实际上在写缓冲区中有一个列表 store-buffer{.cc,.h,-inl.h},列表中记录了所有老生区对象指向新生区的情况。新对象诞生的时候,并不会有指向它的指针,而当有老生区中的对象出现指向新生区对象的指针时,我们便记录下来这样的跨区指向。由于这种记录行为总是发生在写操作时,它被称为写屏障.

垃圾回收三部曲

Stop-the-World 的GC包括三个主要步骤:

  1. 枚举根节点引用;
  2. 发现并标记活对象;
  3. 垃圾内存清理

分代回收在 V8中分为Scavenge, Mark-Sweep

  • Scavenge: 当分配指针达到了新生区的末尾,就会有一次清理。
  • Mark-Sweep: 对于活跃超过2个小周期的对象,则需将其移动至老生区, 当老生区有足够多的对象时才会触发。

Scavenge

void Heap::Scavenge() {
  RelocationLock relocation_lock(this);

  AlwaysAllocateScope scope(isolate());

  gc_state_ = SCAVENGE;

  // Clear descriptor cache.
  isolate_->descriptor_lookup_cache()->Clear();

  // Used for updating survived_since_last_expansion_ at function end.
  intptr_t survived_watermark = PromotedSpaceSizeOfObjects();

  SelectScavengingVisitorsTable();

  PrepareArrayBufferDiscoveryInNewSpace();

  // Flip the semispaces.  After flipping, to space is empty, from space has
  // live objects.
  new_space_.Flip(); // 交换 SemiSpace
  new_space_.ResetAllocationInfo(); // 重设分配指针


  Address new_space_front = new_space_.ToSpaceStart();
  promotion_queue_.Initialize(); // 晋升队列

  ScavengeVisitor scavenge_visitor(this); // Scavenge迭代器
  // Copy roots. 枚举跟对象
  IterateRoots(&scavenge_visitor, VISIT_ALL_IN_SCAVENGE); 

  // Copy objects reachable from the old generation.
  {
    StoreBufferRebuildScope scope(this, store_buffer(),
                                  &ScavengeStoreBufferCallback);
    store_buffer()->IteratePointersToNewSpace(&ScavengeObject);
  }

  // Copy objects reachable from the encountered weak collections list.
  scavenge_visitor.VisitPointer(&encountered_weak_collections_);
  // Copy objects reachable from the encountered weak cells.
  scavenge_visitor.VisitPointer(&encountered_weak_cells_);

  // Copy objects reachable from the code flushing candidates list.
  MarkCompactCollector* collector = mark_compact_collector();
  if (collector->is_code_flushing_enabled()) {
    collector->code_flusher()->IteratePointersToFromSpace(&scavenge_visitor);
  }
  // DoScavenge 处理晋升
  new_space_front = DoScavenge(&scavenge_visitor, new_space_front);

  while (isolate()->global_handles()->IterateObjectGroups(
      &scavenge_visitor, &IsUnscavengedHeapObject)) {
    new_space_front = DoScavenge(&scavenge_visitor, new_space_front);
  }
  isolate()->global_handles()->RemoveObjectGroups();
  isolate()->global_handles()->RemoveImplicitRefGroups();

  isolate()->global_handles()->IdentifyNewSpaceWeakIndependentHandles(
      &IsUnscavengedHeapObject);

  isolate()->global_handles()->IterateNewSpaceWeakIndependentRoots(
      &scavenge_visitor);
  new_space_front = DoScavenge(&scavenge_visitor, new_space_front);

  UpdateNewSpaceReferencesInExternalStringTable(
      &UpdateNewSpaceReferenceInExternalStringTableEntry);

  promotion_queue_.Destroy();

  incremental_marking()->UpdateMarkingDequeAfterScavenge();

  ScavengeWeakObjectRetainer weak_object_retainer(this);
  ProcessYoungWeakReferences(&weak_object_retainer);

  DCHECK(new_space_front == new_space_.top());

  // Set age mark.
  new_space_.set_age_mark(new_space_.top());

  new_space_.LowerInlineAllocationLimit(
      new_space_.inline_allocation_limit_step());

  FreeDeadArrayBuffers(true);

  // Update how much has survived scavenge.
  IncrementYoungSurvivorsCounter(static_cast(
      (PromotedSpaceSizeOfObjects() - survived_watermark) + new_space_.Size()));

  gc_state_ = NOT_IN_GC;
}

这个算法大致是,新生区被划分为两个等大的SemiSpace:出区、入区。绝大多数内存的分配都会在出区发生(但某些特定类型的对象,如可执行的代码对象是分配在老生区的),当出区耗尽时,我们交换出区和入区(这样所有的对象都归属在入区当中),然后将入区中活跃的对象复制至出区或晋升到老生区中,其中标记的过程实际是深度优先搜索。

“标记-清除”算法

void MarkCompactCollector::CollectGarbage() {

  DCHECK(state_ == PREPARE_GC);

  MarkLiveObjects(); //枚举并标记活对象

  DCHECK(heap_->incremental_marking()->IsStopped());

  // ClearNonLiveReferences can deoptimize code in dependent code arrays.
  // Process weak cells before so that weak cells in dependent code
  // arrays are cleared or contain only live code objects.
  ProcessAndClearWeakCells();

  ClearNonLiveReferences();

  ClearWeakCollections();

  heap_->set_encountered_weak_cells(Smi::FromInt(0));

  ClearInvalidStoreAndSlotsBufferEntries();

  SweepSpaces(); // 按不同的空间划分清理

  Finish();

  if (marking_parity_ == EVEN_MARKING_PARITY) {
    marking_parity_ = ODD_MARKING_PARITY;
  } else {
    DCHECK(marking_parity_ == ODD_MARKING_PARITY);
    marking_parity_ = EVEN_MARKING_PARITY;
  }
}

Scavenge算法对于快速回收、紧缩小片内存效果很好,但对于大片内存则消耗过大。频繁的拷贝对于 CPU 是不可承受之重。老生区包含有上百MB的数据,对于这么大的区域,我们采取“标记-清除”算法与“标记-紧缩”算法。

标记算法执行完毕后,我们可以选择清理或是紧缩,这两个算法都可以回收内存。

  • 清理算法非常简单,只需遍历页的位图,搜索连续的死对象释放,时间久了会形成内存碎片。
  • 紧缩算法会尝试将对象从碎片页(包含大量小空闲内存的页)中迁移整合在一起,来释放内存。这些对象会被迁移到另外的页上,因此也可能会新分配一些页。alinode 对此策略进行了优化,使用 alinode runtime 即可享受到。

总结

GC

垃圾回收非常复杂,alinode 提供了详细的 GC 监控,帮助您分析把控性能。