The Write Barrier: How V8 Knows Where a Moved Object Used to Live
V8's write barrier is a four-tier machine that keeps the generational, incremental-marking and compacting collectors honest. The compacting part records slots, not addresses.
TL;DR: V8’s write barrier is not one thing. It is a four-tier machine
- inline fast path, deferred out-of-line path, shared slow path, C++ slow path - that does up to three jobs at once: remembering old-to-new references for the generational collector, marking the value and preventing black-to-white references during incremental marking, and recording the slot of an old-to-old reference so that a compacting GC can find it again and rewrite it. The compacting part does not remember the old address of the moved object; it remembers where the pointer lives.
Every time V8’s mutator writes a heap pointer into a heap object, the garbage collector has to hear about it. Not always - but often enough that the “often enough” case has to be nearly free.
V8’s answer is a layered barrier that most stores never pay for. The interesting design is not the layering; it is the third job, the one that exists only because V8 has a moving collector.
Three jobs, one barrier
V8 ships a readme for the barrier, and it is worth reading before the code:
// src/heap/WRITE_BARRIER.md:3
V8 uses a write barrier to inform the GC about changes to the heap by the mutator.
A write barrier is emitted for heap stores like `host.field = value`.
The write barrier is required for multiple purposes:
* Records old-to-new references for the generational GC to work.
* During marking it prevents black-to-white references during incremental/concurrent marking.
* During marking it records old-to-old references (pointers to objects on evacuation candidates)Three jobs:
- Generational. If an old object points at a young object, the young object has to survive the next scavenge, so the barrier records the slot.
- Marking. During incremental or concurrent marking, the tri-colour invariant says a black object must not point at a white one. The barrier marks the value and, when necessary, shades the host.
- Evacuation slot recording. If the value lives on a page the compacting collector wants to move, the collector needs to find every reference to it. The barrier records the slot.
The first two are well known. The third is where the “how does V8 know where a moved object used to live” question is answered.
The four-tier structure
The barrier is split into four tiers - inline fast path, deferred
out-of-line path, shared slow path, C++ slow path - and the split is the
same in the C++ path, in CSA, and in TurboFan. Tiers 1 and 2 are emitted
per call site: the fast path is inline in the store, the deferred path is a
branch target nearby. Tier 3 is a shared routine written in CSA. Tier 4 is
C++ that may malloc.
The C++ entry point shows the shape of the whole thing:
// src/heap/heap-write-barrier-inl.h:51
// static
void WriteBarrier::CombinedWriteBarrierInternal(Tagged<HeapObject> host,
HeapObjectSlot slot,
Tagged<HeapObject> value,
WriteBarrierMode mode) {
DCHECK_EQ(mode, UPDATE_WRITE_BARRIER);
if constexpr (v8_flags.sticky_mark_bits.value()) {
CombinedWriteBarrierInternalForStickyMarkbits(host, slot, value, mode);
return;
}
MemoryChunk* host_chunk = MemoryChunk::FromHeapObject(host);
// Fast path: Marking is off and the host objects is either in the young
// generation or shared space, for which we don't require remembered sets.
if (!host_chunk->PointersFromHereAreInteresting()) [[likely]] {
return;
}
// Either marking is on, or the host objects is in the old (non-shared)
// generation for which we record remembered sets.
MemoryChunk* value_chunk = MemoryChunk::FromHeapObject(value);
// Old to old writes can bail out when marking is off.
if (!value_chunk->PointersToHereAreInteresting()) {
return;
}
CombinedWriteBarrierInternalSlow(host, host_chunk, slot, value, value_chunk);
}Two loads of a page-header flag and two predictable branches. That is the cost of the barrier for a store that does not need one.
The fast path: two page flags
The flags are per-page, not per-object, and they answer two questions:
kPointersFromHereAreInterestingMask- does this page contain objects whose outgoing pointers the GC cares about? False for young-generation pages and read-only pages.kPointersToHereAreInterestingMask- does this page contain objects the GC might need to find references to? False for read-only pages and, during marking, for pages that are already black.
// src/heap/memory-chunk.h:113
static constexpr MainThreadFlags kAllFlagsMask = ~MainThreadFlags(NO_FLAGS);
static constexpr MainThreadFlags kPointersToHereAreInterestingMask =
POINTERS_TO_HERE_ARE_INTERESTING;
static constexpr MainThreadFlags kPointersFromHereAreInterestingMask =
POINTERS_FROM_HERE_ARE_INTERESTING;The page granularity is what makes this cheap: a whole 256 KB page of young objects answers “not interesting” with a single flag test, so a store into a young object needs no barrier at all.
There is a subtlety in the fast path above that is easy to miss. The second check - “old to old writes can bail out when marking is off” - holds only because when marking is off there is nothing to mark and nothing to rewrite. During marking, an old-to-old write must be examined, because the value might now be on an evacuation candidate page.
The generational barrier: old to new
When the host is old and the value is young, the slot has to be recorded so
the scavenger can find the young object through the old one. This is the
OLD_TO_NEW remembered set, and it is what the
scavenger
reads.
// src/heap/heap-write-barrier.cc:408
void WriteBarrier::GenerationalBarrierSlow(Tagged<HeapObject> host,
Address slot,
Tagged<HeapObject> value) {
const LocalHeap* local_heap = LocalHeap::Current();
MemoryChunk* host_chunk = MemoryChunk::FromHeapObject(host);
MutablePage* host_page =
SbxCast<MutablePage>(host_chunk->Metadata(local_heap->heap()->isolate()));
if (local_heap->is_main_thread()) {
RememberedSet<OLD_TO_NEW>::Insert<AccessMode::NON_ATOMIC>(
host_page, host_chunk->Offset(slot));
} else {
RememberedSet<OLD_TO_NEW_BACKGROUND>::Insert<AccessMode::ATOMIC>(
host_page, host_chunk->Offset(slot));
}
}Note that there are two remembered sets, not one: the main thread uses a
non-atomic insert into OLD_TO_NEW, background threads an atomic insert
into OLD_TO_NEW_BACKGROUND. The scavenger scans both.
The marking barrier: keeping the tri-colour invariant
During incremental or concurrent marking, the barrier must mark the value grey so it gets traced, and shade the host if the host is black and the value is white.
// src/heap/marking-barrier-inl.h:20
template <typename TSlot, RecordYoungSlot kRecordYoung>
void MarkingBarrier::Write(Tagged<HeapObject> host, TSlot slot,
Tagged<HeapObject> value) {
DCHECK(IsCurrentMarkingBarrier(host));
DCHECK(is_activated_ || shared_heap_worklists_.has_value());
DCHECK(MemoryChunk::FromHeapObject(host)->IsMarking());
MarkValue(host, value);
// When recording slots to young pointers, we need to call RecordSlot even if
// IsCompacting() returns true. This is because IsCompacting() simply returns
// false when there are no evacuation candidates. New space pages are not
// considered evacuation candidates and may still be relocated.
if (slot.address() &&
(static_cast<bool>(kRecordYoung) || IsCompacting(host))) {
MarkCompactCollector::RecordSlot<TSlot, kRecordYoung>(host, slot, value);
}
}MarkValue handles the tri-colour logic (mark the value, and if the host is
black and the value is white, shade the host). The RecordSlot call is the
third job, and it is conditional on IsCompacting(host) - the host’s page
must be one the compacting collector intends to move.
The evacuation barrier: how V8 knows where a moved object used to live
Here is the mechanism, and it is not what most people assume.
The barrier does not store the old address of the moved object.
It stores the offset of the slot - the position of the pointer field
inside the host page - in the OLD_TO_OLD remembered set.
// src/heap/mark-compact-inl.h:73
const MemoryChunk* value_chunk = MemoryChunk::FromHeapObject(value);
if (!value_chunk->IsEvacuationCandidate() &&
(!static_cast<bool>(kRecordYoung) ||
!HeapLayout::InYoungGeneration(value_chunk, value))) {
return;
}Note the early-out: the slot is only recorded if the value is on an evacuation candidate page. If the value is in a page that will not move, there is nothing to rewrite later, so nothing is recorded. The insert itself picks the remembered set that matches the kind of space the value is in:
// src/heap/mark-compact-inl.h:97
} else if (V8_LIKELY(!value_page->is_writable_shared()) ||
host_page->heap()->isolate()->is_shared_space_isolate()) {
DCHECK_EQ(host_page->heap(), value_page->heap());
RememberedSet<OLD_TO_OLD>::Insert<AccessMode::ATOMIC>(
host_page, host_chunk->Offset(slot.address()));Now the compaction side. At the end of the mark-compact cycle, the collector
walks the OLD_TO_OLD set and rewrites each recorded slot, and UpdateSlot
does the actual rewrite by reading the old address out of the slot and
following the forwarding pointer stored in the old object’s map word:
// src/heap/mark-compact.cc:4423
if (HeapLayout::InReadOnlySpace(heap_obj)) return;
MapWord map_word = heap_obj->map_word(kRelaxedLoad);
if (!map_word.IsForwardingAddress()) return;
DCHECK_IMPLIES((!v8_flags.minor_ms && !Heap::InFromPage(heap_obj)),
MarkCompactCollector::IsOnEvacuationCandidate(heap_obj) ||
MemoryChunk::FromHeapObject(heap_obj)
->Metadata()
->evacuation_was_aborted());
typename TSlot::TObject target = MakeSlotValue<TSlot, reference_type>(
map_word.ToForwardingAddress(heap_obj));
// Needs to be atomic for map space compaction: This slot could be a map
// word which we update while loading the map word for updating the slot
// on another page.
slot.Relaxed_Store(target);So the flow is: the barrier remembers where the pointer is; the collector later reads what the pointer was from that slot; the moved object carries its new address in its own map word as a forwarding pointer; and the slot is overwritten with the new address.
The object itself does the remembering. The moving collector already has to write a forwarding address into every object it moves, so the “where did this object used to live” question is answered by the object, not by the barrier.
The remembered set is a bitmap, not a list
One more thing makes this affordable. The remembered set is not a list of slot addresses. It is a bitmap, one bit per pointer-sized slot in the page:
// src/heap/base/basic-slot-set.h:277
static constexpr int kCellsPerBucket = 32;
static constexpr int kCellsPerBucketLog2 = 5;
static constexpr int kCellSizeBytesLog2 = 2;
static constexpr int kCellSizeBytes = 1 << kCellSizeBytesLog2;
static constexpr int kBitsPerCell = 32;
static constexpr int kBitsPerCellLog2 = 5;
static constexpr int kBitsPerBucket = kCellsPerBucket * kBitsPerCell;
static constexpr int kBitsPerBucketLog2 =
kCellsPerBucketLog2 + kBitsPerCellLog2;A bucket is 32 cells of 32 bits, i.e. 1024 bits, covering 1024 slots. With V8’s default 256 KB page and 8-byte tagged slots, a page has 32768 slots, so a fully-populated slot set is 32768 bits - 4 KB - for a 256 KB page. One bit per slot.
This is also why the CSA implementation can do the insert without any allocation in the common case: the slot set is allocated lazily per page, and the insert is a bit-set.
When the barrier is skipped
The barrier is elided in several cases: storing a Smi, storing a read-only object, and storing into the most recent young allocation - an “initializing store”, where the host was allocated in the young generation and no GC point has occurred since.
The third rule is the clever one, and it is safe for a non-obvious reason:
// src/heap/WRITE_BARRIER.md:26
None of the three write barrier kinds are necessary for such stores:
* **Old-to-new (generational) barrier:** Not needed because the host object is itself young, so there is no old-to-new reference to record.
* **Old-to-old (evacuation) barrier:** Not needed because the host object is young, so there is no old-to-old slot to track. The GC can still relocate young objects during a GC; new-to-new slots are discovered by scanning the young generation during the atomic GC pause. This is cheap because the young generation is usually small relative to the full heap size. Note that old-to-new references are still recorded by the generational barrier even during incremental marking. That way we discover all references to young objects.
* **Marking barrier:** This is the trickiest case. Concurrent marking bails out of tracing objects that reside in the current new space LAB (linear allocation buffer) and pushes them into an "on hold" queue. This prevents such objects from becoming black — they remain grey. Grey objects are marked but are also contained in the worklist - so they will be traced at some point in the future. All of this means that the host object for such cases can never be black - so we also do not need a WB to prevent black-to-white references here. This is also where the *most recently allocated object* limitation comes from.Read that third bullet carefully. The reason a freshly allocated object needs no marking barrier is not that it is young. It is that concurrent marking refuses to blacken objects in the current linear allocation buffer - they go on an “on hold” queue and stay grey. A grey host can never violate the tri-colour invariant, because grey means “will be traced again”.
The C++ side has the same rules, and the debug build checks them:
// src/heap/heap-write-barrier-inl.h:525
// static
template <typename HostType, typename ValueType>
bool WriteBarrier::IsRequiredCommon(HostType host, ValueType value) {
if (IsSmi(value)) {
return false;
}
if (value.IsCleared()) {
return false;
}
Tagged<HeapObject> target = value.GetHeapObject();
if (ReadOnlyHeap::Contains(target)) {
return false;
}
if constexpr (std::is_same_v<HostType, const HeapObject*>) {
return !IsMostRecentYoungAllocation(host->address());
} else {
return !IsMostRecentYoungAllocation(host.address());
}
}SKIP_WRITE_BARRIER is a mode that a caller asserts; in a verification
build, VerifySkipWriteBarrier checks the assertion and fails the process
if it is wrong.
How the barrier is generated: CSA and TurboFan
The shared slow path is written in CSA, in
WriteBarrierCodeStubAssembler. The generational insert is the interesting
part, because it is the one that has to touch a per-page bitmap:
// src/builtins/builtins-internal-gen.cc:175
TNode<IntPtrT> slot_offset = IntPtrSub(slot, chunk);
TNode<IntPtrT> num_buckets_address =
IntPtrSub(slot_set, IntPtrConstant(SlotSet::kNumBucketsSize));
TNode<IntPtrT> num_buckets = UncheckedCast<IntPtrT>(
Load(MachineType::Pointer(), num_buckets_address, IntPtrConstant(0)));
// Load bucket
TNode<IntPtrT> bucket =
LoadBucket(slot_set, slot_offset, num_buckets, &slow_path);
// Update cell
SetBitInCell(bucket, slot_offset);
Goto(&next);
BIND(&slow_path);
{
TNode<ExternalReference> function =
ExternalConstant(ExternalReference::insert_remembered_set_function());
CallCFunctionWithCallerSavedRegisters(
function, MachineTypeOf<Int32T>::value, fp_mode,
std::make_pair(MachineTypeOf<IntPtrT>::value, page),
std::make_pair(MachineTypeOf<IntPtrT>::value, slot_offset));
Goto(&next);
}The slow path is the C++ Heap::InsertIntoRememberedSetFromCode, the only
tier that may have to allocate the slot set for a page.
The marking side of the CSA barrier encodes the evacuation-candidate test as a single fused condition:
// src/builtins/builtins-internal-gen.cc:467
void IsValueUnmarkedOrRecordSlot(TNode<IntPtrT> value, Label* true_label,
Label* false_label) {
// This code implements the following condition:
// IsUnmarked(value) ||
// OnEvacuationCandidate(value) &&
// !SkipEvacuationCandidateRecording(value)
// 1) IsUnmarked(value) || ....
GotoIf(IsUnmarked(value), true_label);
// 2) OnEvacuationCandidate(value) &&
// !SkipEvacuationCandidateRecording(value)
GotoIfNot(IsPageFlagSet(value, MemoryChunk::kEvacuationCandidateMask),
false_label);If the value is unmarked, the barrier must run (to mark it). If it is marked but on an evacuation candidate page, the barrier must run (to record the slot). Otherwise it can be skipped entirely.
TurboFan emits the barrier as a dedicated machine opcode,
kArchAtomicStoreWithWriteBarrier, whose inline fast path is:
// src/compiler/backend/x64/code-generator-x64.cc:2136
if (mode > RecordWriteMode::kValueIsPointer) {
__ JumpIfSmi(value, ool->exit());
}
#if V8_ENABLE_STICKY_MARK_BITS_BOOL
__ CheckPageFlag(object, scratch0, MemoryChunk::kIncrementalMarking,
not_zero, ool->stub_call());
__ CheckMarkBit(object, scratch0, scratch1, carry, ool->entry());
#else // !V8_ENABLE_STICKY_MARK_BITS_BOOL
static_assert(WriteBarrier::kUninterestingPagesCanBeSkipped);
__ CheckPageFlag(object, scratch0,
MemoryChunk::kPointersFromHereAreInterestingMask,
not_zero, ool->entry());
#endif // !V8_ENABLE_STICKY_MARK_BITS_BOOL
__ bind(ool->exit());Three things happen here, in order: the store itself is emitted (the
EmitTSANAwareStore call above it), a Smi value short-circuits to the exit
label, and then the page flag decides whether to fall into the out-of-line
OutOfLineRecordWrite block. That block checks the value page flag - the
POINTERS_TO_HERE_ARE_INTERESTING test - and, if the value is
uninteresting, jumps back out without doing any work.
That is the whole barrier for a typical store: a Smi test, a page flag test, and a not-taken branch.
The sandbox: external and trusted pointers
With the sandbox enabled, some heap fields do not hold a heap pointer at all. They hold an index into a side table of external pointers, or a tagged indirect pointer into the trusted space. Those need their own barriers, because the GC has to mark the table entry and not the slot.
// src/heap/heap-write-barrier.cc:143
void WriteBarrier::MarkingSlow(Tagged<HeapObject> host,
ExternalPointerSlot slot,
ExternalPointerHandle handle) {
#ifdef V8_COMPRESS_POINTERS
if (!slot.HasExternalPointerHandle()) return;
DCHECK_EQ(handle, slot.Relaxed_LoadHandle());
MarkingBarrier* marking_barrier = CurrentMarkingBarrier(host);
IsolateForPointerCompression isolate(marking_barrier->heap()->isolate());
ExternalPointerTable& table =
isolate.GetExternalPointerTableFor(slot.tag_range());
ExternalPointerTable::Space* space =
isolate.GetExternalPointerTableSpaceFor(slot.tag_range(), host.address());
DCHECK(!space->is_internal_read_only_space());
table.Mark(space, handle, slot.address(), slot.tag_range());Trusted (indirect) pointers get their own TurboFan opcode,
kArchStoreTrustedWithWriteBarrier, because the slot holds a tagged index
rather than a pointer.
Setting a map is a write too
Changing an object’s map is a pointer write, and it goes through the barrier like any other - which matters, because the map word is where a forwarding pointer is stored during compaction.
// src/objects/heap-object-set-map-inl.h:100
set_map_word(value, order);
Heap::NotifyObjectLayoutChangeDone(this);
#ifndef V8_DISABLE_WRITE_BARRIERS
if (emit_write_barrier == EmitWriteBarrier::kYes) {
WriteBarrier::ForValue(this, MaybeObjectSlot(map_slot()), value,
UPDATE_WRITE_BARRIER);
} else {There is also an explicit set_map_safe_transition_no_write_barrier for
callers who know the transition is safe.
Trade-offs
What it buys. The generational collector does not have to scan the whole old generation to find its young objects. Incremental and concurrent marking can run without stopping the world. Compacting can move objects without scanning for references.
What it costs.
- Every store pays something. Even the fast path is a flag test, and the flag test needs the page header, which is a cache miss away from the object.
- The remembered sets are memory. One bit per slot per page, times the number of pages, times the number of remembered set kinds.
- The barrier is a correctness hazard. A missed barrier is not a slow
path, it is a corrupted heap or a lost object. That is why
V8_VERIFY_WRITE_BARRIERSexists and whySKIP_WRITE_BARRIERis a mode the runtime asserts rather than a flag it trusts. - The skipping rules are subtle. “Most recent young allocation” is a property of the allocator, not of the object, and getting it wrong is a use-after-free.
The bottom line
V8’s write barrier is not a “tell the GC that something changed” hook. It is three specific, independently-switchable mechanisms sharing one fast path: remember old-to-new edges so the scavenger can find young objects; maintain the tri-colour invariant during marking so marking can be incremental; and remember slots whose values might move so the compactor can rewrite them.
The deep trick is that the third one does not remember addresses at all. It remembers a bit in a bitmap saying “there is a pointer in this slot”, and the moving object remembers where it went by writing a forwarding pointer into its own map word. Between them the barrier and the object hold all the information, and neither holds the “before” address. The concurrent marker is what the marking half of this exists to feed.
The thing people get wrong
“The write barrier records the old address of the object so the GC can update the reference.” It does not. The barrier records the slot - a bit in a per-page bitmap saying “this field holds a pointer that might need rewriting”. The old address is still sitting in that slot, and the collector reads it out at compaction time, follows the forwarding pointer in the old object’s map word, and overwrites the slot.
That distinction explains why the remembered set can be a bitmap, and why the barrier has to fire on a write even when the value does not change: if the value is on an evacuation candidate page, the collector must know about that slot even if the pointer is identical to what was there before - because the object it points at is about to move.
The other thing people get wrong: “the write barrier is only about the generational GC.” Two of its three jobs only exist during incremental or concurrent marking, and the third only during a compacting GC. The generational part is always on; the expensive part is conditional, which is why the fast path is two flags and a branch.