The Zone Allocator: How V8 Cheats by Never Freeing

A V8 zone is a bump allocator over a linked list of memory segments. You allocate from it, you never free from it, and when the whole zone dies every byte comes back at once.

TL;DR: src/zone/zone.h is a bump allocator. Zone::Allocate rounds the size up to 8 bytes, checks whether the current segment has room, and if not calls Expand to chain on a new segment; otherwise it hands back position_ and bumps it. There is no per-object free. When the zone goes out of scope, ~Zone calls DeleteAll, which walks the segment list and returns every segment to the AccountingAllocator in one pass. That is the whole trick: the compiler builds a graph of tens of thousands of nodes, and the graph’s memory is reclaimed in one free()-equivalent call.

Every compiler allocates a lot of short-lived data. The parser builds an AST. The optimising compiler builds a graph. Both build structures with pointers into each other, with cross-references, with cycles. Freeing those structures individually means walking them and calling destructors in the right order, and the order is not always obvious.

The standard answer is an arena: allocate everything from a pool, free the pool. The standard objection is that you need to free some of it early, or a long-running allocation grows without bound.

V8’s zone is the arena, with a twist: the arena is per-operation, not per-lifetime. A new zone is created for a parse, a new zone for an optimising compilation, and the whole thing is dropped when the operation finishes. There is no incremental free, and the sizes involved are bounded by the size of one compilation.

The bump

The core of the allocator is four lines of pointer arithmetic:

cpp
// src/zone/zone.h:250
  V8_INLINE void* AllocateUnchecked(size_t size) {
    DCHECK_LE(position_, limit_);
    DCHECK_LE(size, limit_ - position_);
    DCHECK_EQ(0, position_ % kAlignmentInBytes);
    void* result = reinterpret_cast<void*>(position_);
    position_ += size;
    return result;
  }

position_ is the next free byte; limit_ is the end of the current segment. AllocateUnchecked assumes there is room, and the caller (Allocate) has already checked:

cpp
// src/zone/zone.h:54
  template <typename TypeTag>
  void* Allocate(size_t size) {
#ifdef V8_USE_ADDRESS_SANITIZER
    return AsanNew(size);
#else
    size = RoundUp(size, kAlignmentInBytes);
#ifdef V8_ENABLE_PRECISE_ZONE_STATS
    if (V8_UNLIKELY(TracingFlags::is_zone_stats_enabled())) {
      type_stats_.AddAllocated<TypeTag>(size);
    }
    allocation_size_for_tracing_ += size;
#endif
    if (V8_UNLIKELY(size > limit_ - position_)) {
      return ExpandAndAllocate(size);
    }
    return AllocateUnchecked(size);
#endif  // V8_USE_ADDRESS_SANITIZER
  }

Three things happen in the fast path that are worth noticing:

  1. The size is rounded up to 8 bytes. kAlignmentInBytes is 8, and the comment on the constant explains why: “All pointers returned from New() are 8-byte aligned. ASan requires 8-byte alignment. MIPS also requires 8-byte alignment.” So the alignment is a property of the allocator, not something each caller has to think about.

  2. The check is size > limit_ - position_, not position_ + size > limit_. The subtraction form cannot overflow, which matters because size comes from user-influenced computations (an array length, a string length times an element size) and position_ is a real address.

  3. The overflow check happens in the if, the allocation in the else. A compiler that inlines the whole thing gets a branch, a compare, an add, and a store. That is the entire cost of a zone allocation, and it is why zone-allocated containers are dramatically faster than std::vector for the same workload.

The TypeTag template parameter exists only under V8_ENABLE_PRECISE_ZONE_STATS, a build flag that turns on per-type allocation accounting. You write zone->Allocate<AstRawString>(n) and in a normal build the tag is a no-op; in a stats build the zone keeps a map from typeid to bytes allocated, so you can ask “where did the 40 MB of zone memory in this compilation go?”. The Zone::New helper is the idiomatic entry point:

