Scavenging the Young Generation: How V8 Copies Live Objects in Milliseconds

V8's scavenger copies the live young generation from one semi-space to another with Cheney's algorithm, using a forwarding-address CAS so parallel scavengers can race safely.

TL;DR: V8’s young generation is two semi-spaces. A scavenge copies every live object from the from-space into the to-space with Cheney’s copying algorithm, then swaps them. The copy is what makes the collection cheap: no marking, no free lists - dead objects are simply not copied. The trick that makes it parallel-safe is a relaxed compare-and-swap on the object’s map word: whoever wins the CAS owns the copy, and the losers read the winner’s forwarding address out of the same word.

JavaScript allocates like a program that never frees anything, and the heap grows and shrinks as objects die. V8 does not want to run a general-purpose collector every few milliseconds - the pause would swamp the program.

So V8 splits the problem in two. Freshly allocated objects are cheap to reclaim: if almost nothing is going to survive, the fastest way to find the garbage is not to find it at all, but to copy the survivors out of the way and let the rest evaporate. That is the scavenger, and it is why a young collection in V8 is measured in single-digit milliseconds.

Two semi-spaces and a swap

cpp
// src/heap/new-spaces.h:35
enum SemiSpaceId { kFromSpace = 0, kToSpace = 1 };

// -----------------------------------------------------------------------------
// SemiSpace in young generation
//
// A SemiSpace is a contiguous chunk of memory holding page-like memory chunks.
// The mark-compact collector  uses the memory of the first page in the from
// space as a marking stack when tracing live objects.

The mutator allocates into the to-space. When the scavenge starts, the two are swapped, and the to-space becomes the from-space:

