С точки зрения разработчика, объекты в JavaScript довольно гибкие и понятные. Мы можем добавлять, удалять и изменять свойства объекта по своему усмотрению. Однако мало кто задумывается о том, как объекты хранятся в памяти и обрабатываются JS-движками. Могут ли действия разработчика, прямо или косвенно, оказать влияние на производительность и потребление памяти? Попробуем разобраться во всем этом в этой статье.

Объект и его свойства

Прежде чем погрузиться во внутренние структуры объекта, давайте быстро пройдемся по мат. части и вспомним, что вообще представляет собой объект. Спецификация ECMA-262 в разделе 6.1.7 The Object Type определяет объект довольно примитивно, просто как набор свойств. Свойства объекта представлены как структура "ключ-значение", где ключ (key) является названием свойства, а значение (value) - набором атрибутов. Все свойства объекта можно условно разделить на два типа: data properties и accessor properties.

Data properties

Свойства, имеющие следующие атрибуты:

  • [[Value]] - значение свойства

  • [[Writable]] - boolean, по умолчанию false - если false, значение [[Value]] не может быть изменено

  • [[Enumerable]] - boolean, по умолчанию false - если true, свойство может участвовать в итерировании посредством "for-in"

  • [[Configurable]] - boolean, по умолчанию false - если false, свойство не может быть удалено, нельзя изменить его тип с Data property на Accessor property (и наоборот), нельзя изменить никакие атрибуты, кроме [[Value]] и выставления [[Writable]] в false

Accessor properties

Свойства, имеющие следующие атрибуты:

  • [[Get]] - функция, возвращающая значение объекта

  • [[Set]] - функция, вызываемая при попытке присвоить значение свойству

  • [[Enumerable]] - идентичен Data property

  • [[Configurable]] - идентичен Data property

Скрытые классы

Таким образом, согласно спецификации, помимо самих значений, каждое свойство объекта должно хранить информацию о своих атрибутах.

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

Приведенный выше простой объект, в представлении движка JavaScript, должен выглядеть примерно следующим образом.

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

Давайте теперь представим, что у нас есть два схожих по структуре объекта.

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

Согласно вышесказанному, нам нужно хранить информацию о каждом из четырех приведенных свойств этих двух объектов. Звучит несколько расточительно с точки зрения потребления памяти. Кроме того, очевидно, что конфигурация этих свойств одинакова, за исключением названия свойства и его [[Value]].

Данную проблему все популярные JS-движки решают с помощью так называемых скрытых классов (hidden classes). Это понятие часто можно встретить в разного рода публикациях и документации. Однако оно немного пересекается с понятием JavaScript-классов, поэтому разработчики движков приняли свои собственные определения. Так, в V8 скрытые классы обозначаются термином Maps (что также пересекается с понятием JavaScript Maps). В движке Chakra, используемом в браузере Internet Explorer, применяется термин Types. Разработчики Safari, в своем движке JavaScriptCore, используют понятие Structures. А в движке SpiderMonkey для Mozilla скрытые классы называются Shapes. Последнее, кстати, тоже довольно популярно и не редко встречается в публикациях, так как это понятие уникально и его трудно перепутать с чем-либо другим в JavaScript.

Вообще, про скрытые классы в сети есть много интересных публикаций. В частности, рекомендую заглянуть в пост Матиаса Биненса, одного из разработчиков V8 и Chrome DevTools.

Итак, суть скрытых классов заключается в том, чтобы выделить метаинформацию и свойства объекта в отдельные, переиспользуемые объекты и привязывать такой класс к реальному объекту по ссылке.

В данной концепции пример выше можно представить следующим образом. Позже мы посмотрим, как выглядят реальные Maps в движке V8, а пока проиллюстрирую в условном виде.

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 }
}

Наследование скрытого класса

Концепция скрытых классов выглядит неплохо в случае объектов с одинаковой формой. Однако, что делать, если второй объект имеет другую структуру? В следующем примере два объекта не идентичны друг другу по структуре, но имеют пересечение.

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

По описанной выше логике в памяти должно появиться два класса с разной формой. Однако тогда проблема дублирования атрибутов возвращается. Дабы избежать этого, скрытые классы принято наследовать друг от друга.

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 }
}

