Что такое Map на самом деле: вскрываем hidden class
Map в V8 — это HeapObject фиксированного размера, описывающий одну раскладку объекта: instance size, DescriptorArray (имя → field index, representation, constness, attributes), elements kind, back-pointer и transitions.
В 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 пропустить бокс и проверки типа. - Constness —
const, если полю за всё время присваивалось ровно одно значение по всем экземплярам этого 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) и делает весь граф форм разделяемым в пределах изолята.
- Заголовок объекта до 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»?
Расставьте по порядку шаги, которыми V8 читает `p.x` у объекта, чей Map совпадает с ожиданием inline cache.
- 1 Загрузить указатель на map из заголовка объекта
- 2 Сравнить его с Map, который inline cache записал для этой точки
- 3 При совпадении взять закэшированный field index, разрешённый ранее из DescriptorArray
- 4 Прочитать значение прямо из этого фиксированного слота — без сравнения имени, без поиска по хешу
▸Почему это работает
Зачем вообще выносить раскладку из объекта? Потому что большинство объектов данного типа структурно идентичны — миллионы узлов DOM, узлов AST или fiber’ов React разделяют горстку форм. Хранить таблицу имя-в-смещение каждого из них встроенно значило бы умножить память на число полей и сделать каждое чтение поиском. Разделение одного Map на раскладку превращает «опиши этот объект» из пер-объектных данных в один кэшируемый указатель — именно его и сравнивает inline cache.
- 01Перечислите поля, хранимые в Map V8, и скажите, какие встроенные, а какие — указатели на другие объекты кучи.
- 02Что лежит в одной записи DescriptorArray и почему это делает чтения быстрыми?
- 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-уровень. Открой, попробуй, потом открой ответ.
встречается в184
- Почему GraphQL получает N+1junior
- Механика DataLoader: батчинг на границе тикаmiddle
- Контракты batch-функции: порядок, формы, ошибкиmiddle
- Federation и lookahead: батчинг за пределами DataLoadermiddle
- Защита сложности запросов: depth, cost, persisted queriesmiddle
- Senior GraphQL API: scheduling-контракт, изоляция арендаторов, наблюдаемостьsenior
- Зачем идемпотентность: безопасные retryjunior
- Серверный state machine: четыре состояния idempotency keymiddle
- Outbox и inbox: effectively-once через dual-write границуmiddle
- Конкурентность и архитектура кеша для идемпотентности на масштабеsenior
- Наблюдаемость, production-инциденты и дизайн для глобального масштабаsenior
- Event loop: один поток, три очередиjunior
- Задачи, микрозадачи и scheduler.yield()middle
- Голодание микрозадач, длинные задачи и LoAFsenior
- Event loop Node.js: фазы, nextTick и задержка циклаsenior
- React, Vue и наблюдаемость INP в продакшенеsenior
- Render pipeline: шесть стадий от байтов до пикселейjunior
- Цена стадий и модель процесса рендерераmiddle
- Инвалидация, dirty-биты и containmiddle
- Слои композитора: продвижение, перекрытие и память GPUmiddle
- Флейм-стрип DevTools и жизненный цикл кадраmiddle
- Layout thrash: форсированная синхронная компоновкаsenior
- BeginMainFrame, анимации на потоке compositor и память GPUsenior
- Observability в проде: LoAF, INP и полная поверхность атакиsenior
- Что такое V8 и почему производительность различается в 100 разjunior
- Четырёхуровневый JIT-конвейер V8 и профилированная тиеризацияmiddle
- Hidden classes, деревья переходов и расположение в памятиmiddle
- Inline caches, состояния IC и деоптимизацияmiddle
- Orinoco GC: параллельный scavenger, конкурентная разметка и барьеры записиmiddle
- Спекулятивный движок TurboFan и ловушка deopt-loopsenior
- V8 в production: Isolates, сжатие указателей и реальные аварииsenior
- Жизненный цикл service worker и стратегии кешированияmiddle
- Граничные случаи service worker: version skew, долговременность и ловушка навигацииsenior
- Что делает реконсилер: render vs commitjunior
- Объект fiber и дерево с двойной буферизациейmiddle
- Чистота фазы render и подшаги фазы commitmiddle
- Реконсиляция: эвристики диффа и ловушка ключейmiddle
- Приоритетные lanes, time-slicing и useTransitionmiddle
- Bailout, мемоизация и tearingsenior
- React Profiler, компилятор и продакшн-наблюдаемостьsenior
- Стратегии рендеринга: SSG, SSR, ISR, streaming и гидратацияjunior
- SSG, SSR, ISR, streaming и RSC — как работает каждая стратегияmiddle
- Цена гидратации: selective, progressive, острова, resumabilitymiddle
- Hydration mismatch: причины, обнаружение и правило детерминизмаsenior
- RSC, стратегия на маршрут и production-наблюдаемостьsenior
- Core Web Vitals: что измеряют LCP, INP и CLSjunior
- CLS: почему происходят сдвиги лейаута и как их остановитьmiddle
- Трейдоффы метрик, RUM-атрибуция и цикл CI+полеsenior
- Общая картина: от URL до LCP до INP как эстафетаjunior
- Восемь слоёв трассировки: от service worker до второй навигацииmiddle
- Пять канонических поломок: где производство стабильно ломаетсяsenior
- Метод трёх треков: чтение трасс и построение системы мониторингаsenior
- Что такое cache stampede и почему он делает всё хужеjunior
- Лок и single-flight: ограничение параллельных rebuildmiddle
- XFetch: вероятностное раннее истечение без координацииmiddle
- Stale-while-revalidate и CDN request coalescingmiddle
- Детектирование stampede и дизайн TTL для продакшенаmiddle
- Метастабильный сбой, fencing-токены и production-постмортемыsenior
- Что такое отношение: таблицы, строки, ключи и ограниченияjunior
- Ограничения, ключи и типы данных Postgresmiddle
- Нормальные формы, денормализация и почему схемы «прилипают»middle
- JSONB, массивы и когда side table побеждаетmiddle
- Heap-хранилище, TOAST и выравнивание колонокsenior
- Целостность схемы: deferral, версионирование и сбои в продакшнеsenior
- Реляционная модель vs документные, wide-column, граф и key-valuesenior
- Index-only scan, Visibility Map и INCLUDEsenior
- Типичные сбои в продакшне и аудит индексовsenior
- pg_statistic, ANALYZE и производственная наблюдаемостьmiddle
- Производственные режимы отказа и стабильность плановsenior
- MVCC: как Postgres раздаёт согласованные снимкиjunior
- Заголовок tuple и механика снимковmiddle
- HOT-обновления и уровни изоляцииmiddle
- VACUUM, bloat и autovacuummiddle
- CLOG, XID wraparound и MultiXactsenior
- SSI и production-тюнинг autovacuumsenior
- Реальные провалы MVCC, deployment-паттерны и распределённые снимкиsenior
- Connection pool: зачем амортизировать стоимость backend Postgresjunior
- Режимы PgBouncer: session, transaction и statementmiddle
- Размер пула: формула (ядра × 2) + шпинделей и двухуровневый стекmiddle
- Исчерпание пула и idle-in-transaction: сценарий отказа в 3 ночиmiddle
- Миграция на transaction mode: план развёртывания и prepared statements в PgBouncer 1.21middle
- Процессная модель Postgres и почему увеличение max_connections снижает производительностьsenior
- Ландшафт пулеров 2026, serverless connection storms и полная таксономия отказовsenior
- Что такое миграция схемы и почему она заменяет ad-hoc DDLjunior
- ADD COLUMN: мгновенно в PG 11+ против перезаписи в старом Postgresjunior
- Режим отказа очереди блокировок: почему мгновенный DDL может заморозить базуmiddle
- Безопасные DDL-паттерны: NOT VALID, CONCURRENTLY и исправления небезопасных операцийmiddle
- Expand-contract: нулевой простой для ломающих изменений схемыmiddle
- Advisory-блокировки, инструменты миграций и координация деплояsenior
- Таксономия сбоев миграций и дисциплина продакшнаsenior
- Зачем нужно шардирование: потолок одного Postgresjunior
- Выбор ключа шарда: стратегии hash, range, list и directorymiddle
- Партиционирование против шардирования: одно слово, два разных понятияmiddle
- Ко-локация и Citus: инвариант, делающий шардирование пригодным к использованиюmiddle
- Режим отказа hot shard: обнаружение, изоляция и долгосрочная политикаmiddle
- Schema-based шардирование и альтернативы мультиарендностиsenior
- Онлайн-решардинг, 2PC и операционная стоимость шардированияsenior
- Семь актов: от CREATE TABLE до Citusjunior
- Акты 1–3 в глубину: схема, индексы и статистика планировщикаmiddle
- Акты 4–6 в глубину: MVCC bloat, connection pooling и безопасные миграцииmiddle
- Акт 7 в глубину: шардинг, co-location и семиуровневый каскад трейдоффовmiddle
- Наблюдаемость, антипаттерны и производственный триажsenior
- Роли Raft, term и почему majority-кворум предотвращает split brainjunior
- Как Raft реплицирует log entry и решает, что его безопасно коммититьmiddle
- Выборы лидера в Raft: таймауты, правила голосования и четыре свойства безопасностиmiddle
- Raft в реальном мире: partition, медленный диск и клиентская маршрутизацияmiddle
- Расширения Raft: pre-vote, learner, snapshot и линеаризуемые чтенияsenior
- Raft в production: membership change, Multi-Raft и observabilitysenior
- Где происходит data fetching — и почему это решает LCPjunior
- Fetch waterfall''''ы — диагностика и лечение через Promise.allmiddle
- React Server Components и Suspense streamingmiddle
- Клиентский кэш: TanStack Query, SWR и stale-while-revalidatemiddle
- LCP, prefetch и race conditions в интерактивном fetchingmiddle
- Senior internals: RSC payload, слои кэша и production паденияsenior
- Трёхстороннее рукопожатие TCPjunior
- Номера последовательности и состояние соединенияmiddle
- DNS: что делает и зачем существуетjunior
- Обход резолвера: перенаправления, типы записей и gluemiddle
- TTL, кеширование и распространение DNSmiddle
- Рукопожатие за 1 RTT: key share и ECDHEmiddle
- Возобновление сессии и 0-RTTmiddle
- WebSocket: HTTP-апгрейд до постоянного соединенияjunior
- Формат WebSocket-фрейма: opcodes, маскирование, фрагментацияmiddle
- Backpressure в WebSocket: когда клиенты не успеваютmiddle
- Реконнект: jittered backoff, thundering herd, восстановление сообщенийsenior
- WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10Msenior
- WebSocket в production: прокси, безопасность и распределённая архитектураsenior
- Что делают обратные проксиjunior
- Health checks, connection draining и slow startmiddle
- Session affinity, consistent hashing и правильное решениеmiddle
- Retry-бури, circuit breakers и load sheddingsenior
- Устойчивая архитектура LB: anycast, zone-aware маршрутизация и observabilitysenior
- Почему QUIC, а не TCP+TLSjunior
- Connection ID и миграция сетиmiddle
- Возобновление 0-RTT и шифрование пакетовsenior
- DDoS: что это и почему работаетjunior
- Атаки усиления и истощение состоянияmiddle
- Ограничение скорости: алгоритмы и архитектураmiddle
- WAF, межсетевые экраны, mTLS и HSTSmiddle
- Отравление DNS-кэша и BGP-перехватsenior
- Эшелонированная защита и экономика атакsenior
- DNS, TCP, TLS по очереди: куда уходят миллисекундыmiddle
- Перехват прокси и шлюзы безопасности: rate limiter, WAF, mTLSmiddle
- Альтернативные пути: QUIC 0-RTT, WebSocket upgrade, миграция соединенияmiddle
- Наблюдаемость: распределённые трейсы, USE/RED и семплированиеsenior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое три сигнала: метрики, логи, трейсыjunior
- Зачем нужны структурные логи: дневник против таблицыjunior
- Схема продакшн-лога: поля, которые несёт каждая строкаmiddle
- PII-редакция и log injectionsenior
- OTel Logs Data Model и audit-логи как подсистемаsenior
- SLI, SLO и error budget: надёжность в числахjunior
- Error budget policy, latency SLO и составные journeysmiddle
- Продакшн-отказы SLO, самонаблюдаемость, безопасность и общая картинаsenior
- Петля инцидента: от пейджера до постмортема до предотвращенияmiddle
- Cache lines и false sharing: когда параллелизм замедляет кодmiddle
- SIMD и data layout: AoS vs SoA и разница в 4–8xmiddle
- Cache-oblivious алгоритмы, PGO и production failuressenior
- GC в production: наблюдаемость, безопасность, edge cases и управление флотомsenior
- Batching: амортизируй фиксированную цену каждой операцииjunior
- Окно батчинга: размер и время ожиданияmiddle
- Batching в Kafka и Postgresmiddle
- io_uring и наблюдаемость пакетированияmiddle
- От Nagle до io_uring: эволюция пакетированияmiddle
- Backpressure, изоляция сбоев и безопасность батчей в продакшенеsenior
- CI enforcement и RUM: делаем бюджеты рабочимиmiddle
- V8 JIT-пайплайн, HTTP-приоритеты и безопасность bundlesenior
- Цикл performance: дисциплина, а не проектjunior
- Классификация и исправление: сопоставление family bottleneck с методамиmiddle
- Observability-стек и CI gates: ловить регрессии до выпускаmiddle
- От инцидента к enforcement: SLO burn до верифицированного исправления за 35 минутmiddle
- Культура, экономика и масштаб performancesenior
- At-most-once, at-least-once, exactly-once: три контракта доставкиjunior
- Три ножки сбоя — где реально происходят дубликаты и потериmiddle
- Consumer-side dedup: самый дешёвый путь к exactly-once processingmiddle
- Kafka exactly-once semantics: idempotent producer и транзакцииmiddle
- SQS visibility timeout, DLQ и outbox patternmiddle
- Exactly-once в production: impossibility-доказательство, гибридные паттерны и реальные инцидентыsenior
- Что такое OAuth и почему пароли — не ответjunior
- Authorization code flow с PKCEmiddle
- Валидация ID-токена и управление JWKS-кешемmiddle
- Ротация refresh-токенов и scope-based least privilegemiddle
- Sender-constrained токены: DPoP и mTLSsenior
- OAuth в production: audience атаки, observability и реальные провалыsenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.