How V8 Gives Every Object a Hidden Class
Every V8 object points at a Map that describes its shape. Adding a property walks a tree of Maps, and the order you add properties in decides which branch you end up on.
TL;DR: Every V8 object points at a
Mapthat describes its shape: instance size, property offsets, the descriptor array. Adding a property does not mutate the object - it walks a tree ofMaps, one edge per property name, and the new map is shared with every other object that made the same sequence of additions. The transitions are held in weak references, so an unused shape dies with its last user; when a shape has to change underneath live objects, V8 deprecates the transition tree and deoptimizes the code that depended on it.
JavaScript gives you objects you can mutate at any time. obj.x = 1 on an
object that never had an x is legal, and legal again on the next line, and
legal again a million times in a loop. The specification says nothing about
“shape” - it says properties live in an ordinary ordered list.
V8 needs something much more specific than that. Its optimizing compiler
emits code like obj.x assuming that x lives at offset 8 of the object and
that reading it is just a load. For that assumption to be sound, V8 needs to
know, cheaply and exactly, where each property lives. That knowledge lives in
a separate heap object - the Map - and the rules for how maps relate to
each other live in a transition tree.
The interesting part is not that the map exists. It is that the tree is weak, that it is ordered, and that changing a shape underneath live objects is a deoptimization event rather than a silent fixup.
The same idea carries the inline cache:
in unoptimized code, obj.x compares the receiver’s map against an
expected map and, if they match, uses a cached offset. The map is the
thing being compared.
The Map is not the prototype
The single most common confusion: people call the map a “prototype” or a “class”. It is neither. The prototype is the object you get when you look up a missing property, and it is reachable from any object. The map is the description of an object - its layout - and it is reachable from every object in the heap.
V8 keeps them in separate fields of the map itself, and the field names make the distinction obvious:
// src/objects/map.tq:89
prototype: JSPrototype;
constructor_or_back_pointer_or_native_context: Object;
@if(V8_ENABLE_WEBASSEMBLY) instance_descriptors: DescriptorArray
|WasmStruct;
@ifnot(V8_ENABLE_WEBASSEMBLY) instance_descriptors: DescriptorArray;
// Wasm maps with custom descriptors overload the "dependent_code" field to
// store their direct supertype map (i.e. their type's canonical RTT).
@if(V8_ENABLE_WEBASSEMBLY) dependent_code: DependentCode|Map;
@ifnot(V8_ENABLE_WEBASSEMBLY) dependent_code: DependentCode;
prototype_validity_cell: Zero|Cell;
transitions_or_prototype_info: Map|Weak<Map>|TransitionArray|PrototypeInfo|
PrototypeSharedClosureInfo|Zero;The prototype lives in prototype. The shape lives in
instance_descriptors. The last field is the interesting one: depending on
what kind of map this is, that single slot holds either the map’s outgoing
transitions (a Map, a Weak<Map> or a TransitionArray) or, for a
prototype map, a PrototypeInfo. The header comment in map.h spells out
the union:
// src/objects/map.h:250
// +---------------+-------------------------------------------------+
// | TaggedPointer | If Map is a prototype map: |
// | | [prototype_info] |
// | | Else: |
// | | [raw_transitions] |
// +---------------+-------------------------------------------------+
A prototype map is a map that describes an object which is used as a
prototype. Such an object is read a lot and written rarely, so V8 optimizes
its layout differently - and it needs the extra PrototypeInfo slot, so the
transitions have to move out of the way.
What a map actually contains
The map itself is small and packed. map.tq defines three bit-field groups
plus a handful of bytes:
// src/objects/map.tq:78
instance_size_in_words: uint8;
inobject_properties_start_or_constructor_function_index: uint8;
used_or_unused_instance_size_in_words: uint8;
visitor_id: uint8;
instance_type: InstanceType;
bit_field: MapBits1;
bit_field2: MapBits2;
bit_field3: MapBits3;bit_field3 is where the interesting bookkeeping lives:
// src/objects/map.tq:28
bitfield struct MapBits3 extends uint32 {
enum_length: int32: 10 bit;
number_of_own_descriptors: int32: 10 bit;
is_prototype_map: bool: 1 bit;
is_dictionary_map: bool: 1 bit;
owns_descriptors: bool: 1 bit;
is_in_retained_map_list: bool: 1 bit;
is_deprecated: bool: 1 bit;
is_unstable: bool: 1 bit;
is_migration_target: bool: 1 bit;
is_extensible: bool: 1 bit;
may_have_interesting_properties: bool: 1 bit;
construction_counter: int32: 3 bit;
}is_deprecated is the “this shape is stale, stop using it” flag.
is_dictionary_map means the object gave up on fast properties and moved to a
name dictionary. construction_counter drives in-object slack tracking - the
mechanism that decides how much space to reserve inside the object itself for
future properties.
The instance_size_in_words byte plus the inobject_properties_start byte
are the reason V8 can do obj.x in one load: the map says “the object’s
in-object properties start at word 2, x is the third field, so it’s at
address this + 8”.
The descriptor array is where the shape lives
The map does not itself store property names. It points at a
DescriptorArray, and the layout of that array is documented right in the
header:
// src/objects/descriptor-array.h:73
// A DescriptorArray is a custom array that holds instance descriptors.
// It has the following layout:
// Header:
// [16:0 bits]: number_of_all_descriptors (including slack)
// [32:16 bits]: number_of_descriptors
// [64:32 bits]: raw_gc_state (used by GC)
// [kEnumCacheOffset]: enum cache
// Elements:
// [kHeaderSize + 0]: first key (and internalized String)
// [kHeaderSize + 1]: first descriptor details (see PropertyDetails)
// [kHeaderSize + 2]: first value for constants / Tagged<Smi>(1) when not
// used
// Slack:
// [kHeaderSize + number of descriptors * 3]: start of slack
// The "value" fields store either values or field types. A field type is either
// FieldType::None(), FieldType::Any() or a weak reference to a Map. All other
// references are strong.
Three words per property: key, details, value. The “details” word packs the property kind, whether it lives in a field or in the dictionary, whether it is const, and the representation of the value:
// src/objects/property-details.h:97
enum Kind {
kNone,
kSmi,
kDouble,
kHeapObject,
kTagged,
// This representation is used for WasmObject fields and basically means
// that the actual field type information must be taken from the Wasm RTT
// associated with the map.
kWasmValue,
kNumRepresentations
};kSmi means “this field is known to hold a small integer”, so it can be
stored unboxed in the object. That is a layout decision, and it is baked
into the map - which means changing a field from kSmi to kDouble is a
map change, and possibly a deprecation.
Note that the value slot of a data field holds a FieldType, not a value.
FieldType can be Any, None, or a weak reference to another Map whose
own field types it inherits. That weak reference is how V8 encodes
“this field has the same type as that field over there” without keeping the
other map alive.
Adding a property: the transition tree
Here is the core of the whole design. Adding x to an object does not
allocate a new object or touch the object’s memory - it looks for (or
creates) a child map of the current one:
// src/objects/map.cc:2096
MaybeHandle<Map> maybe_transition = TransitionsAccessor::SearchTransition(
isolate, map, *name, PropertyKind::kData, attributes);
Handle<Map> transition;
if (maybe_transition.ToHandle(&transition)) {
InternalIndex descriptor = transition->LastAdded();
DCHECK_EQ(attributes, transition->instance_descriptors()
->GetDetails(descriptor)
.attributes());
return UpdateDescriptorForValue(isolate, transition, descriptor, constness,
value);
}If a transition for that name already exists, the existing child map is reused. This is the whole point: the map is not per-object, it is per shape history.
If no transition exists, a new child map is built. Map::CopyWithField
does the work:
// src/objects/map.cc:584
MaybeHandle<Map> Map::CopyWithField(Isolate* isolate, DirectHandle<Map> map,
DirectHandle<Name> name,
DirectHandle<FieldType> type,
PropertyAttributes attributes,
PropertyConstness constness,
Representation representation,
TransitionFlag flag) {
DCHECK(map->instance_descriptors()
->Search(*name, map->NumberOfOwnDescriptors())
.is_not_found());
// Ensure the descriptor array does not get too big.
if (map->NumberOfOwnDescriptors() >= kMaxNumberOfDescriptors) {
return MaybeHandle<Map>();
}and the new map is linked into the tree with Map::ConnectTransition, which
also handles the “the parent no longer owns its descriptors” bookkeeping:
// src/objects/map.cc:1643
if (!IsUndefined(parent->GetBackPointer())) {
parent->set_owns_descriptors(false);
} else if (!parent->IsDetached(isolate)) {
// |parent| is initial map and it must not contain descriptors in the
// descriptors array that do not belong to the map.
DCHECK_EQ(parent->NumberOfOwnDescriptors(),
parent->instance_descriptors()->number_of_descriptors());
}
if (parent->IsDetached(isolate) && !force_connect) {
DCHECK(child->IsDetached(isolate));
if (v8_flags.log_maps) {
LOG(isolate, MapEvent("Transition", parent, child, "prototype", name));
}
} else {
TransitionsAccessor::Insert(isolate, parent, name, child, transition_kind);The tree is navigated via a back pointer stored in the map itself, in the slot that otherwise holds the constructor:
// src/objects/map.h:785
// [back pointer]: points back to the parent map from which a transition
// leads to this map. The field overlaps with the constructor (see above).
inline Tagged<HeapObject> GetBackPointer() const;Map::FindRootMap walks it:
// src/objects/map.cc:773
Tagged<Map> Map::FindRootMap() const {
DisallowGarbageCollection no_gc;
Tagged<Map> result = this;
while (true) {
Tagged<Map> parent;
if (!result->TryGetBackPointer(&parent)) {
#if DEBUG
if (IsJSObjectMap(result)) {
DCHECK_LE(result->NumberOfOwnDescriptors(),
result->instance_descriptors(kAcquireLoad)
->number_of_descriptors());
}
#endif
return result;
}
result = parent;
}
}Why two obj.x = 1 sites make two maps
Now the part that surprises people. Because the tree is keyed on name sequences, the shape depends on the order in which properties were added.
{} (initial map)
|
+x | +y
|
{x}
|
+y
|
{x,y}
{} (initial map)
|
+y | +x
|
{y}
|
+x
|
{y,x}{x} and {y} are different maps, and {x,y} and {y,x} are different
maps. Two objects with the same set of properties but different creation
orders have different maps, and therefore different inline caches, and
therefore different optimized code.
Worse: obj.x = 1 at one site and obj.y = 1 at another site produce two
branches off the same root. Every call site that assigns a property to a
freshly-created object contributes an edge to this tree. In a large
application, the tree can grow wide and deep, and each map is a live heap
object.
V8’s answer to that is not to prevent the tree from growing but to let it die. Transitions are stored in weak references:
// src/objects/transitions.cc:53
// If the map doesn't have any transitions at all yet, install the new one.
if (encoding == kUninitialized || encoding == kMigrationTarget) {
if (flag == SIMPLE_PROPERTY_TRANSITION) {
ReplaceTransitions(isolate, map, MakeWeak(*target));
return;
}
// If the flag requires a full TransitionArray, allocate one.
DirectHandle<TransitionArray> result =
isolate->factory()->NewTransitionArray(1, 0);
result->Set(0, *name, MakeWeak(*target));
ReplaceTransitions(isolate, map, result);
DCHECK_EQ(kFullTransitionArray, GetEncoding(isolate, *result));
return;
}MakeWeak(*target) is the important bit: the child map is reachable only
through a weak reference from the parent. If no object in the heap has that
shape any more, the child map is collected and the edge disappears. The
transition tree is a cache, self-pruning.
There is a fast path worth noting: a map with exactly one outgoing
transition stores it inline in the transitions_or_prototype_info slot as a
Weak<Map>, with no TransitionArray allocation at all. The encoding of
that slot distinguishes the cases.
Deprecation: when a shape has to change underneath live objects
Some changes cannot be done by finding a different map. If a field that was
kSmi needs to hold a double, the object has to be rewritten, not just
re-pointed. V8 handles this by deprecating the transition tree rooted at
the old map:
// src/objects/map.cc:727
void Map::DeprecateTransitionTreeImpl(Isolate* isolate) {
if (is_deprecated()) return;
DisallowGarbageCollection no_gc;
ReadOnlyRoots roots(isolate);
TransitionsAccessor transitions(isolate, this);
transitions.ForEachTransition(
&no_gc,
[&](Tagged<Map> map) { map->DeprecateTransitionTreeImpl(isolate); },
nullptr);
DCHECK(!IsFunctionTemplateInfo(constructor_or_back_pointer()));
set_is_deprecated(true);
if (v8_flags.log_maps) {
LOG(isolate, MapEvent(no_gc, "Deprecate", this, {}));
}
DependentCode::DeoptimizeDependencyGroups<Map>(
isolate, this, DependentCode::kTransitionGroup);
NotifyLeafMapLayoutChange(isolate);
}Two things happen at once. The whole subtree is marked deprecated, so no new
objects will adopt those shapes. And DependentCode::DeoptimizeDependencyGroups
throws away every piece of optimized code that had baked in the old shape.
Objects still holding the old map are migrated lazily, when they are next
touched - Map::Update looks for a migration target, and if there isn’t one
MapUpdater rewrites the object.
This is why “adding a property changes the shape” is a first-class,
observable event in V8, and why Map::TransitionToDataProperty has a special
path for “too many properties”:
// src/objects/map.cc:2114
if (!map->TooManyFastProperties(store_origin)) {
Representation representation = Object::OptimalRepresentation(*value);
DirectHandle<FieldType> type =
Object::OptimalType(*value, isolate, representation);
maybe_map = Map::CopyWithField(isolate, map, name, type, attributes,
constness, representation, flag);
}and TooManyFastProperties is where the “slow properties” threshold lives:
// src/objects/map-inl.h:317
bool Map::TooManyFastProperties(StoreOrigin store_origin) const {
if (UnusedPropertyFields() != 0) return false;
if (store_origin != StoreOrigin::kMaybeKeyed) return false;
if (is_prototype_map()) return false;
int limit = std::max(
{v8_flags.fast_properties_soft_limit.value(), GetInObjectProperties()});
int external =
NumberOfFields(ConcurrencyMode::kSynchronous) - GetInObjectProperties();
return external > limit;
}Past that limit the object is normalized: it stops being a fast-property
object and its properties move into a name dictionary. The map becomes a
dictionary map and the whole fast-path story is over for that object. This is
why obj[computedKey] = v is a cliff and not a slope.
Prototypes use an entirely different mechanism
Because a prototype can be mutated at any time by code you do not control, the “one shape per object” story does not hold for prototypes. V8 handles prototype mutation with validity cells.
A map that serves as a prototype gets a prototype_validity_cell - a Cell
holding a token that means “this prototype chain is still as you saw it”.
Optimized code that inlined a property lookup through a prototype holds a
reference to that cell; when the prototype changes, the cell is invalidated
and the dependent code is deoptimized.
// src/objects/map.cc:2566
// Ensure the prototype is registered with its own prototypes so its cell
// will be invalidated when necessary.
JSObject::LazyRegisterPrototypeUser(validity_cell_holder_map, isolate);The registration is the interesting part. A prototype needs to know who
depends on it, so that it can invalidate them. That is the prototype_users
list - a WeakArrayList stored in the PrototypeInfo:
// src/objects/js-objects.cc:5277
DirectHandle<Map> current_user = user;
DirectHandle<PrototypeInfo> current_user_info =
Map::GetOrCreatePrototypeInfo(user, isolate);
for (PrototypeIterator iter(isolate, user); !iter.IsAtEnd(); iter.Advance()) {
// Walk up the prototype chain as far as links haven't been registered yet.
if (current_user_info->registry_slot() != PrototypeInfo::UNREGISTERED) {
break;
}Note the walk: a prototype registers itself as a user of its prototype,
recursively. So the user list of A.prototype contains B.prototype if
B.prototype.__proto__ === A.prototype, and the invalidation can walk
downwards:
// src/objects/js-objects.cc:5423
void InvalidatePrototypeChainsInternal(Tagged<Map> map) {
// We handle linear prototype chains by looping, and multiple children
// by recursion, in order to reduce the likelihood of running into stack
// overflows. So, conceptually, the outer loop iterates the depth of the
// prototype tree, and the inner loop iterates the breadth of a node.
Tagged<Map> next_map;
for (; !map.is_null(); map = next_map, next_map = {}) {
InvalidateOnePrototypeValidityCellInternal(map);
Tagged<PrototypeInfo> proto_info;
if (!map->TryGetPrototypeInfo(&proto_info)) return;
if (!IsWeakArrayList(proto_info->prototype_users())) {
return;
}This is the mechanism behind Object.prototype.__proto__ changes, Reflect.set
on a prototype, and class field initializers that shadow a base class
accessor.
Keeping maps alive: the retained map list
We said transitions are weak, so shapes die when unused. But some shapes must survive a GC even if no object currently uses them - for instance because optimized code still references the map, or because a debugger is about to ask about it.
V8’s mechanism for that is a per-context list of retained maps, held weakly but with an age counter:
// src/heap/heap.cc:6526
void Heap::AddRetainedMaps(DirectHandle<NativeContext> context,
base::Vector<const IndirectHandle<Map>> maps) {Each entry stores the map weakly plus a “retain for N more GCs” counter
(v8_flags.retain_maps_for_n_gc), and the map sets its own
is_in_retained_map_list bit so it is not added twice. When the list
overflows, CompactRetainedMaps drops the entries whose weak references
have cleared.
Trade-offs
What it buys. Property access becomes a single load at a compile-time
constant offset. Inline caches become a pointer comparison against a map.
Two objects with the same shape share one map and one set of compiled
feedback, so a codebase with thousands of Point objects pays for one
shape, not thousands.
What it costs.
- Memory. Every distinct property-addition sequence is a distinct map, with a descriptor array, a transition array and a back pointer. Programs that build objects in many different orders pay for all of them.
- Shape fragility. Adding one property to a hot object can deoptimize a whole function, because the object’s map changed and the old map got deprecated.
- The dictionary cliff. Once an object crosses the fast-property limit it is normalized into a name dictionary, and it never comes back.
- Weak-reference traffic. Because transitions are weak, every GC has to process and clear them, and a shape that is still in active use has to be re-discovered rather than assumed.
- Prototype mutation is not free either. Every prototype gets a validity cell and a user list, and mutating a prototype invalidates the cells of everything below it.
The bottom line
A V8 map is a description of an object, not a prototype and not a class. It
answers “how big is this object, where does property x live, and what
shape are its elements”. Objects do not own their shape; they point at it.
The shape is reached by walking a transition tree whose edges are property names, so the shape of an object is a function of its history, not of its current property set. That tree is stored with weak references, which makes it self-pruning: a shape nobody uses any more costs nothing. When a shape has to change for objects that already exist, V8 deprecates the tree and deoptimizes the code that assumed the old shape.
The mental model to keep: maps are shared, immutable, and cached; objects are mutable and shareable. The interesting engineering is entirely in deciding when to throw a shared shape away. The feedback vector is where the compiler remembers the shapes it has seen.
The thing people get wrong
“Objects with the same properties have the same hidden class.” No. Objects
with the same properties added in the same order share a map. const a = {}; a.x = 1; a.y = 2; and const b = {}; b.y = 2; b.x = 1; produce two
different maps and two different inline caches, even though a and b are
indistinguishable to Object.keys.
The corollary is the rule of thumb: build objects with all their properties in the same order, in one place, and never add properties conditionally. If you have to build an object whose shape varies, build it through a single factory function so the transition tree stays narrow - or accept the dictionary.
And if you want to see this for yourself, node --allow-natives-syntax plus
%DebugPrint(obj) prints the map, its properties, and its prototype, and
%HasFastProperties(obj) tells you whether the object is still on the fast
path or has already been normalized into a dictionary.