V8: скрытые классы и деоптимизация под нагрузкой
Горячий цикл на Node.js внезапно замедляется в разы после релиза, а профиль показывает время в интерпретаторе вместо JIT. Разбираем, как V8 деоптимизирует функции и что рушит скрытые классы первым.

Node.js-сервис держал 40 000 запросов в секунду на одном ядре в бенчмарке, а в проде на том же железе выдал 12 000. Флейм-граф показал, что горячая функция обработки JSON проводит время не в оптимизированном коде, а в интерпретаторе — TurboFan её собрал, потом выбросил и больше не пересобирал. Это деоптимизация: движок обнаружил, что реальные данные не соответствуют предположениям, на которых строился быстрый код, и откатился назад. Разберём, что именно V8 предполагает про ваши объекты и какие приёмы ломают эти предположения первыми.
Материал написан для V8 из Node.js 20.x и 22.x (движок 11.x–12.x). Механизм скрытых классов стабилен уже много лет, но конкретные пороги инлайн-кеша и эвристики TurboFan между версиями меняются — точные значения смотрите в исходниках движка, а не заучивайте.
Что такое скрытый класс и зачем он движку
JavaScript-объект — это словарь: ключ-строка на значение. Читать значение по имени в хеш-таблице дорого. V8 не хочет платить за хеш при каждом обращении к obj.userId, поэтому строит для каждой формы объекта скрытый класс1 (в терминах движка — Map, не путать с Map из языка). Скрытый класс хранит, какие свойства есть у объекта и по каким смещениям они лежат в памяти.
Пока форма стабильна, доступ к свойству превращается в чтение по фиксированному смещению — как поле структуры в C. Дальше подключается инлайн-кеш (IC): в точке вызова obj.userId движок запоминает, какой скрытый класс он видел, и для него кеширует смещение. Если следующий объект того же класса — попадание, быстрый путь. Если другого — промах, кеш обновляется.
Скрытый класс — это не про то, какие поля вы объявили. Это про порядок и способ, которым вы их добавляли. Два объекта с одинаковым набором ключей могут иметь разные скрытые классы, если ключи появлялись в разном порядке.
Переходы формы
Добавление свойства не меняет скрытый класс на месте — оно создаёт переход к новому классу. Цепочка переходов образует дерево. Объект-литерал { id: 1, name: 'a' } и последовательное присваивание o.id = 1; o.name = 'a' приходят к одному классу. А вот o.name = 'a'; o.id = 1 — к другому, потому что порядок переходов иной.
Что ломается первым
Под нагрузкой первой отваливается функция, которую IC перевёл в мегаморфное состояние. Кеш точки доступа держит несколько форм (обычно до четырёх). Первая форма — мономорфный кеш, самый быстрый. Две-четыре — полиморфный, медленнее. Больше — мегаморфный: движок сдаётся, ходит в общую хеш-таблицу и перестаёт инлайнить эту точку. TurboFan такую функцию либо не оптимизирует, либо оптимизирует хуже.
- Свойства в разном порядке. Фабрика объектов из внешних данных, где ключи приходят в непредсказуемом порядке, плодит десятки скрытых классов на, казалось бы, одинаковые объекты.
- Условное добавление свойств.
if (x) obj.extra = 1создаёт две разные формы. Три-четыре таких условия — и точка доступа мегаморфна. - Смена типа значения в поле. Поле было целым числом (Smi), стало объектом или строкой — форма меняется, кешированный код деоптимизируется.
- delete на свойстве.
delete obj.fieldв большинстве случаев переводит объект в медленный «dictionary mode» и убивает быстрый доступ навсегда для этого объекта. - Дырявые массивы.
arr[0] = 1; arr[1000] = 2превращает packed-массив в holey, а операции над ним — в медленный путь.
Как увидеть деоптимизацию
Не гадайте — измеряйте. V8 отдаёт диагностику через флаги. Запустите нагрузочный сценарий, который повторяет продовые данные, а не синтетику с одинаковыми объектами.
# Node.js 20.x / 22.x
# показать деоптимизации и причины
node --trace-deopt server.js
# показать переходы IC: mono / poly / mega
node --trace-ic server.js 2> ic.log
# состояние оптимизации конкретной функции (нужен --allow-natives-syntax)
node --allow-natives-syntax script.jsВ выводе --trace-deopt ищите строки с причиной вроде wrong map или deoptimizing с именем вашей функции. В --trace-ic — переходы в состояние MEGAMORPHIC на конкретной точке доступа. Это адреса в исходнике, по которым видно, какое чтение свойства сломалось.
С --allow-natives-syntax в коде доступна внутренняя функция проверки статуса оптимизации. Точное имя и набор возвращаемых флагов зависят от версии движка — сверяйтесь с исходниками V8 (файл src/runtime/runtime-test.cc), а не заучивайте вызов из старой статьи, он мог измениться.
Пример: как порядок ключей плодит классы
'use strict';
// Плохо: порядок ключей зависит от входных данных.
// Каждый вариант порядка — свой скрытый класс.
function makeBad(raw) {
const point = {};
for (const key of Object.keys(raw)) {
point[key] = raw[key];
}
return point;
}
// Хорошо: фиксированная форма, поля всегда в одном порядке
// и всегда присутствуют. Один скрытый класс на все объекты.
function makeGood(raw) {
return {
x: raw.x ?? 0,
y: raw.y ?? 0,
label: raw.label ?? '',
};
}Разница не в наборе полей — он одинаков. Разница в том, что makeGood всегда создаёт объект одной формы: три поля, тот же порядок, значения-заглушки вместо undefined-провалов. Горячая функция, которая потом читает point.x, останется мономорфной.
Что мерить и в каких условиях
Замеры без указания условий бессмысленны. Синтетический бенч на 10 000 одинаковых объектов покажет идеальный мономорфный доступ и скроет проблему, которая проявится только на разнородном продовом трафике. Приведённых здесь абсолютных цифр не будет намеренно — они зависят от вашего железа, размера объектов и доли «грязных» форм. Меряйте так:
| Что смотреть | Инструмент | Сигнал проблемы |
|---|---|---|
| Частота деоптимизаций | --trace-deopt | Одна функция деоптимизируется повторно в цикле |
| Состояние IC | --trace-ic | Переход горячей точки в MEGAMORPHIC |
| Где проводится время | Chrome DevTools, вкладка Performance или --prof | Время в интерпретаторе вместо оптимизированного кода |
| Форма объекта в памяти | Heap snapshot в DevTools | «dictionary mode» на объектах, которые должны быть быстрыми |
Прогоняйте нагрузку минимум несколько десятков секунд: TurboFan оптимизирует функцию не сразу, а после того, как она стала горячей. Первые секунды профиля покажут интерпретатор у всех функций подряд — это нормальный прогрев, а не проблема.
Граничные случаи и где приём ломается
Стабилизация форм — не бесплатная универсальная победа. Есть места, где она не помогает или мешает.
- Объекты как словари по замыслу. Если вы храните пользовательский набор ключей неизвестного размера — это и есть словарь. Насильно фиксировать форму бессмысленно, используйте
Map: он не завязан на скрытые классы и предсказуем на произвольных ключах. - Слишком много полей. Объект с сотнями свойств V8 сам переведёт в dictionary mode. Стабильный порядок здесь не спасёт — пересмотрите структуру данных.
- Числа за границей Smi. Целые числа за пределами 31-битного диапазона хранятся как heap-объекты (double). Поле, которое то Smi, то большое число, вызывает переходы формы, даже если «тип» с точки зрения языка один и тот же —
number. - Мегаморфизм по дизайну. Универсальный сериализатор, обходящий любые объекты, будет мегаморфным по определению. Тут борьба со скрытыми классами — не туда: выигрыш даёт кеширование результата или специализированные пути для частых форм.
Что делать дальше
Инлайн-кеши и деоптимизация — только часть картины. Отдельная большая тема — сборка мусора и паузы GC под нагрузкой; здесь мы её не трогали. Порядок действий на своём сервисе:
- Снимите профиль под продоподобной нагрузкой, найдите горячие функции в интерпретаторе.
- Прогоните их через
--trace-deoptи--trace-ic, найдите повторные деоптимизации и мегаморфные точки. - Проверьте фабрики объектов на стабильность формы: один литерал, фиксированный порядок, заглушки вместо условных полей.
- Замените словари произвольных ключей на
Map, уберитеdeleteс горячих объектов. - Повторите замер под той же нагрузкой и сравните долю времени в оптимизированном коде.
Следите в проде за одним показателем: доля CPU-времени горячего эндпоинта, проводимого вне JIT. Рост этой доли после релиза — первый сигнал, что новый код сломал форму объектов, которую V8 раньше держал стабильной.
1 Скрытый класс (hidden class, внутри V8 — Map) — служебная структура движка, описывающая форму объекта: набор свойств, их порядок и смещения в памяти. Позволяет обращаться к свойству по фиксированному адресу вместо поиска по хеш-таблице.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: V8 — Fast properties in V8 (блог разработчиков движка), V8 — Elements kinds in V8
Частые вопросы
У меня не Node.js, а браузер — приёмы те же?
Я включил --trace-deopt, но вывод пустой. Значит, проблемы нет?
Насколько сильно деоптимизация замедляет код на практике?
Стоит ли переписывать весь код ради стабильных скрытых классов?
Map всегда быстрее объекта?
Поможет ли TypeScript избежать этих проблем?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.