Здесь мы видим, что класс Map2 описывает только одно свойство и ссылку на объект с более "узкой" формой.

Стоит также сказать, что на форму скрытого класса влияет не только набор свойств, но и их порядок. Другими словами, следующие объекты будут иметь разные формы скрытых классов.

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 }
}

Если мы меняем форму объекта уже после инициализации, это также приводит к созданию нового скрытого класса-наследника.

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

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

Данный пример приводит к следующей структуре скрытых классов.

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 }
}

Скрытые классы на практике

Чуть выше я ссылался на пост Матиаса Биненса о формах объекта. Однако с тех пор прошло много лет. Для чистоты эксперимента я решил проверить, как обстоят дела на практике в реальном движке V8.

Проведем эксперимент на примере, приведенном в статье Матиаса.

Для этого нам понадобится встроенный внутренний методом V8 - %DebugPrint. Напомню, чтобы иметь возможность использовать встроенные методы движка, его нужно запустить с флагом --allow-natives-syntax. Чтобы видеть подробную информацию об объектах JS, движок должен быть скомпилирован в режиме 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

Мы видим объект a, размещенный по адресу 0x1d47001c9425. К объекту привязан скрытый класс с адресом 0x1d47000da9a9. Внутри самого объекта хранится значение #x: 6. Атрибуты свойства расположены в привязанном скрытом классе в поле instance descriptors. На всякий случай, давайте посмотрим на массив дескрипторов по указанному адресу.

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

В массиве дескрипторов имеется элемент #x, который хранит всю необходимую информацию о свойстве объекта.

Теперь давайте посмотрим на ссылку back pointer с адресом 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

Этот скрытый класс является представлением пустого объекта. Массив дескрипторов у него пустой, а ссылка back pointer не определена.

Теперь давайте посмотрим на объект 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}

Здесь также значение свойства хранится в самом объекте, а атрибуты свойства — в массиве дескрипторов скрытого класса. Однако обращу внимание, что ссылка back pointer здесь тоже не пустая, хотя на приведенной схеме класса-родителя тут быть не должно. Давайте посмотрим на класс по этой ссылке.

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

Класс выглядит точно так же, как скрытый класс пустого объекта выше, но с другим адресом. Это означает, что фактически это дубликат предыдущего класса. Таким образом, реальная структура данного примера выглядит следующим образом.

Это первое отклонение от теории. Чтобы понять, зачем нужен еще один скрытый класс для пустого объекта, нам потребуется объект с несколькими свойствами. Предположим, что исходный объект изначально имеет несколько свойств. Исследовать такой объект через командную строку будет не очень удобно, поэтому воспользуемся Chrome DevTools. Для удобства, замкнем объект внутри контекста функции.

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

const v8Snapshot1 = new V8Snapshot();

Слепок памяти показывает 6 наследуемых классов для этого объекта, что равно количеству свойств объекта. И это второе отклонение от теории, согласно которой предполагалось, что объект изначально имеет один скрытый класс, форма которого содержит те свойства, с которыми он был инициализирован. Причина этому кроется в том, что на практике мы оперируем не одним единственным объектом, а несколькими, может быть даже десятками, сотнями или тысячами. В таких реалиях поиск и перестройка деревьев классов могут оказаться весьма дорогими. Итак, мы подошли к еще одному понятию JS движков.

Переходы

Давайте к примеру выше добавим еще один объект со схожей формой.

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();

На первый взгляд, форма второго объекта очень похожа, но свойства c и d имеют другой порядок следования.

В массивах дескрипторов эти свойства будут иметь разные индексы. Класс с адресом @101187 имеет двух наследников.

Для большей наглядности прогоним лог скрипта через V8 System Analyzer.

Здесь хорошо видно, что изначальная форма { a, b, c, d, e, f } имеет расширение в точке c. Однако интерпретатор не узнает об этом, пока не начнет инициализацию второго объекта. Чтобы составить новое дерево классов, движку пришлось бы найти в куче подходящий по форме класс, разбить его на части, сформировать новые классы и переназначить их всем созданным объектам. Чтобы избежать этого, разработчики V8 решили разбивать класс на набор минимальных форм сразу, еще при первой инициализации объекта, начиная с пустого класса.

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

