V8: скрытые классы и деоптимизация горячих функций
Функция, которую V8 уже скомпилировал в оптимизированный код, может внезапно вернуться к интерпретатору и потерять в скорости в разы. Разбираем, что провоцирует деоптимизацию и как её ловить.

Горячая функция, вызываемая миллионы раз, отработала за 12 мс. Вы добавили одну строку — присвоили полю объекта значение другого типа — и та же функция стала занимать 40 мс. Профилировщик показывает не рост нагрузки, а то, что V8 выбросил оптимизированный код и вернулся к интерпретации байткода. Это деоптимизация, и её причина почти всегда — сломанные предположения о форме объектов. Разберём механизм скрытых классов в V8 (движок Chrome и Node.js, актуальные ветки V8 12.x–13.x), что заставляет их плодиться и как поймать деопт в своём коде.
Скрытые классы: что это и зачем
JavaScript — язык с динамическими объектами: свойства добавляются и удаляются в рантайме. Если бы V8 хранил каждый объект как словарь «ключ → значение», доступ к полю означал бы хеш-поиск при каждом обращении. Дорого. Поэтому движок для каждого объекта заводит скрытый класс (в исходниках V8 — Map, не путать с JS-типом Map) — метаописание структуры: какие поля есть и по какому смещению в памяти лежат.
Пока объекты имеют одинаковую структуру, они делят один скрытый класс. Тогда обращение point.x компилируется не в поиск, а в чтение по фиксированному смещению — как доступ к полю структуры в C. Это и делает оптимизированный код быстрым.
Скрытый класс — это не про то, из какого конструктора создан объект. Два объекта из разных функций-конструкторов могут делить скрытый класс, если поля добавлялись в одинаковом порядке. И наоборот: два объекта из одного конструктора получат разные классы, если порядок присваивания полей разошёлся.
Как строится цепочка переходов
Каждое добавление свойства создаёт переход к новому скрытому классу. Возьмём объект, который наполняется по шагам:
// V8 12.x, поведение проверяется через --allow-natives-syntax
const node = {}; // скрытый класс C0 (пустой)
node.value = 10; // переход C0 -> C1 (добавлено поле value)
node.next = null; // переход C1 -> C2 (добавлено поле next)
Если во всём коде объекты-узлы создаются в том же порядке — сначала value, потом next — все они придут в один и тот же C2. Инлайн-кеши обращений к node.value и node.next останутся мономорфными: движок знает ровно одну форму и генерирует прямой доступ по смещению.
Что ломает мономорфизм
Проблема возникает, когда в одну и ту же горячую функцию прилетают объекты с разными скрытыми классами. Инлайн-кеш из мономорфного (одна форма) становится полиморфным (несколько форм), а после нескольких форм — мегаморфным, и тогда быстрый путь отключается совсем.
Разный порядок инициализации полей
// Две функции создают объекты с одними полями, но в разном порядке
function makeA() {
const obj = {};
obj.width = 100;
obj.height = 50;
return obj; // класс: {} -> width -> height
}
function makeB() {
const obj = {};
obj.height = 50;
obj.width = 100;
return obj; // класс: {} -> height -> width — ДРУГОЙ
}
// Функция, обрабатывающая оба вида, увидит две формы
function area(shape) {
return shape.width * shape.height;
}
Здесь area становится полиморфной без всякой необходимости — достаточно было держать порядок полей одинаковым.
Добавление свойств после создания
Если у одних объектов поле есть, а у других его дописывают позже (или не дописывают вовсе), формы расходятся. Классический вредный паттерн — «докидывать» служебные поля по условию.
Смена типа поля
Скрытый класс запоминает не только набор полей, но и представление их значений. Поле, которое хранило целое число (V8 держит его как Smi — small integer), при записи дробного или строки требует смены представления. Это тоже провоцирует деопт функции, которая полагалась на прежний тип.
const acc = { total: 0 }; // total хранится как Smi
function add(x) {
acc.total += x;
}
add(1); add(2); // всё в целых, код оптимизирован
add(0.5); // total стал double — деопт add()
delete
Оператор delete на поле объекта во многих случаях переводит объект в словарный режим (dictionary mode): V8 отказывается от скрытого класса и хранит свойства как хеш-таблицу. Быстрый доступ по смещению теряется навсегда для этого объекта.
Как это поймать
Node.js запускается с флагами трассировки V8. Они не для прода — они для локального профилирования конкретной функции.
--trace-opt— печатает, какие функции V8 оптимизировал.--trace-deopt— печатает деоптимизации с причиной (например, wrong map, not a Smi).--allow-natives-syntax— открывает встроенные функции вроде%GetOptimizationStatus(fn)и%HasFastProperties(obj)для точечной проверки.
// deopt-check.js — запуск: node --allow-natives-syntax --trace-deopt deopt-check.js
function sum(obj) {
return obj.a + obj.b;
}
// прогреваем оптимизацию мономорфными объектами
for (let i = 0; i < 100000; i++) {
sum({ a: 1, b: 2 });
}
// проверяем, что сохранилась быстрая раскладка полей
console.log(%HasFastProperties({ a: 1, b: 2 }));
// подсовываем другую форму — в логах появится деопт
sum({ b: 2, a: 1 });
Имена внутренних функций (%GetOptimizationStatus, %HasFastProperties) и формат вывода --trace-deopt зависят от версии V8. Точный набор для вашей сборки Node сверяйте в исходниках V8 (src/runtime/runtime.h) — не полагайтесь на имена из старых статей.
Сравнение причин деопта
| Причина | Что происходит | Как избежать |
|---|---|---|
| Разный порядок полей | Расходятся скрытые классы, IC полиморфный | Инициализировать поля в одном порядке, лучше в конструкторе |
| Позднее добавление свойства | Часть объектов имеет лишний переход | Задать все поля сразу, отсутствующие — как null |
| Смена типа поля (Smi → double) | Меняется представление, деопт зависимых функций | Не смешивать целые и дробные в одном поле без нужды |
| delete свойства | Объект уходит в словарный режим | Присваивать null/undefined вместо delete |
Граничные случаи и где это не важно
Мономорфизм имеет смысл ловить только в горячем коде — функциях из top-профиля, которые вызываются десятки тысяч раз в секунду. Для кода, отрабатывающего один раз при старте, разница в скрытых классах не измерима и оптимизировать её — потеря времени.
Полиморфизм сам по себе не катастрофа: V8 держит быстрый путь для нескольких форм (порог невелик, порядка четырёх). Проблема — переход в мегаморфное состояние, когда форм больше и IC отключается. Если функция по замыслу принимает десятки разных типов объектов, ускорять её через единый скрытый класс не выйдет — здесь помогает другая раскладка данных, а не борьба за форму.
Отдельная тема — массивы: V8 держит для них свои представления (packed Smi, packed double, holey). Дыры от arr[100] = x при коротком массиве переводят его в holey-режим и замедляют перебор. Здесь мы это не разворачиваем.
Что делать дальше
Начните с профиля, а не с догадок: снимите CPU-профиль (в Node — node --prof или инспектор), найдите функции из верхушки и только их прогоните под --trace-deopt. Если в логе повторяется деопт по wrong map — ищите места, где в функцию приходят объекты разной формы, и приводите инициализацию полей к одному порядку. Для классов данных объявляйте все поля в конструкторе, включая те, что «иногда» пустые. Мерить эффект стоит на реальной нагрузке: микробенчмарк на пустых объектах врёт, потому что JIT прогревается иначе, чем на боевом трафике.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: V8 blog — Fast properties in V8, V8 docs — Tracing and debugging flags
Частые вопросы
А если у меня другая версия Node/V8 — эти флаги сработают?
Классы ES6 (class) решают проблему автоматически?
Насколько реально деопт замедляет код?
Стоит ли рефакторить весь проект под мономорфизм?
Почему нельзя доверять микробенчмарку на пустых объектах?
Что делать, если функция по замыслу принимает объекты разных типов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.