Object structure in JavaScript engines

From a developer's point of view, objects in JavaScript are quite flexible and understandable. We can add, remove and change the properties of an object at our discretion. However, few people think about how objects are stored in memory and processed by JS engines. Can developer actions, directly or indirectly, impact performance and memory consumption? Let's try to understand all this in this article.

Object and its properties

Before diving into the internal structures of the object, let's quickly go over the mat. parts and remember what an object actually is. ECMA-262 specification in section 6.1.7 The Object Type defines an object quite primitively, simply as a set of properties. The properties of an object are represented as a key-value structure, where the key is the name of the property and the value is a set of attributes. All object properties can be divided into two types: data properties And accessor properties.

Data properties

Properties that have the following attributes:

  • [[Value]] – property value

  • [[Writable]]booleandefault false – if false, value [[Value]]cannot be changed

  • [[Enumerable]]booleanfalse by default – if true, the property can participate in iteration via “for-in”

  • [[Configurable]]booleandefault false – if false, the property cannot be deleted, its type cannot be changed from Data property to Accessor property (and vice versa), no attributes can be changed except [[Value]] and exhibiting [[Writable]] to false

Accessor properties

Properties that have the following attributes:

  • [[Get]] – a function that returns the value of an object

  • [[Set]] – a function called when trying to assign a value to a property

  • [[Enumerable]] – identical to Data property

  • [[Configurable]] – identical to Data property

Hidden classes

Thus, according to the specification, in addition to the values ​​themselves, each property of an object must store information about its attributes.

const obj1 = { a: 1, b: 2 };

The above simple object, when viewed by the JavaScript engine, should look something like this.