Создание нового скрытого класса с добавлением или изменением какого-либо свойства называется переходом (transition). В нашем случае, у первого объекта изначально будет 6 переходов (+a, +b, +c и т.д.).

Такой подход позволяет: а) легко найти подходящую стартовую форму для нового объекта, б) нет необходимости ничего перестраивать, достаточно создать новый класс с ссылкой на подходящую минимальную форму.

              {}
              { 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 }

Внутренние и внешние свойства объекта

Рассмотрим следующий пример:

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}

Если внимательно посмотреть на набор значений этого объекта, мы увидим, что свойство a помечено как in-object, а свойство b - как элемент массива properties.

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

Этот пример показывает, что часть свойств хранится непосредственно внутри самого объекта ("in-object"), а часть свойств - во внешнем хранилище свойств. Связано это с тем, что согласно спецификации ECMA-262, объекты JavaScript не имеют фиксированного размера. Добавляя или удаляя свойства в объекте, меняется его размер. Из-за этого возникает вопрос: какую область памяти выделить под объект? Более того, как расширить уже аллоцированную память объекта? Разработчики V8 решили эти вопросы следующим образом.

Внутренние свойства

В момент первичной инициализации литерал объекта уже распарсен, и AST-дерево содержит информацию о свойствах, указанных в момент инициализации. Набор таких свойств помещается непосредственно внутрь объекта, что позволяет обращаться к ним максимально быстро и с минимальными затратами. Эти свойства называются in-object.

Давайте еще раз взглянем на класс пустого объекта.

d8> const obj1 = {}
d8>
d8> %DebugPrint(obj1);
DebugPrint: 0x2d56001c9ed1: [JS_OBJECT_TYPE]
 - map: 0x2d56000c4945 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2d56000c4b11 <Object map = 0x2d56000c414d>
 - elements: 0x2d56000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x2d56000006cd <FixedArray[0]>
 - All own properties (excluding elements): {}
