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.his a bump allocator.Zone::Allocaterounds the size up to 8 bytes, checks whether the current segment has room, and if not callsExpandto chain on a new segment; otherwise it hands backposition_and bumps it. There is no per-object free. When the zone goes out of scope,~ZonecallsDeleteAll, which walks the segment list and returns every segment to theAccountingAllocatorin 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 onefree()-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:
// 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:
// 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:
The size is rounded up to 8 bytes.
kAlignmentInBytesis 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.The check is
size > limit_ - position_, notposition_ + size > limit_. The subtraction form cannot overflow, which matters becausesizecomes from user-influenced computations (an array length, a string length times an element size) andposition_is a real address.The overflow check happens in the
if, the allocation in theelse. 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 thanstd::vectorfor 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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”:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// src/parsing/parser-base.h:2051
ZoneScope zone_scope(zone()); // Free regexp parser memory after use.
ZoneScope is a snapshot-and-restore:
// 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:
// 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:
// 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:
// 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::Deletememsets the freed region with0xcd. 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
ZoneObjectbase class with a deletedoperator 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.