cpp
// src/zone/zone.h:101
  template <typename T, typename... Args>
  T* New(Args&&... args) {
    static_assert(alignof(T) <= kAlignmentInBytes);
    void* memory = Allocate<T>(sizeof(T));
    return new (memory) T(std::forward<Args>(args)...);
  }

new (memory) T(...) - placement new into zone memory. The object is constructed, so it can have a non-trivial constructor, but it is never destroyed. The zone’s destructor does not run destructors, and the comment says so explicitly:

cpp
// src/zone/zone.h:322
// ZoneObject is an abstraction that helps define classes of objects
// allocated in the Zone. Use it as a base class; see ast.h.
class ZoneObject {
 public:
  // The accidential old-style pattern
  //    new (zone) SomeObject(...)
  // now produces compilation error. The proper way of allocating objects in
  // Zones looks like this:
  //    zone->New<SomeObject>(...)
  void* operator new(size_t, Zone*) = delete; // See explanation above.
  // Allow non-allocating placement new.
  void* operator new(size_t size, void* ptr) { // See explanation above.
    return ptr;
  }

operator new(size_t, Zone*) = delete is a compile error, not a runtime one. The old idiom new (zone) Foo is banned by the type system, and operator delete(void*, size_t) { UNREACHABLE(); } makes sure nobody ever tries to delete a zone object. The comment is blunt about it: “ZoneObjects should never be deleted individually; use Zone::DeleteAll() to delete all zone objects in one go. Note, that descructors will not be called.”

So the zone is not just a fast allocator, it is a lifetime model. If you put a std::lock_guard in a zone object, its destructor never runs. That is why the AST nodes in V8 are trivially destructible and the parser manages any non-trivial state explicitly.

Segments

The bump pointer needs memory, and the memory needs to come from somewhere. A zone holds a linked list of Segments, and the newest one is at the head:

cpp
// src/zone/zone-segment.h:10
// Segments represent chunks of memory: They have starting address
// (encoded in the this pointer) and a size in bytes. Segments are
// chained together forming a LIFO structure with the newest segment
// available as segment_head_. Segments are allocated using malloc()
// and de-allocated using free().

Note the trick in “starting address (encoded in the this pointer)”. The Segment object is placement-new’d into the first bytes of the memory it manages, so the segment header and the payload share one allocation:

cpp
// src/zone/zone-segment.h:38
  Address start() const { return address(sizeof(Segment)); }
  Address end() const { return address(size_); }

One malloc per segment, not two. When the segment is returned, the header is zapped and the whole block is freed.

The growth policy is a “high water mark” strategy - the segment size doubles (well, grows by 2x plus the request) up to a cap:

cpp
// src/zone/zone.cc:143
void Zone::Expand(size_t size) {
  // Make sure the requested size is already properly aligned and that
  // there isn't enough room in the Zone to satisfy the request.
  DCHECK_EQ(size, RoundDown(size, kAlignmentInBytes));
  DCHECK_LT(limit_ - position_, size);

  // Compute the new segment size. We use a 'high water mark'
  // strategy, where we increase the segment size every time we expand
  // except that we employ a maximum segment size when we delete. This
  // is to avoid excessive malloc() and free() overhead.
  //
  // TODO(409953791): This can be simplified when using the page pool for
  // managing zone memory as it works with equal-sized segments.
  Segment* head = segment_head_;
  const size_t old_size = head ? head->total_size() : 0;
  static const size_t kSegmentOverhead = sizeof(Segment) + kAlignmentInBytes;
  const size_t new_size_no_overhead = size + (old_size << 1);

So the first segment is small, the second is roughly twice the first, the third roughly twice the second, and so on, until the cap:

cpp
// src/zone/zone.h:232
  // Never allocate segments smaller than this size in bytes.
  static const size_t kMinimumSegmentSize = 8 * KB;

  // Never allocate segments larger than this size in bytes.
  static const size_t kMaximumSegmentSize = 32 * KB;

8 KB minimum, 32 KB maximum. The cap is the interesting part: without it, a zone that allocated 100 MB would have a last segment of 100 MB, and freeing that back to the OS is one big munmap - which is slow, and which defeats the point of the high water mark. With the cap, the segments stay in the 8-32 KB range, so the whole zone can be handed back as a sequence of ordinary-sized allocations.

Expand then allocates the new segment and makes it the head:

cpp
// src/zone/zone.cc:178
  Segment* segment = allocator_->AllocateSegment(new_size);
  if (segment == nullptr) {
    V8::FatalProcessOutOfMemory(nullptr, "Zone");
  }

  DCHECK_GE(segment->total_size(), new_size);
  segment_bytes_allocated_ += segment->total_size();
  segment->set_zone(this);
  segment->set_next(segment_head_);

The new segment becomes the head and the old head becomes its next, so the list is LIFO. position_ and limit_ are recomputed for the new segment, and the old segment’s used bytes are folded into allocation_size_ so that the accounting stays correct across the boundary.

The destructor is the free

Here is the whole reclamation story:

cpp
// src/zone/zone.cc:110
void Zone::DeleteAll() {
  Segment* current = segment_head_;
  if (current) {
    // Commit the allocation_size_ of segment_head_ and disconnect the segments
    // list from the zone in order to ensure that tracing accounting allocator
    // will observe value including memory from the head segment.
    allocation_size_ = allocation_size();
    segment_head_ = nullptr;
  }
  allocator_->TraceZoneDestruction(this);

  // Traverse the chained list of segments and return them all to the allocator.
  while (current) {
    Segment* next = current->next();
    segment_bytes_allocated_ -= current->total_size();
    ReleaseSegment(current);
    current = next;
  }

  position_ = limit_ = 0;
  allocation_size_ = 0;

and the destructor:

cpp
// src/zone/zone.cc:37
Zone::~Zone() {
  DeleteAll();
  DCHECK_EQ(segment_bytes_allocated_.load(), 0);
}

The DCHECK_EQ(segment_bytes_allocated_.load(), 0) is a self-check: after DeleteAll every byte that was handed out must have come back. If the accounting is off, the debug build tells you.

ReleaseSegment un-poisons the memory for ASan and gives it back:

cpp
// src/zone/zone.cc:136
void Zone::ReleaseSegment(Segment* segment) {
  // Un-poison the segment content so we can reuse or zap it later.
  ASAN_UNPOISON_MEMORY_REGION(reinterpret_cast<void*>(segment->start()),
                              segment->capacity());
  allocator_->ReturnSegment(segment);
}

This is the “never free” claim in its full sense: nothing is freed individually, ever. Zone::Delete exists, and it is a lie:

cpp
// src/zone/zone.h:78
  template <typename TypeTag = void>
  void Delete(void* pointer, size_t size) {
    DCHECK_NOT_NULL(pointer);
    DCHECK_NE(size, 0);
    size = RoundUp(size, kAlignmentInBytes);
#ifdef V8_ENABLE_PRECISE_ZONE_STATS
    if (V8_UNLIKELY(TracingFlags::is_zone_stats_enabled())) {
      type_stats_.AddDeallocated<TypeTag>(size);
    }
    freed_size_for_tracing_ += size;
#endif

#ifdef DEBUG
    static const unsigned char kZapDeadByte = 0xcd;
    memset(pointer, kZapDeadByte, size);
#endif
  }

It does not move position_ back. It does not put the bytes on a free list. In a debug build it fills the memory with 0xcd so that a use-after-“delete” shows up as garbage rather than as plausible data. In a release build it does nothing at all except adjust the tracing counters. The bytes stay consumed until the whole zone dies.

This is deliberate and it is the only sensible design for the use case. If Delete actually reclaimed the bytes, the zone would need a free list, the free list would need to be searched on every allocation, and the “allocate is four instructions” property would be gone. The price paid is that a zone’s peak memory is its final memory. For a compiler that is exactly right: the graph is at its largest right before you throw it away.

Getting the memory back to the OS

AccountingAllocator is the layer between the zone and the OS. Its default path is plain malloc:

cpp
// src/zone/accounting-allocator.cc:113
Segment* AccountingAllocator::AllocateSegment(size_t requested_bytes) {
  base::AllocationResult<void*> memory;
  const bool use_managed_memory_for_isolate =
      (isolate_ != nullptr ||
       v8_flags.managed_zone_memory_for_isolate_independent_memory);
  if (v8_flags.managed_zone_memory && use_managed_memory_for_isolate) {
    memory = ManagedZones::AllocateSegmentWithRetry(isolate_, requested_bytes);
  } else {
    memory = AllocAtLeastWithRetry(requested_bytes);
  }
  if (V8_UNLIKELY(memory.ptr == nullptr)) {
    return nullptr;
  }

and the return path:

cpp
// src/zone/accounting-allocator.cc:139
void AccountingAllocator::ReturnSegment(Segment* segment) {
  segment->ZapContents();
  size_t segment_size = segment->total_size();
  current_memory_usage_.fetch_sub(segment_size, std::memory_order_relaxed);
  segment->ZapHeader();
  const bool use_managed_memory_for_isolate =
      (isolate_ != nullptr ||
       v8_flags.managed_zone_memory_for_isolate_independent_memory);
  if (v8_flags.managed_zone_memory && use_managed_memory_for_isolate) {
    ManagedZones::ReturnSegment(isolate_, segment, segment_size);
  } else {
    free(segment);
  }
}

Note ZapContents() then ZapHeader(). The contents are zapped first because the header lives inside the allocation; zapping the header first would destroy the size field needed to know how much to zap.

The optional “managed” path is interesting. With --managed-zone-memory, zone segments come from V8’s own page allocator instead of malloc, in 512 KB reservations:

cpp
// src/zone/accounting-allocator.cc:93
#if defined(V8_HOST_ARCH_64_BIT)
  static constexpr size_t kMinZonePageSize = 512 * KB;
#else
  // For 32-bit platforms, use smaller reservation size to avoid virtual memory
  // exhaustion in scenarios when many zones get created, e.g. when compiling a
  // large number of Wasm modules.
  static constexpr size_t kMinZonePageSize = 256 * KB;
#endif
  static constexpr size_t kMinZonePageAlignment = 16 * KB;

512 KB is a compromise. Bigger reservations mean fewer mmap calls but more virtual address space wasted, and on a 32-bit platform that waste is fatal - hence the 256 KB fallback with the comment about Wasm modules.

And the managed path is pooled. A zone reservation of exactly the pool page size goes back into the MemoryPool instead of being unmapped:

cpp
// src/zone/accounting-allocator.cc:82
  static void ReturnSegment(Isolate* isolate, void* memory, size_t bytes) {
    VirtualMemory reservation(GetPlatformPageAllocator(),
                              reinterpret_cast<Address>(memory), bytes);
    if (reservation.size() == ManagedZones::kMinZonePageSize) {
      IsolateGroup::current()->memory_pool()->AddZoneReservation(
          isolate, std::move(reservation));
    }
    // Reservation will be automatically freed here otherwise.
  }

Only reservations of exactly the pool page size are pooled. A bigger one - a 32 KB segment inside a 512 KB page - falls through and is unmapped. The pool exists because zone allocation is one of the hottest allocation paths in the compiler, and mmap/munmap are syscalls.

Zone accounting

Two different questions get two different counters.

allocation_size() is “how many bytes of the zone are in use right now”:

cpp
// src/zone/zone.h:177
  // Returns precise value of used zone memory, allowed to be called only
  // from thread owning the zone.
  size_t allocation_size() const {
    size_t extra = segment_head_ ? position_ - segment_head_->start() : 0;
    return allocation_size_ + extra;
  }

The extra term is the part of the current segment that has been handed out; allocation_size_ is the total for all the older segments. This is why Expand has to do this bookkeeping when it swaps the head:

cpp
// src/zone/zone.cc:187
  // Commit the allocation_size_ of segment_head_ if any, in order to ensure
  // that tracing accounting allocator will observe value including memory
  // from the previous head segment.
  allocation_size_ = allocation_size();
  segment_head_ = segment;

segment_bytes_allocated_ is a different number: “how many bytes did the OS give us”. It goes up when a segment is allocated and down when one is returned, and it is what the ~Zone DCHECK checks.

allocation_size_for_tracing() and freed_size_for_tracing() are the third pair, and they exist for a different reason. They can be read from another thread while the zone is being mutated:

cpp
// src/zone/zone.h:184
  // When V8_ENABLE_PRECISE_ZONE_STATS is not defined, returns used zone memory
  // not including the head segment.
  // Can be called from threads not owning the zone.
  size_t allocation_size_for_tracing() const {

The allocation_size_ and segment_bytes_allocated_ members are std::atomic<size_t> precisely so that the tracing thread can read them without a lock.

The compiler wraps all of this in ZoneStats, which tracks the peak memory a compilation used, per phase:

cpp
// src/compiler/zone-stats.h:17
class V8_EXPORT_PRIVATE ZoneStats final {
 public:
  class V8_NODISCARD Scope final {

The Scope object is a per-phase zone factory:

cpp
// src/compiler/zone-stats.h:48
    Zone* zone() {
      if (zone_ == nullptr) zone_ = zone_stats_->NewEmptyZone(zone_name_);
      return zone_;
    }
    void Destroy() {
      if (zone_ != nullptr) zone_stats_->ReturnZone(zone_);
      zone_ = nullptr;
    }

Each phase of the TurboFan pipeline gets its own zone, named after the phase. When the phase ends, Destroy returns the zone to ZoneStats, which records the peak and deletes the zone. The phase boundaries are where the memory is actually reclaimed, so the reported “peak zone memory” is a sum over phases rather than a single high-water mark.

The tracing side is a developer flag:

cpp
// src/flags/flag-definitions.h:2697
DEFINE_DEVELOPER_FLAG(trace_zone_stats, "trace zone memory usage")

with a tolerance so the trace does not emit a line per allocation:

cpp
// src/flags/flag-definitions.h:2702
DEFINE_SIZE_T(
    zone_stats_tolerance, 1 * MB,
    "report a tick only when allocated zone memory changes by this amount")

The parser’s zones

The parser is the biggest zone user, and it uses several.

ReusableUnoptimizedCompileState owns two long-lived zones and a factory that carves AST nodes out of them:

cpp
// src/parsing/parse-info.cc:165
ReusableUnoptimizedCompileState::ReusableUnoptimizedCompileState(
    Isolate* isolate)
    : hash_seed_(HashSeed(isolate)),
      allocator_(isolate->allocator()),
      v8_file_logger_(isolate->v8_file_logger()),
      dispatcher_(isolate->lazy_compile_dispatcher()),
      ast_string_constants_(isolate->ast_string_constants()),
      ast_raw_string_zone_(allocator_,
                           "unoptimized-compile-ast-raw-string-zone"),
      single_parse_zone_(allocator_, "unoptimized-compile-parse-zone"),
      ast_value_factory_(
          new AstValueFactory(ast_raw_string_zone_, single_parse_zone(),
                              ast_string_constants(), hash_seed())) {}

The names are the point. The zone names show up in the trace output, so when you ask “where is the compiler spending its memory”, the answer comes back as zone names.

ast_raw_string_zone_ holds the string literals - they are shared across parses, so they live in their own zone that is not reset between compilations. single_parse_zone_ holds the AST of one parse and is reset between parses:

cpp
// src/parsing/parse-info.h:203
  void NotifySingleParseCompleted() { single_parse_zone_.Reset(); }

Reset is the interesting call. It does not destroy the zone and create a new one; it keeps the first segment and releases the rest:

cpp
// src/zone/zone.cc:69
void Zone::Reset() {
  if (!segment_head_) return;
  Segment* keep = segment_head_;
  segment_head_ = segment_head_->next();
  if (segment_head_ != nullptr) {
    // Reset the position to the end of the new head, and uncommit its
    // allocation size (which will be re-committed in DeleteAll).
    position_ = segment_head_->end();
    allocation_size_ -= segment_head_->end() - segment_head_->start();
  }
  keep->set_next(nullptr);
  DeleteAll();
  allocator_->TraceZoneCreation(this);

  // Un-poison the kept segment content so we can zap and reuse it.
  ASAN_UNPOISON_MEMORY_REGION(reinterpret_cast<void*>(keep->start()),
                              keep->capacity());
  keep->ZapContents();

  segment_head_ = keep;
  position_ = RoundUp(keep->start(), kAlignmentInBytes);
  limit_ = keep->end();
  DCHECK_LT(allocation_size(), kAlignmentInBytes);
  DCHECK_EQ(segment_bytes_allocated_, keep->total_size());
}

Read it carefully: keep is the old head, and everything after it in the chain is released. Then the old head becomes the head again, with position_ rewound to its start. The kept segment is exactly one segment - at most 32 KB - and it is reused for the next parse.

This is the “small zone stays small” behaviour. A parse of a small file allocates a few KB and the zone keeps one segment. A parse of a large file allocates megabytes and, on reset, keeps only the last (largest) segment and throws away the rest. The zone’s steady-state footprint is therefore one segment, not the peak.

NotifySingleParseCompleted is called from ~ParseInfo:

cpp
// src/parsing/parse-info.cc:237
ParseInfo::~ParseInfo() { reusable_state_->NotifySingleParseCompleted(); }

so the reset happens when the parse’s ParseInfo dies, not when the compilation finishes. The AST is dead at that point; only the bytecode and the shared function info survive, and those are heap objects, not zone objects.

There is a third zone in the parser, preparser_zone_, and it is member of the Parser object rather than a local:

cpp
// src/parsing/parser.h:1109
  Zone preparser_zone_;

The preparser runs speculatively - V8 parses a function body without generating bytecode, to find out whether it is worth compiling - and that scratch space has to survive across preparse calls within one compilation.

And the parser uses ZoneScope for a genuinely scoped piece of work. The regexp validator allocates a lot of temporary parsing state, and the scope releases it as soon as the regexp has been validated:

cpp
// src/parsing/parser-base.h:2051
  ZoneScope zone_scope(zone());  // Free regexp parser memory after use.

ZoneScope is a snapshot-and-restore:

cpp
// src/zone/zone.h:307
// Similar to the HandleScope, the ZoneScope defines a region of validity for
// zone memory. All memory allocated in the given Zone during the scope's
// lifetime is freed when the scope is destructed, i.e. the Zone is reset to
// the state it was in when the scope was created.
class ZoneScope final {
 public:
  explicit ZoneScope(Zone* zone) : zone_(zone), snapshot_(zone->Snapshot()) {}

  ~ZoneScope() { snapshot_.Restore(zone_); }

The ZoneSnapshot records the position, the limit, and the head segment, and Restore rewinds to them and releases the segments that were allocated in between:

cpp
// src/zone/zone.cc:219
void ZoneSnapshot::Restore(Zone* zone) const {
  // Release segments up to the stored segment_head_.
  Segment* current = zone->segment_head_;
  while (current != segment_head_) {
    // If this check failed, then either you passed a wrong zone, or the zone
    // was reset to an earlier snapshot already. We cannot move forward again.
    CHECK_NOT_NULL(current);
    Segment* next = current->next();
    zone->ReleaseSegment(current);
    current = next;
  }

This is the “scoped arena” pattern: a zone that supports a stack of allocation scopes. It is how V8 gets both properties - the four- instruction allocation and partial reclamation.

Zone containers

Because the zone owns the memory, the standard containers need a zone-aware allocator. ZoneAllocator is a thin adapter:

cpp
// src/zone/zone-allocator.h:34
  explicit ZoneAllocator(Zone* zone) : zone_(zone) {}
  template <typename U>
  ZoneAllocator(const ZoneAllocator<U>& other) V8_NOEXCEPT
      : ZoneAllocator<T>(other.zone()) {}

  T* allocate(size_t length) { return zone_->AllocateArray<T>(length); }
  void deallocate(T* p, size_t length) { zone_->DeleteArray<T>(p, length); }

deallocate calls Zone::DeleteArray, which is the fake delete from before. So a std::vector<T, ZoneAllocator<T>> “frees” elements that were never really freed, and its capacity is never reclaimed until the zone dies. That is fine for the intended use - a vector that grows during a compilation and dies with it - but it means a std::vector in a zone can hold a lot more memory than its length suggests.

V8 does not use std::vector directly for this reason. It has ZoneVector, a hand-written replacement with a different growth strategy, and ZoneList, which is a linked list that embeds its own storage:

cpp
// src/zone/zone-list.h:20
// ZoneLists are growable lists with constant-time access to the elements.
// The list itself and all its elements are supposed to be allocated in zone
// memory. Unlike ZoneVector container, the ZoneList instance has minimal
// possible size which makes it a good candidate for embedding into other
// often-allocated zone objects.
//
// Note, ZoneLists' elements cannot be deleted individually and the destructor
// intentionally does not free the backing store. Because of the latter, the
// ZoneList must not be used outsize of zone memory. Consider using ZoneVector
// or other containers instead.

“minimal possible size” - a ZoneList is a pointer and a length, so it can be embedded in an AST node that is itself embedded in a zone.

Trade-offs

What it buys.

  • Cheap allocation. Four instructions in the fast path, no free list, no size bookkeeping per object.
  • Cheap deallocation. One call per zone, regardless of how many objects were allocated.
  • No allocator pressure on the GC. Zone memory is not on the V8 heap. The parser’s AST and the compiler’s graph do not show up in the heap statistics and do not trigger GCs.
  • Simple lifetime reasoning. Everything in a zone dies together. There is no question about whether a node is still reachable.

What it costs.

  • Peak memory equals final memory. A zone never returns memory to the allocator during its life. A compilation that allocates 100 MB transiently holds 100 MB until it finishes.
  • The debug allocator is not free. In debug builds, Zone::Delete memsets the freed region with 0xcd. This is a real cost, but a useful one: it turns a use-after-delete into an obvious garbage value instead of a plausible-looking object.
  • Destructors do not run. Zone objects must be trivially destructible, or their cleanup has to be written out by hand. This is a real constraint on the code that uses zones, and it is why V8 has a ZoneObject base class with a deleted operator delete.
  • Fragmentation across the segment chain. The bump allocator never reuses a hole, so a zone that allocates 1 KB, 1 MB, 1 KB has a 1 MB hole in the middle that nothing can use.

The bottom line

The zone allocator is a bet that the lifetime of a group of allocations is uniform. When that bet holds - a parse, a compilation, a regexp validation - the win is enormous: allocation becomes a pointer bump and deallocation becomes a list walk, with no per-object work at all.

The bet does not hold for anything that lives as long as the process, which is why zone memory is never used for anything the JavaScript program can see. Zone memory is for the machinery that builds the program, and it is reclaimed the moment the machinery is done.

The two refinements V8 adds on top of the basic arena are what make it practical: Reset keeps one segment so a long-lived zone does not grow without bound, and ZoneScope/ZoneSnapshot give you scoped reclamation inside a zone for the cases where a sub-allocation really does need to go away early.

The thing people get wrong

The belief is that “the zone frees memory when you delete from it”. It does not. Zone::Delete is a debug-only poison pill and a statistics adjustment; the bytes are gone from the allocator’s point of view only when the zone is destroyed. If you write code that allocates 100 MB in a zone, deletes 90 MB of it, and keeps going, you are using 100 MB.

The other misconception is that zone memory is a special kind of V8 heap memory. It is not. It does not live in a MemoryChunk, it is not seen by the garbage collector, it is not accounted for in the heap statistics, and it is not returned by the scavenger. By default it is malloced memory in 8-32 KB chunks; with --managed-zone-memory it comes from V8’s page allocator in 512 KB reservations that can be pooled. The heap and the zone are two entirely separate memory systems that happen to live in the same process.

V8InternalsMemoryCompilerParser