0x2d56000c4945: [Map] in OldSpace
 - map: 0x2d56000c3c29 <MetaMap (0x2d56000c3c79 <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: 0x2d5600000061 <undefined>
 - prototype_validity cell: 0x2d5600000a31 <Cell value= 1>
 - instance descriptors (own) #0: 0x2d5600000701 <DescriptorArray[0]>
 - prototype: 0x2d56000c4b11 <Object map = 0x2d56000c414d>
 - constructor: 0x2d56000c4655 <JSFunction Object (sfi = 0x2d5600335385)>
 - dependent code: 0x2d56000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Хочу обратить внимание на параметр inobject properties. Здесь он равен 4, хотя в объекте еще нет ни одного свойства. Дело в том, что у пустых объектов, по умолчанию есть несколько слотов для in-object свойств. В V8 количество таких слотов равно 4.

d8> obj1.a = 1;
d8> obj1.b = 2;
d8> obj1.c = 3;
d8> obj1.d = 4;
d8> obj1.e = 5;
d8> obj1.f = 6;
d8>
d8> %DebugPrint(obj1);
DebugPrint: 0x2d56001c9ed1: [JS_OBJECT_TYPE]
 - map: 0x2d56000db291 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2d56000c4b11 <Object map = 0x2d56000c414d>
 - elements: 0x2d56000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x2d56001cc1a9 <PropertyArray[3]>
 - All own properties (excluding elements): {
    0x2d5600002a21: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x2d5600002a31: [String] in ReadOnlySpace: #b: 2 (const data field 1), location: in-object
    0x2d5600002a41: [String] in ReadOnlySpace: #c: 3 (const data field 2), location: in-object
    0x2d5600002a51: [String] in ReadOnlySpace: #d: 4 (const data field 3), location: in-object
    0x2d5600002a61: [String] in ReadOnlySpace: #e: 5 (const data field 4), location: properties[0]
    0x2d5600002a71: [String] in ReadOnlySpace: #f: 6 (const data field 5), location: properties[1]
 }
0x2d56000db291: [Map] in OldSpace
 - map: 0x2d56000c3c29 <MetaMap (0x2d56000c3c79 <NativeContext[285]>)>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 1
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x2d56000db169 <Map[28](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x2d56000dace9 <Cell value= 0>
 - instance descriptors (own) #6: 0x2d56001cc1f5 <DescriptorArray[6]>
 - prototype: 0x2d56000c4b11 <Object map = 0x2d56000c414d>
 - constructor: 0x2d56000c4655 <JSFunction Object (sfi = 0x2d5600335385)>
 - dependent code: 0x2d56000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Это значит, что первые 4 свойства, добавленные в пустой объект будут размещены в эти слоты как in-object.

Внешние свойства

Свойства, которые были добавлены после инициализации, уже не могут быть размещены внутри объекта, так как память под объект уже выделена. Чтобы не тратить ресурсы на переаллоцирование всего объекта, движок помещает такие свойства во внешнее хранилище, в данном случае, во внешний массив свойств, ссылка на который уже имеется внутри объекта. Такие свойства называются внешними или нормальными (именно такой термин можно нередко встретить в материалах разработчиков V8). Доступ к таким свойствам чуть менее быстрый, так как требуется резолв ссылки на хранилище и получение свойства по индексу. Но это намного эффективнее, чем переаллоцирование всего объекта.

Быстрые и медленные свойства

Внешнее свойство из примера выше, как мы только что рассмотрели, хранится во внешнем массиве свойств, связанном непосредственно с нашим объектом. Формат данных в этом массиве идентичен формату внутренних свойств. Другими словами, там хранятся только значения свойств, а метаинформация о них размещена в массиве дескрипторов, где также содержится информация и о внутренних свойствах. По сути, внешние свойства отличаются от внутренних только местом их хранения. И те, и другие условно можно считать быстрыми свойствами. Однако напомню, что JavaScript - это живой и гибкий язык программирования. Разработчик имеет возможность добавлять, удалять и изменять свойства объекта по своему усмотрению. Активное изменение набора свойств может привести к существенным затратам процессорного времени. Для оптимизации этого процесса V8 поддерживает так называемые "медленные" свойства. Суть медленных свойств заключается в использовании другого типа внешнего хранилища. Вместо массива значений свойства размещаются в отдельном объекте-словаре вместе со всеми их атрибутами. Доступ как к значениям, так и к атрибутам таких свойств осуществляется по их названию, которое служит ключом словаря.

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}

Мы удалили свойство obj1.a. Несмотря на то, что свойство было внутренним, мы полностью изменили форму скрытого класса. Если быть точным, мы его сократили, что отличается от типичного расширения формы. Это означает, что дерево форм стало короче, следовательно, дескрипторы и массив значений также должны быть перестроены. Все эти операции требуют определенных временных ресурсов. Чтобы избежать этого, движок изменяет способ хранения свойств объекта на медленный с использованием объекта-словаря. В данном примере словарь (NameDictionary) расположен по адресу 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

Массивы

Согласно разделу 23.1 Array Objects спецификации, массив - это объект, ключи которого являются целыми числами от 0 до 2**32 - 2. С одной стороны, кажется, что с точки зрения скрытых классов массив ничем не отличается от обычного объекта. Однако на практике массивы бывают довольно большими. Что если в массиве тысячи элементов? Неужели на каждый элемент будет создан отдельный скрытый класс? Давайте посмотрим, как на самом деле выглядит скрытый класс массива.

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]

Как мы видим, у скрытого класса этого объекта ссылка back pointer пустая, что означает отсутствие родительского класса, хотя мы добавили два элемента. Дело в том, что скрытый класс любого массива всегда имеет единую форму JS_ARRAY_TYPE. Это особенный скрытый класс, у которого в дескрипторах есть лишь одно свойство - length. Элементы массива же располагаются внутри объекта в структуре FixedArray. В действительности скрытые классы массивов все же могут наследоваться, поскольку сами элементы могут иметь различные типы данных, а ключи, в зависимости от числа, могут храниться разными способами для оптимизации доступа к ним. В этой статье я не буду подробно рассматривать все возможные переходы внутри массивов, так как это тема для отдельной статьи. Однако стоит иметь в виду, что разнообразные нестандартные манипуляции с ключами массивов могут привести к созданию древа классов для всех или части элементов.

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

