Куча V8 и раскладка объекта
Куча V8 разбита на пространства — new space (два semi-space, bump-pointer аллокация), old space, large object space, code space, read-only space. Аллокация в new space — это инкремент указателя, пока semi-space не заполнится, запуская Scavenge.
Аллокация свежего объекта в V8 часто быстрее, чем malloc. Нет поиска по free-list, нет поиска класса размера — просто один инкремент указателя. Подвох в том, что это работает лишь потому, что память, в которую он аллоцирует, одноразовая: маленькие ясли, которые целиком эвакуируются каждые несколько миллисекунд. Понимание того, как куча нарезана на пространства, объясняет и почему аллокация так дёшева, и почему короткоживущий мусор почти бесплатен, а долгоживущие данные дороги.
Куча — не один пул, это несколько пространств
V8 не относится к куче как к одному нерасчленённому блобу. Он разбивает её на пространства (spaces), у каждого своя стратегия аллокации и своё отношение к сборщику мусора. Разбиение продиктовано поколенческой гипотезой: большинство объектов умирает молодыми, поэтому выгодно аллоцировать их где-то дёшево и одноразово и «продвигать» лишь редких выживших.
- New space (молодое поколение) — маленькое (от пары МБ до десятков МБ), где рождается каждый нормальный объект. Оно разбито на два semi-space (from-space и to-space). Аллокация тут — это дешёвый путь, описанный ниже. New space собирается очень часто минорным GC под названием Scavenger.
- Old space (tenured) — большое, куда объекты, пережившие пару Scavenge, продвигаются. Собирается редко мажорным GC (mark-compact). Долгоживущие данные живут здесь.
- Large object space (LOS) — объекты выше порога (около 600 KB, примерно полнабора страниц) аллоцируются здесь напрямую и никогда не двигаются, потому что копировать огромный объект на каждый GC расточительно. Большой типизированный массив или гигантская строка попадают сюда.
- Code space — держит JIT-скомпилированный машинный код от Sparkplug/Maglev/TurboFan. Часто в паре с выделенным code-range, чтобы сгенерированный код лежал в известной области.
- Read-only space — неизменяемые корни (общий Oddball
undefined, частые Map, интернализированные корни строк). Будучи неизменяемым, оно может быть общим на изоляты, экономя память, когда вы порождаете много воркеров.
Вместе эти пространства реализуют одну дисциплину: аллоцируй дёшево и временно, плати позже только если данные выживают. Без разбиения каждая аллокация требовала бы управления free-list с рождения, а каждый GC обрабатывал бы страницы с кодом, неизменяемые корни и гигантские массивы так же, как обычные короткоживущие объекты — делая и аллокацию, и сборку несравнимо дороже.
(Исторически было и отдельное Map space под hidden classes; позже V8 свернул его в old space.)
Bump-pointer аллокация: почему new почти бесплатен
Внутри semi-space аллокация — это bump-pointer (продвижение указателя). V8 держит два значения: указатель на следующий свободный байт (top) и конец пространства (limit). Чтобы аллоцировать N байт:
// Концептуальный быстрый путь аллокации в new space:
function allocate(bytes) {
if (top + bytes > limit) return slowPath(bytes); // semi-space полон -> запуск GC
const addr = top;
top += bytes; // <-- вся аллокация: один инкремент указателя
return addr;
}Вот и всё на быстром пути: сравнить, затем инкрементировать указатель. Никакого поиска по free-list, никакого слияния, никаких классов размера — работы, которую делает malloc. Это возможно только потому, что new space собирается копированием: когда оно заполняется, живые объекты целиком вывозятся, а всё semi-space сбрасывается в пустоту, так что фрагментацией управлять не приходится.
Когда top прошёл бы limit, semi-space полон и V8 запускает Scavenge (минорный GC): он копирует живые объекты из from-space в to-space (и продвигает объекты, уже пережившие один раз, в old space), затем меняет роли двух semi-space местами. Мёртвые объекты вообще не трогаются — их просто оставляют в покинутом from-space, что делает сбор короткоживущего мусора по сути бесплатным. Это глубинная причина, почему «аллоцировать много короткоживущих объектов дёшево; держать много объектов живыми дорого».
Раскладка памяти объекта
Теперь приблизим один обычный объект в куче. Его раскладка, по порядку:
Объект JS в памяти:
[ Map pointer ] смещение 0 — hidden class: вид + раскладка свойств
[ properties backing-store ptr ] — внеобъектные именованные свойства (overflow)
[ elements backing-store ptr ] — индексные элементы (массивная часть)
[ in-object property slot 0 ] — первое быстрое свойство, внутри объекта
[ in-object property slot 1 ]
...- Map pointer (смещение 0) — hidden class, как мы установили два урока назад: какого вида этот объект и где живёт каждое именованное свойство.
- Properties backing store — указатель на внеобъектный массив (или словарь), хранящий свойства, не уместившиеся в in-object слоты.
- Elements backing store — указатель на индексные элементы (
obj[0],obj[1], …); массивы держат свои данные с числовыми ключами здесь, отдельно от именованных свойств. - In-object property slots — первые несколько именованных свойств хранятся внутри собственного блока памяти объекта (одна загрузка на чтение), остальные переливаются в properties backing store (лишнее разыменование).
Число in-object слотов фиксируется при создании Map, поэтому объявление горячих свойств заранее в конструкторе держит их in-object и быстрыми. Hidden classes и in-object против out-of-object свойств мы разбираем глубоко в юните про hidden classes; здесь зафиксируйте скелет: указатель на Map, два указателя на backing-store, затем inline-слоты.
- Размер new space
- от пары МБ до десятков МБ
- Аллокация в new space
- один bump указателя
- Частота минорного GC (Scavenge)
- каждые несколько мс под нагрузкой
- Порог large object space
- около 600 KB
- Продвижение в old space
- после ~2 переживших Scavenge
- Заголовок объекта до данных
- Map ptr + 2 ptr на backing-store
Почему аллокация в new space может быть одним инкрементом указателя без поиска по free-list?
Аллоцируется типизированный массив на 5 МБ. В какое пространство он попадёт и что в нём особенного?
Расставьте, что происходит, когда аллокация в new space достигает лимита и запускается Scavenge.
- 1 Bump-аллокация толкнула бы top за limit — semi-space полон
- 2 V8 ставит паузу и начинает Scavenge (минорный GC)
- 3 Живые объекты копируются из from-space в to-space
- 4 Объекты, уже пережившие один раз, продвигаются в old space
- 5 Два semi-space меняются ролями; мёртвые брошены, пространство сброшено
▸Почему это работает
Почему делить new space на два semi-space, а не на одно? Потому что Scavenger — копирующий сборщик: ему нужно место назначения, куда копировать выживших, пока он читает оригиналы. From-space — где объекты живут сейчас; to-space — пустое назначение. После копирования роли меняются, так что только что опустевшее from-space становится следующим to-space. Цена в том, что половина new space всегда простаивает резервом — классический размен «место за скорость», который делает сбор простым копированием-и-обменом с нулевой фрагментацией. Scavenger и мажорный GC мы полностью раскрываем в юните про сборку мусора.
- 01Назовите пространства кучи V8 и для чего каждое.
- 02Объясните bump-pointer аллокацию и почему она так дёшева.
- 03Опишите раскладку памяти обычного объекта JS.
V8 разбивает кучу на пространства, а не относится к ней как к одному пулу, что продиктовано поколенческой гипотезой: большинство объектов умирает молодыми. New space, молодое поколение, маленькое и разбито на два semi-space; в нём рождается каждый нормальный объект, и аллокация там — это один bump указателя top, проверяемого против limit, дешевле malloc, потому что нет управления free-list. Эта дешевизна зависит от сбора копированием: когда semi-space заполняется, Scavenger копирует живые объекты в другое semi-space, продвигает уже переживших один раз в old space, меняет два semi-space местами и бросает мёртвых, так что короткоживущий мусор почти ничего не стоит. Old space держит tenured-выживших и собирается редко мажорным mark-compact GC; large object space держит объекты выше примерно 600 KB, аллоцируемые напрямую и не двигаемые; code space держит JIT-машинный код; а read-only space держит неизменяемые корни, общие на изоляты. Отдельный объект разложен как указатель на Map по смещению 0, затем указатели на backing-store его свойств и элементов, затем in-object property slots — inline стоят одной загрузки, а overflow стоит разыменования. Теперь, когда встретишь процесс, где пул объектов «для экономии на аллокации» продлил паузы GC, — ты знаешь почему: пулинг продвигает объекты в old space, а мажорный GC — дорогой. Аллоцируй свободно; избегай удержания.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.