cpp
// src/heap/new-spaces.cc:668
void SemiSpaceNewSpace::SwapSemiSpaces() {
  // Flip the semispaces. After flipping, to space is empty and from space has
  // live objects.
  SemiSpace::Swap(&from_space_, &to_space_);
  to_space_.Reset();
  from_space_.Reset();
  quarantined_size_ = 0;
  allocation_top_ = kNullAddress;
  DCHECK_EQ(0u, Size());

Swapping two pointers is the whole “flip”. There is no per-object work, no free list to walk, no mark bits to clear. After the swap the to-space is empty and ready for the mutator, and the from-space holds a contiguous block of objects of which, typically, most are garbage.

The scavenge is a one-liner in the heap:

cpp
// src/heap/heap.cc:2617
void Heap::Scavenge() {
  DCHECK_NOT_NULL(new_space());
  DCHECK(!incremental_marking()->IsMarking());

  TRACE_GC(tracer(), GCTracer::Scope::SCAVENGER_SCAVENGE);
  SetGCState(SCAVENGE);

  // Implements Cheney's copying algorithm
  scavenger_collector_->CollectGarbage();

  SetGCState(NOT_IN_GC);
}

The paging allocator

The to-space is not one big contiguous allocation region. It is a list of pages, and the allocator is a bump pointer that spills onto a new page when it runs out - filling the remainder of the old page with a filler object so that the scavenger’s page walker can still find boundaries:

cpp
// src/heap/new-spaces.cc:722
std::optional<std::pair<Address, Address>> SemiSpaceNewSpace::Allocate(
    int size_in_bytes, AllocationAlignment alignment) {
  size_in_bytes = ALIGN_TO_ALLOCATION_ALIGNMENT(size_in_bytes);
  Address top = allocation_top();
  if (top) {
    DCHECK_SEMISPACE_ALLOCATION_TOP(allocation_top(), to_space_);
    Address high = to_space_.page_high();
    int filler_size = MainAllocator::GetFillToAlign(top, alignment);
    int aligned_size_in_bytes = size_in_bytes + filler_size;

    if (top + aligned_size_in_bytes <= high) {
      IncrementAllocationTop(high);
      return std::pair(top, high);
    }

    int remaining_in_page = static_cast<int>(high - top);
    heap()->CreateFillerObjectAt(top, remaining_in_page);
    SetAllocationTop(high);

The mutator allocates from the top of the current page; when the page runs out, the tail is filled with a filler object and a fresh page is appended.

There is a second, subtler mechanism in the same function: allocation buffer parking. When the mutator’s linear allocation buffer does not fill a page, the leftover tail is parked so a later allocation can reuse it, as long as the tail is at least 4 KB (kAllocationBufferParkingThreshold, new-spaces.h). Parking is only allowed outside a GC - during a collection the space belongs to the scavenger.

During the GC the allocator plays a different game. Because several scavenger threads allocate into the same to-space concurrently, the linear allocation buffer handed to each thread has to be limited so that two threads never think they own the same bytes:

cpp
// src/heap/main-allocator.cc:501
  if (allocator_->in_gc()) {
    // During GC we allow multiple LABs in new space and since Allocate() above
    // returns the whole remaining page by default, we limit the size of the LAB
    // here.
    size_t used = std::max(aligned_size_in_bytes, kLabSizeInGC);
    limit = std::min(end, start + used);

Cheney’s algorithm: the worklists

Cheney’s copying algorithm is the classic answer to “copy the live set without marking it”. The scavenger implements it with two worklists - one for objects copied into the to-space, one for objects promoted to old space

  • and the objects on those worklists are the grey set. The root scanner supplies the initial work:
cpp
// src/heap/scavenger.cc:280
class Scavenger {
 public:
  static constexpr int kScavengedObjectListSegmentSize = 256;

  struct ScavengedObjectListEntry {
    Tagged<HeapObject> heap_object;
    Tagged<Map> map;
    SafeHeapObjectSize size;
  };

  using ScavengedObjectList =
      ::heap::base::Worklist<ScavengedObjectListEntry,
                             kScavengedObjectListSegmentSize>;

The worklist is a segmented worklist of 256-entry chunks, and each scavenger thread has a local one plus a shared global one, so threads do not contend on a single queue.

The main loop drains both worklists, and the copies it makes feed back into the worklist - that is the whole algorithm:

cpp
// src/heap/scavenger.cc:2538
void Scavenger::Process(JobDelegate* delegate) {
  ScavengerCopiedObjectVisitor copied_object_visitor(this);
  ScavengerPromotedObjectVisitor promoted_object_visitor(this);

  bool done;
  size_t objects = 0;
  do {
    done = true;
    ScavengedObjectListEntry entry;
    while (!ShouldEagerlyProcessPromotedList() &&
           local_copied_list_.Pop(&entry)) {
      copied_object_visitor.Visit(entry.map, entry.heap_object, entry.size);
      done = false;
      if (delegate && ((++objects % kInterruptThreshold) == 0)) {
        if (!local_copied_list_.IsGlobalEmpty()) {
          delegate->NotifyConcurrencyIncrease();
        }
      }
    }

The visitor that pops an entry from the copied list walks the object’s fields, and each field that points into the from-space triggers another copy - which pushes onto the copied list. The loop ends when both lists are empty. No marking bit is ever set on a young object.

The ShouldEagerlyProcessPromotedList check is a scheduling heuristic: if the promoted list is getting long, it is drained first, because promoted objects live in old space and their fields may point at old-to-old references that other threads need.

The forwarding CAS

When the scavenger copies an object, it cannot just copy it - another thread might be copying the same object at the same time, because two different fields can point at it. V8 solves this by using the object’s map word - the word that normally holds the pointer to its map - as a forwarding address. The first thread to win a compare-and-swap owns the copy; the others read the winner’s address out of the same word:

cpp
// src/heap/scavenger.cc:1972
  // This CAS can be relaxed because we do not access the object body if the
  // object was already copied by another thread. We only access the page header
  // of such objects and this is safe because of the memory fence after page
  // header initialization.
  if (!source->relaxed_compare_and_swap_map_word_forwarded(
          MapWord::FromMap(map), target)) {
    // Other task migrated the object.
    allocator_.FreeLast(space, target, object_size);
    const MapWord map_word = source->map_word(kRelaxedLoad);
    UpdateHeapObjectReferenceSlot(slot, map_word.ToForwardingAddress(source));
    SynchronizePageAccess(*slot);
    DCHECK(!Heap::InFromPage(*slot));
    return true;
  }

  // Copy the content of source to target. Note that we do this on purpose
  // *after* the CAS. This avoids copying of the object in the (unlikely)
  // failure case. It also helps us to ensure that we do not rely on non-relaxed
  // memory ordering for the CAS above.
  target->set_map_word(map, kRelaxedStore);
  Heap::CopyBlock(target.address() + kTaggedSize,
                  source.address() + kTaggedSize,
                  SafeHeapObjectSize(object_size.value() - kTaggedSize));

Three things to notice. The CAS is relaxed, because a thread that loses the race never touches the object body - it only reads the page header, which is protected by the memory fence after page header initialisation. The copy happens after the CAS, so a losing thread never does a wasted memcpy. And the losing thread still has to fix up its slot: it reads the forwarding address the winner wrote and points its slot there. That last point is the crux - after the CAS, every thread that finds the object can find its new address.

Promotion: the age mark

Not everything is copied into the to-space. Objects that are old enough get promoted to old space instead, so the young generation does not keep re-copying the same long-lived objects forever.

The age is tracked by an age mark: a single address separating the part of the space holding objects that already survived a collection from the part holding fresh allocations.

cpp
// src/heap/new-spaces-inl.h:92
bool SemiSpaceNewSpace::ShouldBePromoted(Address object) const {
  return IsAddressBelowAgeMark(object);
}

The check is per-page, using a page flag, so that it does not need a metadata table lookup:

cpp
// src/heap/new-spaces-inl.h:80
  if (!chunk->InNewSpaceBelowAgeMark()) {
    return false;
  }

  const Address age_mark = age_mark_;
  const bool on_age_mark_page =
      chunk->address() < age_mark &&
      age_mark <= chunk->address() + NormalPage::kPageSize;
  DCHECK_EQ(chunk->Metadata()->ContainsLimit(age_mark), on_age_mark_page);
  return !on_age_mark_page || address < age_mark;

The age mark is set at the end of every collection, to the top of the survivor region:

cpp
// src/heap/new-spaces.cc:429
void SemiSpaceNewSpace::SetAgeMarkAndBelowAgeMarkPageFlags() {
  age_mark_ = allocation_top();

  NormalPage* current_page = to_space().first_page();

  for (size_t i = 0; i < to_space_.quarantined_pages_count_; i++) {
    DCHECK_NOT_NULL(current_page);
    current_page->SetFlagNonExecutable(MemoryChunk::NEW_SPACE_BELOW_AGE_MARK);
    current_page = current_page->next_page();
  }

So the sequence is: the survivors of collection N are copied into the bottom of the to-space; the age mark is placed at the top of that survivor region; fresh allocations then fill the space above it. At collection N+1 the from-space contains survivors (below the mark) and fresh objects (above it), and the ones below the mark are promoted.

In other words: survive one scavenge, and you are promoted.

The three-way fallback

The decision to copy or promote is not a simple either/or. EvacuateObjectDefault has three attempts, in order:

cpp
// src/heap/scavenger.cc:2126
  if (!ShouldBePromoted(object.address())) {
    // A semi-space copy may fail due to fragmentation. In that case, we
    // try to promote the object.
    if (SemiSpaceCopyObject(map, slot, object, object_size, object_fields))
        [[likely]] {
      return RememberedSetEntryNeeded(heap_, slot);
    }
  }

  // We may want to promote this object if the object was already semi-space
  // copied in a previous young generation GC or if the semi-space copy above
  // failed.
  if (PromoteObject<THeapObjectSlot, promotion_heap_choice>(
          map, slot, object, object_size, object_fields)) [[likely]] {
    return RememberedSetEntryNeeded(heap_, slot);
  }

Copy first, promote second, copy again third, and only then give up. The reason is fragmentation: the to-space may not have a contiguous run of the right size, in which case promotion into old space (which has free lists and different size classes) succeeds where the copy failed.

The three attempts also differ in what they record. A copied object is pushed onto the copied worklist; a promoted object onto the promoted worklist; and both update the pretenuring feedback:

cpp
// src/heap/scavenger.cc:2008
bool Scavenger::SemiSpaceCopyObject(Tagged<Map> map, THeapObjectSlot slot,
                                    Tagged<HeapObject> object,
                                    SafeHeapObjectSize object_size,
                                    ObjectFields object_fields) {
  static_assert(std::is_same_v<THeapObjectSlot, FullHeapObjectSlot> ||
                    std::is_same_v<THeapObjectSlot, HeapObjectSlot>,
                "Only FullHeapObjectSlot and HeapObjectSlot are expected here");
  DCHECK(heap()->AllowedToBeMigrated(map, object, NEW_SPACE));
  return TryMigrateObject(
      map, slot, object, object_size, NEW_SPACE,
      [this, map, object_fields, object,
       object_size](Tagged<HeapObject> target) {
        PretenuringHandler::UpdateAllocationSite(
            heap_, map, object, object_size, &local_pretenuring_feedback_);
        copied_size_ += object_size.value();
        if (object_fields == ObjectFields::kMaybePointers) {
          local_copied_list_.Push({target, map, object_size});
        }
      });
}

Note the ObjectFields::kMaybePointers check: an object with no pointers in it - a number, a boolean - does not need to be on a worklist, because there is nothing to trace from it.

Pretenuring: allocation-site feedback

The PretenuringHandler::UpdateAllocationSite call above is more than bookkeeping. When V8 sees that objects allocated at a particular allocation site keep surviving, it can allocate them directly in old space next time, so they never have to be scavenged at all. The scavenger is where that evidence is gathered: it knows which objects survived and how big they were, and merges the feedback into the heap’s pretenuring handler at the end of the collection. The young collector is also the profiler for the old one.

Special cases: large objects and strings

Large objects. Objects too big for a page live in a separate “new large object space”, which is also flipped at the start of the scavenge. They are never copied - they are promoted immediately, by writing a self-forwarding map word:

cpp
// src/heap/scavenger.cc:2052
bool Scavenger::HandleLargeObject(Tagged<Map> map, Tagged<HeapObject> object,
                                  SafeHeapObjectSize object_size,
                                  ObjectFields object_fields) {
  // Quick check first: A large object is the first (and only) object on a data
  // page (code pages have a different offset but are never young). This check
  // avoids the page flag access.
  if (MemoryChunk::AddressToOffset(object.address()) !=
      MemoryChunkLayout::ObjectStartOffsetInDataPage()) [[likely]] {

Strings. Three string cases are special, and all are about avoiding a copy:

  • A thin string points at an internalized string that is always in old space, so the scavenger points the slot at the target and drops the thin string.
  • A cons string whose second half is empty is a “shortcut candidate” - already flattened. The scavenger points the slot at the first half and drops the cons string:
cpp
// src/heap/scavenger.cc:2186
  if (shortcut_strings_ &&
      object->unchecked_second() == ReadOnlyRoots(heap()).empty_string()) {
    Tagged<HeapObject> first = Cast<HeapObject>(object->unchecked_first());

    UpdateHeapObjectReferenceSlot(slot, first);
  • A string that can be internalized in place is treated specially so that internalization does not have to copy the characters.

The “shortcut” trick is a nice illustration of the copying collector’s second-order effects: by not copying a flattened cons string and instead splicing it out, the scavenger collapses a two-object rope into one.

The parallel scavenge

The scavenge is not single-threaded, even though it is a stop-the-world pause. The main thread scans the roots while worker threads process the old-to-new remembered set - the pages of old space that may point into the young generation. The job is posted before the root scan starts, and the main thread participates while it scans:

cpp
// src/heap/scavenger.cc:1737
    std::unique_ptr<JobHandle> job_handle = V8::GetCurrentPlatform()->PostJob(
        v8::TaskPriority::kUserBlocking, std::move(job));

    // Iterate roots on the main thread while background threads scavenge pages.
    {
      // Copy roots.
      TRACE_GC(heap_->tracer(), GCTracer::Scope::SCAVENGER_SCAVENGE_ROOTS);

The number of scavenger tasks is derived from the size of the young generation and the number of cores, capped at eight:

cpp
// src/heap/scavenger.cc:1579
int NumberOfScavengeTasks(Heap* heap) {
  // The maximum number of scavenger tasks including the main thread. The actual
  // number of tasks is determined at runtime.
  static constexpr int kMaxScavengerTasks = 8;

  if (!v8_flags.parallel_scavenge) {
    return 1;
  }
  const int num_scavenge_tasks =
      static_cast<int>(
          SemiSpaceNewSpace::From(heap->new_space())->TotalCapacity()) /
          MB +
      1;

Each task gets its own Scavenger object with its own local worklists, and they publish into shared global worklists at the end. The ScavengerJobTask also computes the desired concurrency dynamically - if the work turns out bigger than the number of pages suggests, it asks the job system for more workers.

One detail worth noting: the scavenger explicitly asserts that incremental marking is not running:

cpp
// src/heap/heap.cc:2619
  DCHECK(!incremental_marking()->IsMarking());

So the classic scavenger and the concurrent marker do not run at the same time. They interleave across collections instead: after a young collection, if the old generation has consumed enough of its allocation limit, V8 starts an incremental marking cycle, which then runs with the mutator until the next collection.

Growing and shrinking the young generation

The young generation is not a fixed size. V8 grows it when a lot survives and shrinks it when the mutator is idle.

cpp
// src/heap/heap.cc:3684
  const bool should_grow =
      (new_space_->TotalCapacity() < new_space_->MaximumCapacity()) &&
      (survived_since_last_expansion_ > new_space_->TotalCapacity());

  if (should_grow) survived_since_last_expansion_ = 0;

  if (should_grow == should_shrink) return ResizeNewSpaceMode::kNone;
  return should_grow ? ResizeNewSpaceMode::kGrow : ResizeNewSpaceMode::kShrink;
}

The growth rule is the interesting one: grow only if the surviving bytes since the last expansion exceed the whole current capacity. V8 doubles the young generation only when the survivors are comparable in size to the entire space - a sign that the workload is allocating long-lived objects and would benefit from a bigger nursery. Growth is by a factor of two (semi_space_growth_factor, default 2).

The shrink rule fires when the allocation throughput drops below 1000 bytes per millisecond - the mutator is idle - and keeps at least twice the currently-used size, floored at the minimum capacity. The GC scheduler decides when any of this runs.

The bounds come from the physical memory size:

cpp
// src/heap/heap.cc:4824
// static
size_t Heap::DefaultMinSemiSpaceSize() {
  return RoundUp(512 * KB, NormalPage::kPageSize);
}

// static
size_t Heap::DefaultMaxSemiSpaceSize(uint64_t physical_memory) {
  if (v8_flags.minor_ms) {
    static constexpr size_t kMinorMsMaxCapacity = 72 * MB;
    return RoundUp(kMinorMsMaxCapacity, NormalPage::kPageSize);
  }

So a semi-space starts at 512 KB and can grow to 32 MB (72 MB with the minor mark-sweep collector, 8 MB on low-end Android).

Trade-offs

What it buys. A young collection is a copy of the live set and nothing else. No marking, no sweeping, no free-list maintenance. The cost is proportional to what survives, not to what was allocated, and the swap that starts the collection is two pointer writes.

What it costs.

  • Copying is not free. Every survivor is memcpy’d. A workload where most objects survive has paid twice: once to copy, once to collect.
  • Fragmentation. The to-space is a bump allocator, so a large object can fail to fit and force a promotion. The three-way fallback in EvacuateObjectDefault exists because of this.
  • Promotion is not always right. An object promoted too early occupies old space, where the mark-compact collector will trace it forever.
  • The pause is still a pause. The scavenger is a stop-the-world event, even though its work is parallelised.

The bottom line

The scavenger is a copying collector with a two-pointer flip. Live young objects are copied from the from-space into the to-space with Cheney’s algorithm; dead ones are not copied, and the whole from-space becomes free memory in one pointer swap.

The two ideas that make it work in practice are the forwarding CAS on the map word - which lets several threads copy concurrently without locks and without double-copying - and the age mark, which decides when an object has earned a place in old space.

The mental model: the young generation is a nursery, and the scavenger moves the survivors into bigger pots.

The thing people get wrong

“The scavenger is a stop-the-world pause, so it is expensive.” It is a stop-the-world pause, but the work inside it is proportional to the live set and is spread across up to eight threads while the main thread scans roots. A young collection in V8 is typically single-digit milliseconds.

The opposite misconception is also common: “the scavenger frees the young generation, so memory usage drops.” It does not necessarily. The from-space is reused, not returned to the OS - the same pages come back as the to-space. Memory only drops when V8 shrinks the young generation or when the old generation is collected.

And the third: “objects are promoted when they get old.” They are promoted when they survive. The age mark is not a clock; it is a line drawn across the to-space at the end of each collection, and everything below it

  • everything copied out of the previous from-space - is promoted at the next one.

V8InternalsGcMemory