Структура объекта в JavaScript движках02.04.2024 09:00
С точки зрения разработчика, объекты в 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, должен выглядеть примерно следующим образом.
Согласно вышесказанному, нам нужно хранить информацию о каждом из четырех приведенных свойств этих двух объектов. Звучит несколько расточительно с точки зрения потребления памяти. Кроме того, очевидно, что конфигурация этих свойств одинакова, за исключением названия свойства и его [[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, а пока проиллюстрирую в условном виде.
Концепция скрытых классов выглядит неплохо в случае объектов с одинаковой формой. Однако, что делать, если второй объект имеет другую структуру? В следующем примере два объекта не идентичны друг другу по структуре, но имеют пересечение.
По описанной выше логике в памяти должно появиться два класса с разной формой. Однако тогда проблема дублирования атрибутов возвращается. Дабы избежать этого, скрытые классы принято наследовать друг от друга.
Здесь мы видим, что класс Map2 описывает только одно свойство и ссылку на объект с более «узкой» формой.
Стоит также сказать, что на форму скрытого класса влияет не только набор свойств, но и их порядок. Другими словами, следующие объекты будут иметь разные формы скрытых классов.
Чуть выше я ссылался на пост Матиаса Биненса о формах объекта. Однако с тех пор прошло много лет. Для чистоты эксперимента я решил проверить, как обстоят дела на практике в реальном движке V8.
Проведем эксперимент на примере, приведенном в статье Матиаса.
Для этого нам понадобится встроенный внутренний методом V8 — %DebugPrint. Напомню, чтобы иметь возможность использовать встроенные методы движка, его нужно запустить с флагом --allow-natives-syntax. Чтобы видеть подробную информацию об объектах JS, движок должен быть скомпилирован в режиме debug.
Мы видим объект a, размещенный по адресу 0x1d47001c9425. К объекту привязан скрытый класс с адресом 0x1d47000da9a9. Внутри самого объекта хранится значение #x: 6. Атрибуты свойства расположены в привязанном скрытом классе в поле instance descriptors. На всякий случай, давайте посмотрим на массив дескрипторов по указанному адресу.
Здесь также значение свойства хранится в самом объекте, а атрибуты свойства — в массиве дескрипторов скрытого класса. Однако обращу внимание, что ссылка back pointer здесь тоже не пустая, хотя на приведенной схеме класса-родителя тут быть не должно. Давайте посмотрим на класс по этой ссылке.
Класс выглядит точно так же, как скрытый класс пустого объекта выше, но с другим адресом. Это означает, что фактически это дубликат предыдущего класса. Таким образом, реальная структура данного примера выглядит следующим образом.
Это первое отклонение от теории. Чтобы понять, зачем нужен еще один скрытый класс для пустого объекта, нам потребуется объект с несколькими свойствами. Предположим, что исходный объект изначально имеет несколько свойств. Исследовать такой объект через командную строку будет не очень удобно, поэтому воспользуемся Chrome DevTools. Для удобства, замкнем объект внутри контекста функции.
Слепок памяти показывает 6 наследуемых классов для этого объекта, что равно количеству свойств объекта. И это второе отклонение от теории, согласно которой предполагалось, что объект изначально имеет один скрытый класс, форма которого содержит те свойства, с которыми он был инициализирован. Причина этому кроется в том, что на практике мы оперируем не одним единственным объектом, а несколькими, может быть даже десятками, сотнями или тысячами. В таких реалиях поиск и перестройка деревьев классов могут оказаться весьма дорогими. Итак, мы подошли к еще одному понятию JS движков.
Переходы
Давайте к примеру выше добавим еще один объект со схожей формой.
На первый взгляд, форма второго объекта очень похожа, но свойства 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 }
Если внимательно посмотреть на набор значений этого объекта, мы увидим, что свойство 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.
Внешние свойства
Свойства, которые были добавлены после инициализации, уже не могут быть размещены внутри объекта, так как память под объект уже выделена. Чтобы не тратить ресурсы на переаллоцирование всего объекта, движок помещает такие свойства во внешнее хранилище, в данном случае, во внешний массив свойств, ссылка на который уже имеется внутри объекта. Такие свойства называются внешними или нормальными (именно такой термин можно нередко встретить в материалах разработчиков V8). Доступ к таким свойствам чуть менее быстрый, так как требуется резолв ссылки на хранилище и получение свойства по индексу. Но это намного эффективнее, чем переаллоцирование всего объекта.
Быстрые и медленные свойства
Внешнее свойство из примера выше, как мы только что рассмотрели, хранится во внешнем массиве свойств, связанном непосредственно с нашим объектом. Формат данных в этом массиве идентичен формату внутренних свойств. Другими словами, там хранятся только значения свойств, а метаинформация о них размещена в массиве дескрипторов, где также содержится информация и о внутренних свойствах. По сути, внешние свойства отличаются от внутренних только местом их хранения. И те, и другие условно можно считать быстрыми свойствами. Однако напомню, что JavaScript — это живой и гибкий язык программирования. Разработчик имеет возможность добавлять, удалять и изменять свойства объекта по своему усмотрению. Активное изменение набора свойств может привести к существенным затратам процессорного времени. Для оптимизации этого процесса V8 поддерживает так называемые «медленные» свойства. Суть медленных свойств заключается в использовании другого типа внешнего хранилища. Вместо массива значений свойства размещаются в отдельном объекте-словаре вместе со всеми их атрибутами. Доступ как к значениям, так и к атрибутам таких свойств осуществляется по их названию, которое служит ключом словаря.
Мы удалили свойство obj1.a. Несмотря на то, что свойство было внутренним, мы полностью изменили форму скрытого класса. Если быть точным, мы его сократили, что отличается от типичного расширения формы. Это означает, что дерево форм стало короче, следовательно, дескрипторы и массив значений также должны быть перестроены. Все эти операции требуют определенных временных ресурсов. Чтобы избежать этого, движок изменяет способ хранения свойств объекта на медленный с использованием объекта-словаря. В данном примере словарь (NameDictionary) расположен по адресу 0x2387001cc1d9.
Согласно разделу 23.1 Array Objects спецификации, массив — это объект, ключи которого являются целыми числами от 0 до 2**32 - 2. С одной стороны, кажется, что с точки зрения скрытых классов массив ничем не отличается от обычного объекта. Однако на практике массивы бывают довольно большими. Что если в массиве тысячи элементов? Неужели на каждый элемент будет создан отдельный скрытый класс? Давайте посмотрим, как на самом деле выглядит скрытый класс массива.
Как мы видим, у скрытого класса этого объекта ссылка back pointer пустая, что означает отсутствие родительского класса, хотя мы добавили два элемента. Дело в том, что скрытый класс любого массива всегда имеет единую форму JS_ARRAY_TYPE. Это особенный скрытый класс, у которого в дескрипторах есть лишь одно свойство — length. Элементы массива же располагаются внутри объекта в структуре FixedArray. В действительности скрытые классы массивов все же могут наследоваться, поскольку сами элементы могут иметь различные типы данных, а ключи, в зависимости от числа, могут храниться разными способами для оптимизации доступа к ним. В этой статье я не буду подробно рассматривать все возможные переходы внутри массивов, так как это тема для отдельной статьи. Однако стоит иметь в виду, что разнообразные нестандартные манипуляции с ключами массивов могут привести к созданию древа классов для всех или части элементов.
В примере выше оба элемента -1 и 232 - 1не входят в диапазон возможных индексов массива [0 .. 232 - 2] и были объявлены как обычные свойства объекта с соответствующими формами и порождением дерева скрытых классов.
Еще одна нештатная ситуация возможна в случае попытки изменить атрибуты индекса. Чтобы элементы хранились в быстром хранилище, все индексы должны иметь одинаковую конфигурацию. Попытка изменить атрибуты любого из индексов не приведет к созданию отдельного свойства, но приведет к изменению типа хранилища на медленное, в котором будут храниться не только значения, но и атрибуты каждого индекса. По сути, здесь применяется то же правило, что и в случае с медленными свойствами объекта.
В этой статье мы познакомились ближе с методами хранения свойств объектов, понятиями скрытого класса, формами объекта, дескрипторов объекта, внутренними и внешними свойствами, а также быстрым и медленным способами их хранения. Давайте теперь коротко вспомним основные тезисы и выводы.
Каждый объект в JavaScript имеет свой основной внутренний класс и скрытый класс, описывающий его форму.
Скрытые классы наследуют друг друга и выстраивются в деревья классов. Форма объекта { a: 1 } будет родительской для формы объекта { a: 1, b: 2 }.
Порядок свойств имеет значение. Объекты { a: 1, b: 2 } и { b: 2, a: 1 } будут иметь две разные формы.
Класс-наследник хранит ссылку на класс-родитель и информацию о том, что было изменено (переход).
В дереве классов каждого объекта количество уровней не менее количества свойств в объекте.
Самыми быстрыми свойствами объекта будут те, которые объявлены при инициализации. В следующем примере доступ к свойству obj1.a будет быстрее, чем к obj2.a.
Нетипичные изменения формы объекта, такие как удаление свойства, могут привести к изменению типа хранения свойств на медленный. В следующем примере obj1 изменит свой тип на NamedDictionary, и доступ к его свойствам будет значительно медленнее, чем к свойствам obj2.
const obj1 = { a: 1, b: 2 };
delete obj1.a; // изменяет тип хранения на NameDictionary
const obj2 = { a: 1, b: 2 };
obj2.a = undefined; // не меняет тип хранения свойств
Массив является обычным классом, форма которого имеет вид { length: [W__] }. Элементы массива хранятся в специальных структурах, ссылки на которые размещены внутри объекта. Добавление и удаление элементов массива не приводят к увеличению дерева классов.
const arr = [];
arr[0] = 1; // новый элемент массива не увеличивает дерево классов
const obj = {};
obj1[0] = 1; // каждое новое свойство объекта увеличивает дерево классов
Использование нетипичных ключей в массиве, например не числовых или вне диапазона [0 .. 232 - 2]), приводит к созданию новых форм в дереве классов.