[]

В примере выше оба элемента -1 и 232 - 1 не входят в диапазон возможных индексов массива [0 .. 232 - 2] и были объявлены как обычные свойства объекта с соответствующими формами и порождением дерева скрытых классов.

Еще одна нештатная ситуация возможна в случае попытки изменить атрибуты индекса. Чтобы элементы хранились в быстром хранилище, все индексы должны иметь одинаковую конфигурацию. Попытка изменить атрибуты любого из индексов не приведет к созданию отдельного свойства, но приведет к изменению типа хранилища на медленное, в котором будут храниться не только значения, но и атрибуты каждого индекса. По сути, здесь применяется то же правило, что и в случае с медленными свойствами объекта.

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]

Итог

В этой статье мы познакомились ближе с методами хранения свойств объектов, понятиями скрытого класса, формами объекта, дескрипторов объекта, внутренними и внешними свойствами, а также быстрым и медленным способами их хранения. Давайте теперь коротко вспомним основные тезисы и выводы.

  • Каждый объект в JavaScript имеет свой основной внутренний класс и скрытый класс, описывающий его форму.

  • Скрытые классы наследуют друг друга и выстраивются в деревья классов. Форма объекта { a: 1 } будет родительской для формы объекта { a: 1, b: 2 }.

  • Порядок свойств имеет значение. Объекты { a: 1, b: 2 } и { b: 2, a: 1 } будут иметь две разные формы.

  • Класс-наследник хранит ссылку на класс-родитель и информацию о том, что было изменено (переход).

  • В дереве классов каждого объекта количество уровней не менее количества свойств в объекте.

  • Самыми быстрыми свойствами объекта будут те, которые объявлены при инициализации. В следующем примере доступ к свойству obj1.a будет быстрее, чем к obj2.a.

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

const obj2 = {};
obj2.a = 1; // <- "a" - внешнее свойство
  • Нетипичные изменения формы объекта, такие как удаление свойства, могут привести к изменению типа хранения свойств на медленный. В следующем примере obj1 изменит свой тип на NamedDictionary, и доступ к его свойствам будет значительно медленнее, чем к свойствам obj2.

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

const obj2 = { a: 1, b: 2 };
obj2.a = undefined; // не меняет тип хранения свойств
  • Если в объекте есть внешние свойства, а внутренних меньше 4-х, такой объект можно немного оптимизировать, так как пустой объект, по умолчанию имеет несколько слотов для in-object свойств.

const obj1 = { a: 1 };
obj1.b = 2;
obj1.c = 3;
obj1.d = 4;
obj1.e = 5;
obj1.f = 6;

%DebugPrint(obj1);
...
- All own properties (excluding elements): {
    ...#a: 1 (const data field 0), location: in-object
    ...#b: 2 (const data field 1), location: properties[0]
    ...#c: 3 (const data field 2), location: properties[1]
    ...#d: 4 (const data field 3), location: properties[2]
    ...#e: 5 (const data field 4), location: properties[3]
    ...#f: 6 (const data field 5), location: properties[4]
 }

const obj2 = Object.fromEntries(Object.entries(obj1));

%DebugPrint(obj2);
...
 - All own properties (excluding elements): {
    ...#a: 1 (const data field 0), location: in-object
    ...#b: 2 (const data field 1), location: in-object
    ...#c: 3 (const data field 2), location: in-object
    ...#d: 4 (const data field 3), location: in-object
    ...#e: 5 (const data field 4), location: properties[0]
    ...#f: 6 (const data field 5), location: properties[1]
 }
  • Массив является обычным классом, форма которого имеет вид { length: [W__] }. Элементы массива хранятся в специальных структурах, ссылки на которые размещены внутри объекта. Добавление и удаление элементов массива не приводят к увеличению дерева классов.

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

const obj = {};
obj1[0] = 1; // каждое новое свойство объекта увеличивает дерево классов
  • Использование нетипичных ключей в массиве, например не числовых или вне диапазона [0 .. 232 - 2]), приводит к созданию новых форм в дереве классов.

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] }
// }}

Эту и другие мои статьи, так же, читайте в моем канале:

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

Комментарии (0)