open atlas
↑ К треку
Внутренности движка JavaScript JSE · 03 · 01

Что такое Map на самом деле: вскрываем hidden class

Map в V8 — это HeapObject фиксированного размера, описывающий одну раскладку объекта: instance size, DescriptorArray (имя → field index, representation, constness, attributes), elements kind, back-pointer и transitions.

JSE Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

В browser/03-v8-internals вы узнали, что два объекта с одинаковым порядком добавления свойств «разделяют hidden class» и что именно это делает чтение свойств быстрым. Хорошо — но что это за штука? Это не тег и не флаг. Это настоящий, выделенный в памяти C++-объект, лежащий в куче, с точной раскладкой, которую можно осмотреть в d8. Этот урок вскрывает коробку и называет каждое поле внутри Map.

Объект почти пуст; знание держит Map

Вы видели обзор в browser/03-v8-internals/03-hidden-classes: объекты, разделяющие hidden class, разделяют быстрый путь inline cache. Здесь мы вскрываем эту коробку. Главный структурный факт V8 такой: объект JavaScript почти не несёт собственных метаданных. Тело обычного объекта в памяти — это маленький непрерывный блок:

[ map pointer ][ properties pointer ][ elements pointer ][ in-object slot 0 ][ slot 1 ] ...

Первое слово — это указатель на Map (имя hidden class в V8; Shape в SpiderMonkey, Structure в JavaScriptCore). Всё, что сообщает V8, что это за объект — какие имена свойств есть, где каждое лежит, какой тип хранит, — находится не в объекте. Оно в Map. Объект хранит лишь значения, упакованные в анонимные слоты по фиксированным байтовым смещениям. Имена и смещения живут на одну индирекцию дальше, в общем Map.

В этом весь фокус. Поскольку знание о раскладке вынесено в Map, чтение свойства p.x может скомпилироваться так: загрузить указатель на map, сравнить его с тем map, который ожидает inline cache, и при совпадении прочитать слот по жёстко зашитому смещению. Никакого сравнения имён, никакого поиска по хешу — имя x было разрешено в смещение один раз, на уровне Map, и переиспользуется для каждого объекта, разделяющего этот Map.

Внутри DescriptorArray

Прежде чем читать DescriptorArray, спросите себя: что именно нужно знать о свойстве, чтобы скомпилировать доступ к нему в одну инструкцию? Ответ — здесь.

Сердце Map — это DescriptorArray (массив дескрипторов, по одному на каждое именованное свойство), таблица, превращающая имя свойства во всё, что V8 нужно для чтения или записи. Одна запись на каждое именованное (со строковым ключом) собственное свойство, в порядке объявления. Каждый дескриптор хранит:

  • Ключ — (обычно интернированное) имя свойства, например "x". Интернированные имена сравниваются по указателю, так что поиск дескриптора — это скан указателей, а не сравнение строк.
  • Field indexкакой слот держит значение и является ли он in-object (встроенным в тело объекта) или out-of-object (в отдельном PropertyArray). Это мы полностью раскрываем в уроке 03.
  • Representation — какого рода значение поле статически известно держать: Smi (малое целое, без бокса), Double (распакованный 64-битный float в слоте «mutable HeapNumber»), HeapObject (указатель на любой объект кучи) или Tagged (что угодно — самое общее). Более узкие representations позволяют TurboFan пропустить бокс и проверки типа.
  • Constnessconst, если полю за всё время присваивалось ровно одно значение по всем экземплярам этого Map (тогда компилятор может заинлайнить значение), или mutable, как только появляется второе значение.
  • Attributes — биты writable / enumerable / configurable свойства, плюс является ли это обычным data-полем, const-слотом данных, свёрнутым в дескриптор, или парой accessor (getter/setter).

Вместе эти пять полей означают: разделив Map один раз, V8 знает всё необходимое для чтения или записи любого свойства без обращения к самому объекту. Уберите representation — компилятор не сможет пропустить бокс; уберите field index — он не найдёт слот вообще.

// Логическое содержимое DescriptorArray для объекта { x: 1, y: 2.5 }:
// index 0:  key="x"  field#=0  rep=Smi      const=true  attrs=W,E,C  storage=in-object
// index 1:  key="y"  field#=1  rep=Double   const=true  attrs=W,E,C  storage=in-object

Важно: два разных Map могут разделять один DescriptorArray — родительский Map и дочерний, добавивший лишь одно свойство в конце, могут указывать на тот же backing-массив дескрипторов, причём дочерний объявляет, что владеет на один дескриптор больше родителя. Поэтому строить дерево родственных форм дёшево: большая часть данных дескрипторов переиспользуется, а не копируется.