{
  a: {
    [[Value]]: 1,
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  },
  b: {
    [[Value]]: 2,
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

Let's now imagine that we have two objects that are similar in structure.

const obj1 = { a: 1, b: 2 };
const obj2 = { a: 3, b: 4 };

According to the above, we need to store information about each of the four given properties of these two objects. Sounds a bit wasteful in terms of memory consumption. Also, it is obvious that the configuration of these properties is the same, except for the name of the property and its [[Value]].

All popular JS engines solve this problem using the so-called hidden classes (hidden classes). This concept can often be found in various publications and documentation. However, it overlaps slightly with the concept of JavaScript classes, so engine developers have adopted their own definitions. So, in V8 hidden classes are denoted by the term Maps (which also overlaps with the concept of JavaScript Maps). The Chakra engine used in the Internet Explorer browser uses the term Types. Safari developers, in their JavaScriptCore engine, use the concept Structures. And in the SpiderMonkey engine for Mozilla, hidden classes are called Shapes. The latter, by the way, is also quite popular and is often found in publications, since this concept is unique and difficult to confuse with anything else in JavaScript.

In general, there are many interesting publications about hidden classes on the Internet. In particular, I recommend taking a look at post by Mathias Bienensone of the developers of V8 and Chrome DevTools.

So, the essence of hidden classes is to separate the metainformation and properties of an object into separate, reusable objects and bind such a class to a real object by reference.

In this concept, the example above can be represented as follows. Later we will see what real Maps look like in the V8 engine, but for now I will illustrate it in a conditional form.

Map1 {
  a: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  },
  b: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

ob1 {
  map: Map1,
  values: { a: 1, a: 2 }
}

ob2 {
  map: Map1,
  values: { a: 3, a: 4 }
}

Hidden class inheritance

The concept of hidden classes looks good in the case of objects with the same shape. However, what if the second object has a different structure? In the following example, two objects are not identical in structure, but have an intersection.

const obj1 = { a: 1, b: 2 };
const obj2 = { a: 3, b: 4, c: 5 };

According to the logic described above, two classes with different shapes should appear in memory. However, then the problem of duplicate attributes returns. To avoid this, it is customary for hidden classes to inherit from each other.

Map1 {
  a: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  },
  b: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

Map2 {
  back_pointer: Map1,
  с: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

ob1 {
  map: Map1,
  values: { a: 1, b: 2 }
}

ob2 {
  map: Map2,
  values: { a: 3, b: 4, c: 5 }
}

Here we see that the class Map2 describes only one property and a reference to an object with a “narrower” form.

It is also worth saying that the shape of a hidden class is influenced not only by the set of properties, but also by their order. In other words, the following objects will have different forms of hidden classes.

Map1 {
  a: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  },
  b: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

Map2 {
  b: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  },
  a: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

ob1 {
  map: Map1,
  values: { a: 1, b: 2 }
}

ob2 {
  map: Map2,
  values: { b: 3, a: 4 }
}

If we change the shape of an object after initialization, this also leads to the creation of a new hidden subclass.

const ob1 = { a: 1, b: 2 };
obj1.c = 3;

const obj2 = { a: 4, b: 5, c: 6 };

This example results in the following hidden class structure.

Map1 {
  a: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  },
  b: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

Map2 {
  back_pointer: Map1,
  с: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

Map3 {
  back_pointer: Map1,
  с: {
    [[Writable]]: true,
    [[Enumerable]]: true,
    [[Configurable]]: true,
  }
}

ob1 {
  map: Map2,
  values: { a: 1, b: 2, c: 3 }
}

ob2 {
  map: Map3,
  values: { a: 4, b: 5, c: 6 }
}

Hidden classes in practice

Just above I referred to post by Mathias Bienens about the shapes of an object. However, many years have passed since then. For the purity of the experiment, I decided to check how things are in practice in a real V8 engine.

Let's conduct an experiment using the example given in Matthias's article.

For this we need the built-in internal V8 method – %DebugPrint. Let me remind you that in order to be able to use the built-in methods of the engine, it needs to be launched with the flag --allow-natives-syntax. To see detailed information about JS objects, the engine must be compiled in debug.

d8> const a = {};
d8> a.x = 6;
d8> const b = { x: 6 };
d8>
d8> %DebugPrint(a);
DebugPrint: 0x1d47001c9425: [JS_OBJECT_TYPE]
 - map: 0x1d47000da9a9 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x1d47000c4b11 <Object map = 0x1d47000c414d>
 - elements: 0x1d47000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x1d47000006cd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x1d4700002b91: [String] in ReadOnlySpace: #x: 6 (const data field 0), location: in-object
 }
0x1d47000da9a9: [Map] in OldSpace
 - map: 0x1d47000c3c29 <MetaMap (0x1d47000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 3
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x1d47000c4945 <Map[28](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x1d47000da9f1 <Cell value= 0>
 - instance descriptors (own) #1: 0x1d47001cb111 <DescriptorArray[1]>
 - prototype: 0x1d47000c4b11 <Object map = 0x1d47000c414d>
 - constructor: 0x1d47000c4655 <JSFunction Object (sfi = 0x1d4700335385)>
 - dependent code: 0x1d47000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

We see the object alocated at 0x1d47001c9425. A hidden class with an address is attached to the object 0x1d47000da9a9. The value is stored inside the object itself #x: 6. The property attributes are located in the bound hidden class in the field instance descriptors. Just in case, let's look at the array of descriptors at the specified address.

d8> %DebugPrintPtr(0x1d47001cb111)
DebugPrint: 0x1d47001cb111: [DescriptorArray]
 - map: 0x1d470000062d <Map(DESCRIPTOR_ARRAY_TYPE)>
 - enum_cache: 1
   - keys: 0x1d47000dacad <FixedArray[1]>
   - indices: 0x1d47000dacb9 <FixedArray[1]>
 - nof slack descriptors: 0
 - nof descriptors: 1
 - raw gc state: mc epoch 0, marked 0, delta 0
  [0]: 0x1d4700002b91: [String] in ReadOnlySpace: #x (const data field 0:s, p: 0, attrs: [WEC]) @ Any
0x1d470000062d: [Map] in ReadOnlySpace
 - map: 0x1d47000004c5 <MetaMap (0x1d470000007d <null>)>
 - type: DESCRIPTOR_ARRAY_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x1d4700000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x1d4700000701 <DescriptorArray[0]>
 - prototype: 0x1d470000007d <null>
 - constructor: 0x1d470000007d <null>
 - dependent code: 0x1d47000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

32190781763857

The descriptor array contains an element #xwhich stores all the necessary information about an object's property.

Now let's look at the link back pointer with address 0x1d47000c4945.

d8> %DebugPrintPtr(0x1d47000c4945)
DebugPrint: 0x1d47000c4945: [Map] in OldSpace
 - map: 0x1d47000c3c29 <MetaMap (0x1d47000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 4
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - back pointer: 0x1d4700000061 <undefined>
 - prototype_validity cell: 0x1d4700000a31 <Cell value= 1>
 - instance descriptors (own) #0: 0x1d4700000701 <DescriptorArray[0]>
 - transitions #1: 0x1d47000da9d1 <TransitionArray[6]>Transition array #1:
     0x1d4700002b91: [String] in ReadOnlySpace: #x: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x1d47000da9a9 <Map[28](HOLEY_ELEMENTS)>

 - prototype: 0x1d47000c4b11 <Object map = 0x1d47000c414d>
 - constructor: 0x1d47000c4655 <JSFunction Object (sfi = 0x1d4700335385)>
 - dependent code: 0x1d47000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0
0x1d47000c3c29: [MetaMap] in OldSpace
 - map: 0x1d47000c3c29 <MetaMap (0x1d47000c3c79 <NativeContext[285]>)>
 - type: MAP_TYPE
 - instance size: 40
 - native_context: 0x1d47000c3c79 <NativeContext[285]>

32190780688709

This hidden class is a representation of an empty object. Its descriptor array is empty, and the link back pointer not determined.

Now let's look at the object b.

d8> %DebugPrint(b)    
DebugPrint: 0x1d47001cb169: [JS_OBJECT_TYPE]
 - map: 0x1d47000dab39 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x1d47000c4b11 <Object map = 0x1d47000c414d>
 - elements: 0x1d47000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x1d47000006cd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x1d4700002b91: [String] in ReadOnlySpace: #x: 6 (const data field 0), location: in-object
 }
0x1d47000dab39: [Map] in OldSpace
 - map: 0x1d47000c3c29 <MetaMap (0x1d47000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - unused property fields: 0
 - elements kind: HOLEY_ELEMENTS
 - enum length: 1
 - stable_map
 - back pointer: 0x1d47000dab11 <Map[16](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x1d4700000a31 <Cell value= 1>
 - instance descriptors (own) #1: 0x1d47001cb179 <DescriptorArray[1]>
 - prototype: 0x1d47000c4b11 <Object map = 0x1d47000c414d>
 - constructor: 0x1d47000c4655 <JSFunction Object (sfi = 0x1d4700335385)>
 - dependent code: 0x1d47000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

{x: 6}

Here too, the property value is stored in the object itself, and the property attributes are stored in a hidden class descriptor array. However, please note that the link back pointer here is also not empty, although in the above diagram the parent class should not be there. Let's take a look at the class at this link.

d8> %DebugPrintPtr(0x1d47000dab11)
DebugPrint: 0x1d47000dab11: [Map] in OldSpace
 - map: 0x1d47000c3c29 <MetaMap (0x1d47000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - unused property fields: 1
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - back pointer: 0x1d4700000061 <undefined>
 - prototype_validity cell: 0x1d4700000a31 <Cell value= 1>
 - instance descriptors (own) #0: 0x1d4700000701 <DescriptorArray[0]>
 - transitions #1: 0x1d47000dab39 <Map[16](HOLEY_ELEMENTS)>
     0x1d4700002b91: [String] in ReadOnlySpace: #x: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x1d47000dab39 <Map[16](HOLEY_ELEMENTS)>
 - prototype: 0x1d47000c4b11 <Object map = 0x1d47000c414d>
 - constructor: 0x1d47000c4655 <JSFunction Object (sfi = 0x1d4700335385)>
 - dependent code: 0x1d47000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0
0x1d47000c3c29: [MetaMap] in OldSpace
 - map: 0x1d47000c3c29 <MetaMap (0x1d47000c3c79 <NativeContext[285]>)>
 - type: MAP_TYPE
 - instance size: 40
 - native_context: 0x1d47000c3c79 <NativeContext[285]>

32190780779281

The class looks exactly the same as the empty object hidden class above, but with a different address. This means that it is actually a duplicate of the previous class. So the actual structure of this example is as follows.

This is the first deviation from the theory. To understand why we need another hidden class for an empty object, we need an object with several properties. Let's assume that the original object initially has several properties. It won’t be very convenient to explore such an object via the command line, so we’ll use Chrome DevTools. For convenience, let's close the object inside the function context.

function V8Snapshot() {
  this.obj1 = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 };
}

const v8Snapshot1 = new V8Snapshot();

The memory snapshot shows 6 inherited classes for this object, which is equal to the number of properties of the object. And this is the second deviation from the theory, which assumed that an object initially had one hidden class, the form of which contained the properties with which it was initialized. The reason for this lies in the fact that in practice we operate not with one single object, but with several, maybe even tens, hundreds or thousands. In such realities, searching and rebuilding class trees can be quite expensive. So, we come to another concept of JS engines.

Transitions

Let's take the example above and add another object with a similar shape.

function V8Snapshot() {
  this.obj1 = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 };
  this.obj2 = { a: 1, b: 2, d: 3, c: 4, e: 5, f: 6 };
}

const v8Snapshot1 = new V8Snapshot();

At first glance, the shape of the second object is very similar, but the properties c And d have a different order.

In descriptor arrays, these properties will have different indexes. Class with address @101187 has two heirs.

For greater clarity, let’s run the script log through V8 System Analyzer.

Here you can clearly see that the original form { a, b, c, d, e, f } has an extension at the point c. However, the interpreter won't know about this until it starts initializing the second object. To create a new class tree, the engine would have to find a suitable class in the heap, break it into parts, form new classes and reassign them to all created objects. To avoid this, the V8 developers decided to split the class into a set of minimal forms right away, even during the first initialization of the object, starting with an empty class.

{}
{ a }
{ a, b }
{ a, b, c }
{ a, b, c, d }
{ a, b, c, d, e }
{ a, b, c, d, e, f }

Creating a new hidden class by adding or changing some property is called transition (transition). In our case, the first object will initially have 6 transitions (+a, +b, +c, etc.).

This approach allows: a) to easily find a suitable starting form for a new object, b) there is no need to rebuild anything, it is enough to create a new class with a link to a suitable minimal form.

              {}
              { a }
              { a, b }

{ a, b, c }            { a, b, d }
{ a, b, c, d }         { a, b, d, c }
{ a, b, c, d, e }      { a, b, d, c, e }
{ a, b, c, d, e, f }   { a, b, d, c, e, f }

Internal and external properties of an object

Consider the following example:

d8> const obj1 = { a: 1 };
d8> obj1.b = 2;
d8>
d8> %DebugPrint(obj1);
DebugPrint: 0x2387001c942d: [JS_OBJECT_TYPE]
 - map: 0x2387000dabb1 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2387000c4b11 <Object map = 0x2387000c414d>
 - elements: 0x2387000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x2387001cb521 <PropertyArray[3]>
 - All own properties (excluding elements): {
    0x238700002a21: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x238700002a31: [String] in ReadOnlySpace: #b: 2 (const data field 1), location: properties[0]
 }
0x2387000dabb1: [Map] in OldSpace
 - map: 0x2387000c3c29 <MetaMap (0x2387000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - unused property fields: 2
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x2387000d9ca1 <Map[16](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x2387000dabd9 <Cell value= 0>
 - instance descriptors (own) #2: 0x2387001cb4f9 <DescriptorArray[2]>
 - prototype: 0x2387000c4b11 <Object map = 0x2387000c414d>
 - constructor: 0x2387000c4655 <JSFunction Object (sfi = 0x238700335385)>
 - dependent code: 0x2387000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0
 
 {a: 1, b: 2}

If we look closely at the value set of this object, we see that the property a marked as in-objectand the property b – as an array element properties.

- All own properties (excluding elements): {
    ... #a: 1 (const data field 0), location: in-object
    ... #b: 2 (const data field 1), location: properties[0]
 }

This example shows that some properties are stored directly inside the object itself (“in-object”), and some properties are stored in external property storage. This is due to the fact that, according to the specification ECMA-262, JavaScript objects do not have a fixed size. Adding or removing properties to an object changes its size. Because of this, the question arises: what area of ​​​​memory to allocate for the object? Moreover, how to expand the already allocated memory of an object? The V8 developers resolved these issues in the following way.

Internal Properties

At the time of initial initialization, the object literal is already parsed, and the AST tree contains information about the properties specified at the time of initialization. A set of such properties is placed directly inside the object, which allows you to access them as quickly as possible and at minimal cost. These properties are called in-object.

External properties

Properties that were added after initialization can no longer be placed inside the object, since memory for the object has already been allocated. In order not to waste resources on re-allocating the entire object, the engine places such properties in external storage, in this case, in an external array of properties, a reference to which is already available inside the object. Such properties are called external or normal (this is the term that can often be found in materials from V8 developers). Access to such properties is slightly less fast, since it requires resolving the link to the storage and retrieving the property by index. But this is much more efficient than re-allocating the entire object.

Fast and slow properties

The external property from the example above, as we just looked at, is stored in an external property array associated directly with our object. The data format in this array is identical to the format of the internal properties. In other words, only property values ​​are stored there, and metainformation about them is placed in a descriptor array, which also contains information about internal properties. In fact, external properties differ from internal ones only in the place of their storage. Both of them can be conditionally considered fast properties. However, let me remind you that JavaScript is a living and flexible programming language. The developer has the ability to add, delete and change the properties of an object at his own discretion. Actively changing a set of properties can result in significant CPU time consumption. To optimize this process, V8 supports so-called “slow” properties. The essence of the slow properties is to use a different type of external storage. Instead of an array of values, properties are placed in a separate dictionary object along with all their attributes. Both the values ​​and attributes of such properties are accessed by their name, which serves as a dictionary key.

d8> delete obj1.a;
d8>
d8> %DebugPrint(obj1)
DebugPrint: 0x2387001c942d: [JS_OBJECT_TYPE]
 - map: 0x2387000d6071 <Map[12](HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x2387000c4b11 <Object map = 0x2387000c414d>
 - elements: 0x2387000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x2387001cc1d9 <NameDictionary[30]>
 - All own properties (excluding elements): {
   b: 2 (data, dict_index: 2, attrs: [WEC])
 }
0x2387000d6071: [Map] in OldSpace
 - map: 0x2387000c3c29 <MetaMap (0x2387000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 12
 - inobject properties: 0
 - unused property fields: 0
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - dictionary_map
 - may_have_interesting_properties
 - back pointer: 0x238700000061 <undefined>
 - prototype_validity cell: 0x238700000a31 <Cell value= 1>
 - instance descriptors (own) #0: 0x238700000701 <DescriptorArray[0]>
 - prototype: 0x2387000c4b11 <Object map = 0x2387000c414d>
 - constructor: 0x2387000c4655 <JSFunction Object (sfi = 0x238700335385)>
 - dependent code: 0x2387000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

{b: 2}

We have removed the property obj1.a. Even though the property was internal, we completely changed the form of the hidden class. To be precise, we shortened it, which is different from the typical form expansion. This means that the form tree has become shorter, hence the descriptors and value array must also be rebuilt. All these operations require certain time resources. To avoid this, the engine changes the way it stores object properties to a slower way using a dictionary object. In this example, the dictionary (NameDictionary) is located at 0x2387001cc1d9.

d8> %DebugPrintPtr(0x2387001cc1d9)
DebugPrint: 0x2387001cc1d9: [NameDictionary]
 - FixedArray length: 30
 - elements: 1
 - deleted: 1
 - capacity: 8
 - elements: {
              7: b -> 2 (data, dict_index: 2, attrs: [WEC])
 }
0x238700000ba1: [Map] in ReadOnlySpace
 - map: 0x2387000004c5 <MetaMap (0x23870000007d <null>)>
 - type: NAME_DICTIONARY_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x238700000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x238700000701 <DescriptorArray[0]>
 - prototype: 0x23870000007d <null>
 - constructor: 0x23870000007d <null>
 - dependent code: 0x2387000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

39062729441753

Arrays

According to section 23.1 Array Objects specifications, an array is an object whose keys are integers from 0 before 2**32 - 2. On the one hand, it seems that from the point of view of hidden classes, an array is no different from a regular object. However, in practice, arrays can be quite large. What if there are thousands of elements in the array? Will a separate hidden class be created for each element? Let's see what a hidden array class actually looks like.

d8> arr = [];
d8> arr[0] = 1;
d8> arr[1] = 2;
d8>
d8> %DebugPrint(arr); 
DebugPrint: 0x24001c9421: [JSArray]
 - map: 0x0024000ce6b1 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
 - prototype: 0x0024000ce925 <JSArray[0]>
 - elements: 0x0024001cb125 <FixedArray[17]> [PACKED_SMI_ELEMENTS]
 - length: 2
 - properties: 0x0024000006cd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x2400000d41: [String] in ReadOnlySpace: #length: 0x00240030f6f9 <AccessorInfo name= 0x002400000d41 <String[6]: #length>, data= 0x002400000061 <undefined>> (const accessor descriptor), location: descriptor
 }
 - elements: 0x0024001cb125 <FixedArray[17]> {
           0: 1
           1: 2
        2-16: 0x0024000006e9 <the_hole_value>
 }
0x24000ce6b1: [Map] in OldSpace
 - map: 0x0024000c3c29 <MetaMap (0x0024000c3c79 <NativeContext[285]>)>
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - unused property fields: 0
 - elements kind: PACKED_SMI_ELEMENTS
 - enum length: invalid
 - back pointer: 0x002400000061 <undefined>
 - prototype_validity cell: 0x002400000a31 <Cell value= 1>
 - instance descriptors #1: 0x0024000cef3d <DescriptorArray[1]>
 - transitions #1: 0x0024000cef59 <TransitionArray[4]>Transition array #1:
     0x002400000e05 <Symbol: (elements_transition_symbol)>: (transition to HOLEY_SMI_ELEMENTS) -> 0x0024000cef71 <Map[16](HOLEY_SMI_ELEMENTS)>

 - prototype: 0x0024000ce925 <JSArray[0]>
 - constructor: 0x0024000ce61d <JSFunction Array (sfi = 0x2400335da5)>
 - dependent code: 0x0024000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

[1, 2]

As we can see, the hidden class of this object has a link back pointer empty, which means there is no parent class, although we have added two elements. The fact is that the hidden class of any array always has a single form JS_ARRAY_TYPE. This is a special hidden class that has only one property in its descriptors – length. Array elements are located inside an object in a structure FixedArray. In reality, hidden array classes can still be inherited because the elements themselves can have different data types, and the keys, depending on the number, can be stored in different ways to optimize access to them. In this article I will not consider in detail all possible transitions within arrays, since this is a topic for a separate article. However, it is worth keeping in mind that various non-standard manipulations with array keys can lead to the creation of a class tree for all or part of the elements.

d8> const arr = [];
d8> arr[-1] = 1;
d8> arr[2**32 - 1] = 2;
d8>
d8> %DebugPrint(arr)
DebugPrint: 0xe0b001c98c9: [JSArray]
 - map: 0x0e0b000dacc1 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
 - prototype: 0x0e0b000ce925 <JSArray[0]>
 - elements: 0x0e0b000006cd <FixedArray[0]> [PACKED_SMI_ELEMENTS]
 - length: 0
 - properties: 0x0e0b001cb5f1 <PropertyArray[3]>
 - All own properties (excluding elements): {
    0xe0b00000d41: [String] in ReadOnlySpace: #length: 0x0e0b0030f6f9 <AccessorInfo name= 0x0e0b00000d41 <String[6]: #length>, data= 0x0e0b00000061 <undefined>> (const accessor descriptor), location: descriptor
    0xe0b000dab35: [String] in OldSpace: #-1: 1 (const data field 0), location: properties[0]
    0xe0b000daca9: [String] in OldSpace: #4294967295: 2 (const data field 1), location: properties[1]
 }
0xe0b000dacc1: [Map] in OldSpace
 - map: 0x0e0b000c3c29 <MetaMap (0x0e0b000c3c79 <NativeContext[285]>)>
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - unused property fields: 1
 - elements kind: PACKED_SMI_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x0e0b000dab45 <Map[16](PACKED_SMI_ELEMENTS)>
 - prototype_validity cell: 0x0e0b000dab95 <Cell value= 0>
 - instance descriptors (own) #3: 0x0e0b001cb651 <DescriptorArray[3]>
 - prototype: 0x0e0b000ce925 <JSArray[0]>
 - constructor: 0x0e0b000ce61d <JSFunction Array (sfi = 0xe0b00335da5)>
 - dependent code: 0x0e0b000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

[]

In the example above, both elements -1 And 232 - 1 are not included in the range of possible array indices [0 .. 232 - 2] and were declared as ordinary object properties with corresponding forms and the generation of a tree of hidden classes.

Another abnormal situation is possible if you try to change index attributes. In order for items to be stored in fast storage, all indexes must have the same configuration. Attempting to change the attributes of any of the indexes will not create a separate property, but will result in changing the storage type to slow, which will store not only the values, but also the attributes of each index. Essentially the same rule applies here as in the case of slow object properties.

d8> const arr = [1];
d8> Object.defineProperty(arr, '0', { value: 2, writable:  false });      
d8> arr.push(3);
d8>
d8> %DebugPrint(arr);
DebugPrint: 0x29ee001c9425: [JSArray]
 - map: 0x29ee000dad05 <Map[16](DICTIONARY_ELEMENTS)> [FastProperties]
 - prototype: 0x29ee000ce925 <JSArray[0]>
 - elements: 0x29ee001cb391 <NumberDictionary[16]> [DICTIONARY_ELEMENTS]
 - length: 2
 - properties: 0x29ee000006cd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x29ee00000d41: [String] in ReadOnlySpace: #length: 0x29ee0030f6f9 <AccessorInfo name= 0x29ee00000d41 <String[6]: #length>, data= 0x29ee00000061 <undefined>> (const accessor descriptor), location: descriptor
 }
 - elements: 0x29ee001cb391 <NumberDictionary[16]> {
   - requires_slow_elements
   0: 2 (data, dict_index: 0, attrs: [_EC])
   1: 3 (data, dict_index: 0, attrs: [WEC])
 }
0x29ee000dad05: [Map] in OldSpace
 - map: 0x29ee000c3c29 <MetaMap (0x29ee000c3c79 <NativeContext[285]>)>
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - unused property fields: 0
 - elements kind: DICTIONARY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x29ee000cf071 <Map[16](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x29ee00000a31 <Cell value= 1>
 - instance descriptors (own) #1: 0x29ee000cef3d <DescriptorArray[1]>
 - prototype: 0x29ee000ce925 <JSArray[0]>
 - constructor: 0x29ee000ce61d <JSFunction Array (sfi = 0x29ee00335da5)>
 - dependent code: 0x29ee000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

[2, 3]

Bottom line

In this article, we took a closer look at methods for storing object properties, the concepts of a hidden class, object forms, object descriptors, internal and external properties, as well as fast and slow ways to store them. Let us now briefly recall the main theses and conclusions.

  • Every object in JavaScript has its own main inner class and a hidden class that describes its shape.

  • Hidden classes inherit each other and are arranged into class trees. Object Shape { a: 1 } will be the parent of the object's shape { a: 1, b: 2 }.

  • The order of the properties matters. Objects { a: 1, b: 2 } And { b: 2, a: 1 } will have two different forms.

  • The successor class stores a reference to the parent class and information about what was changed (transition).

  • In the class tree of each object, the number of levels is not less than the number of properties in the object.

  • The fastest properties of an object will be those declared at initialization. In the following example, access the property obj1.a will be faster than obj2.a.

const obj1 = { a: undefined };
obj1.a = 1; // <- "a" - in-object свойство

const obj2 = {};
obj2.a = 1; // <- "a" - внешнее свойство
  • Unusual changes to an object's shape, such as deleting a property, can cause the property storage type to change to slow. In the following example obj1 will change its type to NamedDictionaryand accessing its properties will be significantly slower than accessing properties obj2.

const obj1 = { a: 1, b: 2 };
delete obj1.a; // изменяет тип хранения на NameDictionary 

const obj2 = { a: 1, b: 2 };
obj2.a = undefined; // не меняет тип хранения свойств
  • An array is a regular class whose form is { length: [W__] }. Array elements are stored in special structures, references to which are placed inside the object. Adding and removing array elements does not increase the class tree.

const arr = [];
arr[0] = 1; // новый элемент массива не увеличивает дерево классов

const obj = {};
obj1[0] = 1; // каждое новое свойство объекта увеличивает дерево классов
  • Using unusual keys in an array, such as non-numeric or out-of-range keys [0 .. 232 - 2]), leads to the creation of new forms in the class tree.

const arr = [];
arr[-1] = 1;
arr[2**32 - 1] = 2;
// Приведет к образованию дерева форм
// { length } => { length, [-1] } => { length, [-1], [2**32 - 1] }
const arr = [1, 2, 3];
// { elements: {
//   #0: 1,
//   #1: 2,
//   #2: 3
// }}

Object.defineProperty(arr, '0', { writable: false };
// { elements: {
//   #0: { value: 1, attrs: [_EC] }, 
//   #1: { value: 2, attrs: [WEC] },
//   #2: { value: 3, attrs: [WEC] }
// }}

Read this and my other articles in my channel:

EN: https://t.me/frontend_almanac_ru
EN: https://t.me/frontend_almanac

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *