Объекты в JS не бесплатные

от автора

Поговорим про микро-оптимизацию. Про, казалось бы, самую безобидную вещь: обычный {}.

[object Object] выглядит почти бесплатной абстракцией. Создал объект, напихал полей в любом порядке, удалил ненужное, передал дальше и забыл. Но, внезапно, одинаковые для тебя объекты могут оказаться разными для V8, порядок присваиваний влияет на машинный код, а “безобидный” delete оставляет после себя совсем не пустое место.

Для 99% систем это не имеет значения. А вот если у тебя hot path, через который проходят десятки тысяч объектов в секунду, или ты пишешь библиотечный код, стоит знать, что V8 на самом деле делает с твоими объектами.

И чего он там с ними делает?

Когда ты создаёшь объект, V8 не выделяет «мешок ключей». Он строит для него hidden class (map внутри V8) структуру, которая описывает layout: какие у объекта поля, в каком порядке они лежат, какого типа, по каким офсетам читать. Сам объект это компактный массив значений, как struct в C. Hidden class хранится отдельно, и каждый объект держит на него ссылку.

Hidden classes связаны между собой через transitions и вместе образуют transition tree. Когда ты добавляешь поле к объекту, V8 идёт по исходящему переходу из текущего hidden class: если переход с таким полем уже есть переключается на существующий дочерний, если нет создаёт новый hidden class и новую ветку дерева.

Если несколько объектов идут по одним и тем же переходам в одинаковом порядке они разделяют один hidden class. Это даёт V8 базу для inline cache: «по этому офсету всегда лежит string, читаем напрямую, без проверок».

Когда hidden classes начинают расходиться (по порядку, по типам, по добавлению/удалению), inline cache переходит:

  • monomorphic (один shape — fast path),

  • polymorphic (до 4 shapes — V8 проверяет каждый, всё ещё быстро),

  • megamorphic (4+ — V8 сдаётся, идёт через generic lookup).

Стоимость растёт на каждом шаге.

Где ты теряешь?

Классический пример постепенная сборка vs литерал:

const a = {}a.id = 1a.name = 'jopa'const b = { id: 1, name: 'jopa' }

Если порядок добавления полей совпадает, оба варианта в итоге попадают на один hidden class. У литерала есть бонус V8 запоминает финальный shape как boilerplate и при повторных вызовах аллоцирует объект сразу с готовым layout, без прохода по transition tree. Но в hot loop разница в установившемся режиме копеечная.

Хуже, если порядок полей в разных фабриках не совпадает:

function makeA(id, name) { return { id, name } }function makeB(name, id) { return { name, id } }

Это уже два разных hidden class. Любой код, который читает obj.id через эти фабрики, видит два shape на одном call site и IC становится polymorphic. Само по себе ещё не больно, но если таких разных объектов не два, а шесть, то добро пожаловать в megamorphic.

Ещё кейс условное добавление полей:

const user = { id, name }if (isAdmin) {  user.role = 'admin'}

Часть объектов имеет shape {id, name}, часть — {id, name, role}. Уже polymorphic. Лучше класть role: null сразу и потом присвоить значение при необходимости.

delete — отдельная история

delete user.name

Вот тут V8 уже не прощает. delete переводит объект в dictionary mode (он же slow mode, normalized properties). Это hashmap вместо фиксированного layout. Ни inline cache, ни офсетов, каждое чтение поля идёт через hash lookup в C++ runtime. И обратно V8 объект уже не вернёт, даже если форма стабилизировалась.

Вроде как память освободил. А по факту превратил объект в Map без типизации.

Если поле больше не нужно лучше присвой null или undefined. Shape сохранится, движок не потеряет в оптимизации.

А что по цифрам?

Стенд: Node 24, миллион объектов с тремя полями, в hot loop читаем obj.id, меряем ns/op. По три прогона, чтобы JIT успел устаканиться.

  • literal (same shape): 3.99 нс

  • stepwise (same order): 4.08 нс

  • poly IC (2 shape): 4.65 нс

  • megamorphic (6 shapes): 8.51 нс

  • dict mode (after delete): 56.12 нс

Что видно:

  • literal vs stepwise — разницы практически нет. V8 одинаково хорошо справляется с обоими.

  • polymorphic IC (2 shape) — ~15% оверхед. V8 спокойно тянет до 4 shape на одном call site.

  • megamorphic (6 shape)x2+ к чтению. Уже серьёзно.

  • delete (dictionary mode)x14 медленнее. Больно 🙂

На 100k RPS × 50 чтений полей на запрос с dict-mode объектами вместо нормальных — потеря ~260 мс CPU/с.

А что было на Node 22?

Для наглядности — те же сценарии на Node 22:

  • literal: 11.0 → 4.0 нс (x2.8)

  • stepwise: 11.1 → 4.1 нс (x2.7)

  • poly (2 shape): 10.3 → 4.7 нс (x2.2)

  • megamorphic: 14.1 → 8.5 нс (x1.7)

  • dict mode: 56.8 → 56.1 нс (=)

Что интересно:

  • Fast path между релизами стал ~x2.5 быстрее. TurboFan и Maglev продолжают тюниться, монохромное чтение поля на Node 24 — 4 нс против 11 на Node 22.

  • Dict mode — константа. 56 нс на обеих версиях. Это C++ hashmap lookup, JIT там не играет, оптимизировать нечего.

  • Относительная разница fast/slow растёт. На Node 22 delete был x5 медленнее literal’а, на Node 24 — уже x14. Fast path уходит вперёд, slow path стоит.

Вывод: с каждым новым V8 сидеть в dictionary mode или megamorphic IC становится всё дороже относительно “правильного” кода.

А что делать то?

  • В hot path — литералы, со всеми полями сразу. Не знаешь значение полож null.

  • Поля во всех фабриках — в одном порядке. Делаешь { id, name }, делай везде { id, name }.

  • delete — не нужон. Только obj.field = null (или undefined).

  • Если объект сложный и часто создаётся — класс или фабрика. Один shape гарантированно.

  • Не стоит превращать один call site в универсальную точку на все случаи жизни.: четыре shape потолок V8, дальше generic.

Что в итоге?

Объект в JS не «просто словарь». Это контракт с V8: ты обещаешь стабильный shape, V8 обещает быструю работу через hidden classes и inline cache. Нарушаешь — съезжаешь в polymorphic, потом megamorphic, потом dictionary mode. С каждым шагом дороже.

В обычном CRUD это не заметно. На hot path — это десятки процентов CPU и заметные хвосты по p99.

Date.now() тратил CPU на syscall, parseFloat — на парсинг, плохой shape — на cache miss. А потом говорят: “ой да ну какой Node, он же медленный, давайте напишем на Go и пойдем за ванильным лате на растительном”

Но shape, на самом деле, это только половина истории. Даже при том же hidden class смена типа значения в поле способна инвалидировать уже оптимизированный код. В следующей статье расскажу, что V8 на самом деле хранит за number, string и null, при чём тут Smi, Double, HeapObject и Tagged и почему совет «положи null заранее» работает не для каждого поля.

Node быстрый. Просто не мешай ему.

Что почитать?

ссылка на оригинал статьи https://habr.com/ru/articles/1066944/