Back-pointer и transitions: Map знает соседей

Map — это узел дерева, и он хранит рёбра:

  • Back-pointer указывает на Map, из которого этот был выведен — раскладку до добавления последнего свойства. V8 идёт по back-pointer’ам, чтобы найти общего предка при согласовании форм и (как увидим в уроке 02) при миграции устаревших раскладок.
  • Указатель transitions ведёт вперёд, в TransitionArray с ключом {имя свойства, attributes}, к дочерним Map, достигаемым добавлением следующего свойства. Добавление "y" к Map-для-{x} следует (или создаёт) ребро перехода "y" к Map-для-{x,y}.

Так что Map одновременно и описание текущей раскладки, и маршрутизатор к соседним. Эта двойная роль делает переходы форм амортизированно O(1) и делает весь граф форм разделяемым в пределах изолята.

Что где лежит (V8, 64 бита)
Заголовок объекта до in-object-слотов
map + properties + elements = 3 слова
Размер Map (фиксированный)
~10 полей размером с указатель
Map на раскладку (общий)
1 — все подходящие объекты разделяют его
Representations поля
Smi, Double, HeapObject, Tagged
Чтение свойства при совпадении map
загрузка map, сравнение, чтение по смещению (~2-3 инстр.)
Осмотр Map в d8
%DebugPrint(obj) при --allow-natives-syntax

Всё это видно напрямую. В d8 --allow-natives-syntax команда %DebugPrint(obj) печатает адрес Map объекта, его DescriptorArray с representation и field index каждого свойства, elements kind и прототип. Два объекта, которые «разделяют hidden class», печатают один и тот же адрес Map; два разошедшихся печатают разные.

Викторина

Для обычного объекта `{x:1, y:2}` где физически живёт информация «свойство x лежит по field index 0»?

Викторина

Вы аллоцируете 100000 объектов через `new Point(x, y)`, все с одинаковыми двумя полями в одинаковом порядке. Сколько объектов Map существует для них?

Расставь шаги по порядку

Расставьте по порядку шаги, которыми V8 читает `p.x` у объекта, чей Map совпадает с ожиданием inline cache.

  1. 1 Загрузить указатель на map из заголовка объекта
  2. 2 Сравнить его с Map, который inline cache записал для этой точки
  3. 3 При совпадении взять закэшированный field index, разрешённый ранее из DescriptorArray
  4. 4 Прочитать значение прямо из этого фиксированного слота — без сравнения имени, без поиска по хешу
Почему это работает

Зачем вообще выносить раскладку из объекта? Потому что большинство объектов данного типа структурно идентичны — миллионы узлов DOM, узлов AST или fiber’ов React разделяют горстку форм. Хранить таблицу имя-в-смещение каждого из них встроенно значило бы умножить память на число полей и сделать каждое чтение поиском. Разделение одного Map на раскладку превращает «опиши этот объект» из пер-объектных данных в один кэшируемый указатель — именно его и сравнивает inline cache.

Вспомните перед уходом
  1. 01
    Перечислите поля, хранимые в Map V8, и скажите, какие встроенные, а какие — указатели на другие объекты кучи.
  2. 02
    Что лежит в одной записи DescriptorArray и почему это делает чтения быстрыми?
  3. 03
    Почему аллокация миллиона объектов одной формы не аллоцирует миллион Map, и как это подтвердить?
Итог

Map в V8 (hidden class; Shape в SpiderMonkey, Structure в JavaScriptCore) — это настоящий HeapObject фиксированного размера, полностью описывающий одну раскладку объекта и разделяемый всеми объектами с такой же раскладкой и историей. Его встроенные поля — instance size, instance type и bit-поля, число own descriptors и elements kind; он указывает на DescriptorArray, прототип, back-pointer на родительский Map и TransitionArray детей. DescriptorArray отображает каждое имя свойства в field index, representation (Smi/Double/HeapObject/Tagged), флаг constness и attributes. Сам экземпляр объекта почти пуст — лишь указатель map плюс анонимные слоты значений по фиксированным смещениям. Это разделение и есть движок быстрых чтений: разреши имя в смещение один раз в Map, затем читай p.x как «загрузить указатель map, сравнить с Map из inline cache, прочитать слот». Следующие уроки идут по этой структуре вперёд (transitions, deprecation и migration) и вниз (как на самом деле устроены хранение и доступ к полям). Теперь, когда встретишь деопт или регрессию производительности, первый вопрос звучит так: сколько различных Map видит эта горячая точка — и что DescriptorArray каждого из них говорит о representation поля?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.