Последние пару лет изменили то, как пишется код. Copilot, Cursor, ChatGPT, Claude - все это генерирует вполне работающий JavaScript быстрее, чем успеваешь сформулировать задачу. Это удобно. Но у этого удобства есть обратная сторона, сгенерированный код работает правильно в смысле делает что просили, но не всегда правильно в смысле не течет по памяти и не роняет прод под нагрузкой.
Модели хорошо знают синтаксис и паттерны. Они значительно хуже разбираются в том, что происходит под капотом конкретного движка. Closure, захватывающий лишние данные. Event listener, который никогда не снимается. Объект, который непреднамеренно продлевает жизнь половине DOM дерева. Все это - настоящие ошибки, которые реально встречаются в проде и ни один линтер их не поймает.
Чтобы замечать такие вещи при ревью, нужно понимать, как именно V8 хранит объекты и когда решает их удалить. Это не академическое знание - это инструмент, который меняет то, на что смотришь в коде.
Эта серия состоит из трех частей. Первая - про то, как V8 вообще организует память, что такое Stack и Heap, чем они отличаются и что такое pointer. Вторая - про сборщик мусора, как он определяет, что живо, а что нет и как делает это, не останавливая программу надолго. Третья - практическое применение, откуда берутся утечки памяти, как их искать и как писать код, который не создает лишней работы для GC.
Начнем с основ:
Два способа хранить данные.
Когда V8 выполняет ваш код, ему нужно где то держать данные - переменные, объекты, промежуточные результаты вычислений. Для этого существует два принципиально разных механизма, Stack и Heap. Они устроены по разному, обслуживают разные нужды и работают по разным правилам.
Хорошая аналогия для Stack - стопка тарелок на кухне. Класть можно только сверху, брать тоже только сверху. Порядок строгий и предсказуемый, следующее место всегда известно заранее. Heap больше похож на склад с открытым пространством, вещи можно положить в любое свободное место, ящ��ки бывают разного размера, некоторые связанны между собой, а чтобы найти нужный - есть бумажка с адресом.
Stack: строгий и быстрый.
Stack это непрерывная область памяти, которую операционная система выделяет при запуске процесса. В Node.js его размер по умолчанию около 984 кб на основной поток (флаг --stack-size позволяет изменить это). Это фиксированный, заранее выделенный блок, не растет и не сжимается.
Управление Stack устроено очень просто, есть один регистр процессора, который называется Stack Pointer (далее SP). Он всегда указывает на вершину стека - текущую границу используемой памяти. Когда нужно выделить место под новые данные SP сдвигается на нужное количество байт. Когда данные больше не нужны SP сдвигается обратно. Никакого поиска свободного места, никакого учета - одна инструкция. Именно поэтому аллокация на Stack практически бесплатная.
Каждый вызов функции создает на Stack новый Stack Frame - блок памяти с локальными переменными функции, ее аргументами, адресом возврата и сохраненными регистрами. Когда функция завершается, ее фрейм уничтожается мгновенно, SP просто сдвигается назад и память считается свободной. Никакой уборки, никакого GC.

На Stack живут только данные с заранее известным фиксированным размером и строго ограниченным временем жизни - не дольше вызова функции. В JavaScript это числа, булевы значения, undefined, null и указатели на объекты в Heap. Про указатели поговорим отдельно.
Heap: гибкий и долгоживущий.
Heap значительно большая область памяти. В Node.js по умолчанию от 1.5 до нескольких гигабайт в зависимости от платформы, управляется флагами --max-old-space-size и --max-semi-space-size. В отличие от Stack, Heap не имеет встроенного порядка.
Когда вы пишете const user = { name: "Aleksey" }, объект { name: "Aleksey" } создается именно здесь. Аллокация в Heap сложнее, чем на Stack, V8 поддерживает внутренний аллокатор, который ведет учет свободных блоков и решает, куда поместить новый объект. Простейший вариант называется bump pointer allocation, V8 держит указатель на границу использованной памяти и сдвигает его вперед при каждой аллокации. Это почти так же быстро, как Stack - но только пока есть непрерывное свободное место и нет фрагментации.
Ключевое отличие Heap от Stack, объекты здесь не освобождаются автоматически при выходе из функции. Когда Stack Frame уничтожается, указатель на объект исчезает - но сам объект в Heap продолжает занимать память. Определить, что объект больше никому не нужен и память можно вернуть - это задача сборщика мусора. Подробно об алгоритмах GC в части 2.
Pointer: мост между Stack и Heap.
Вернемся к строчке const user = { name: "Aleksey" }. На Stack создается не сам объект, а pointer - 64 битное число, содержащее адрес объекта в Heap. Объект живет в Heap, на Stack хранится только бумажка с адресом.
Pointer объясняет одну из самых частых точек путаницы - разницу между передачей по значению и передачей по ссылке. В JavaScript все передается по значению. Для объекта значением является pointer.
function mutate(obj) { obj.name = "Oleg"; // мутируем объект по адресу из pointer } function reassign(obj) { obj = { name: "Kostya" }; // меняем локальную копию pointer } const user = { name: "Aleksey" }; mutate(user); // user.name === "Oleg" — мутация через pointer работает reassign(user); // user.name === "Oleg" — переприсвоение pointer не работает
В mutate мы получили копию pointer, пошли по адресу в Heap и изменили объект. Изменение видно всем, кто держит pointer на тот же объект. В reassign мы присвоили локальной копии pointer новое значение - но оригинальный pointer в вызывающем коде не изменился.
Это различие объясняет еще несколько вещей, почему передача объекта в функцию не копирует его, почему obj1 === obj2 сравнивает адреса, а не содержимое и почему мутация объекта через любую ссылку видна всем, что держит pointer на него.
Что где живет: полная картина
const x = 42; // 42 — на Stack напрямую const flag = true; // true — на Stack напрямую const name = "Aleksey"; // * см. ниже о строках const user = { age: 25 }; // pointer на Stack, объект в Heap const arr = [1, 2, 3]; // pointer на Stack, массив в Heap function greet(person) { // person — копия pointer, живёт на Stack этого фрейма const greeting = "Hello"; // * см. ниже о строках return greeting + person.name; }
Со строками чуть сложнее. Короткие строки в V8 могут хранится в специальном пуле и разделяются между всеми, кто использует одинаковое значение. Длинные строки живут в Heap как объекты. С точки зрения JavaScript кода строки ведут себя как примитивы - иммутабельны, сравниваются по значению - но их физическое расположение зависит от реализации движка.
Зачем два механизма?
Можно задаться вопросом, почему не хранить все в одном большом Heap? Stack работает быстро именно потому, что строго упорядочен - но эта упорядоченность требует, чтобы время жизни данных совпадало с временем жизни функции. Нельзя держать на Stack объект, который нужен после возврата из функции. Heap лишен этого ограничения - объект живет, пока на него есть ссылки. Но за гибкость приходится платить сложностью аллокации и необходимостью GC.
Проще говоря, Stack выбирают, когда время жизни данных известно заранее. Heap - когда нет. V8 принимает решение автоматически, но понимая механизм, вы можете видеть в коде, что создает лишнее давление на GC, а что нет.
Об одном коварном антипаттерне.
Если вы создаете объекты в горячих циклах - тех, что выполняют тысячи раз, каждая итерация добавляет новый объект в Heap. Большенство из них сразу станут мусором, но GC все равно должен их обработать. Часто достаточно вынести объект за пределы цикла и переиспользовать его:
// Создаёт новый объект на каждой итерации for (let i = 0; i < 100000; i++) { processData({ index: i, value: items[i] }); // новый объект каждый раз } // Переиспользует один объект const data = { index: 0, value: null }; for (let i = 0; i < 100000; i++) { data.index = i; data.value = items[i]; processData(data); }
Это работает только если processData не хранит ссылку на объект - если хранит, вы получите баг, где все записи указывают на один и тот же объект. Но в горячих путях, где объекты действительно временные, это заметно снижает нагрузку на GC.
Про Stack Overflow стоит сказать отдельно, Stack имеет фиксированный размер и неограниченная рекурсия исчерпывает его с ошибкой RangeError: Maximum call stack size exceeded. Это не баг V8 - это физический предел. Хвостовая рекурсия теоретически решает проблему, но V8 не реализует Tail Call Optimization.
Heap изнутри: пространства памяти V8
Прежде чем завершить эту часть, стоит заглянуть немного глубже - потому что Heap в V8 устроен не как единый монолитный блок, а как несколько отдельных пространств. Это напрямую влияет на то, как работает GC.
Представьте городской архив. Можно сложить все документы в один зал - но тогда уборка устаревших бумаг потребует перебора всего зала, останавливая работу на часы. Разумнее разбить зал на секции, новые поступления, долгосрочное хранение, большие документы. Уборку маленькой комнаты можно делать каждые несколько минут - быстро и без остановки работы.
V8 делает именно это. Разбиение Heap на пространства следует из конкретного наблюдения, сделанного исследователями еще в 1984 году - "большинство объектов умирают очень молодыми". Точнее 80-98% аллоцированных объектов становятся недостижимыми в течение нескольких миллисекунд после создания. Временные результаты вычислений, промежуточные объекты в цепочках методов, локальные переменные - все это создается и тут же становится мусором. Лишь малый процент объектов выживает надолго - кэши, конфигурация, корневые структуры данных. Это наблюдение называется гипотезой о поколениях и V8 построил на нем всю архитектуру Heap.

В ранних версиях V8 существвали отдельные Old Pointer Space, Old Data Space и Map Space. В современном V8 это разделение убрано, Old Space единый, а Hidden Classes хранятся в нем же. V8 использует page-level metadata для оптимизации сканирования вместо физического разделения пространств.
New Space - первое место, куда попадает любой новый объект. Он намеренно маленький, 1-8 мб на каждую половину (флаг --max-semi-space-size). Маленький размер не ограничение, а замысел, маленькое пространство можно собрать быстро. New Space разбит на from-Space и To-Space, зачем нужны две половины - станет ясно из описания алгоритма Scavenger в части 2.
Old Space - принимает объекты, пережившие несколько циклов сборки мусора в New Space. Этот процесс называется promotion. Old Space значительно больше - до нескольких гигабайт и убирается реже и дороже.
Large Object Space - хранит объекты размером более ~128кб - большие массивы, крупные JSON-объекты, ArrayBuffer. Такие объекты никогда не копируются- копирование мегабайтных объектов слишком дорого.
Code Space - хранит скомпилированный байткод Ignition и машинный код TurboFan. Страницы памяти здесь помечены как исполняемые на уровне ОС и не смешиваются с данными - это политика безопасности W^X (Write XOR Execute).
Разберем арифметику, которая объясняет, зачем все это нужно. Node.js приложение обрабатывает 1000 HTTP запросов в секунду. Каждый запрос создает ~500 временных объектов. Это 500 000 новых объектов в секунду 95% из которых мертвы через 10 мс. Если бы GC сканировал весь Heap при каждой сборке - он обходил бы гигабайты памяти каждые несколько миллисекунд. Паузы были бы в секундах.
Разбиение решает задачу, 95% мусора убирается Minor GC из 8мб. New Space за 1-2мс. Old Space убирается редко и только когда нужно. Дешевые операции - часто, дорогие - редко.
Уточнение про аналогию склад.
Аналогия со складом создает одно неверное представление, в V8 объекты не лежат статично. GC может перемещать объекты в памяти - это называется compaction. После сборки мусора GC сдвигает выжившие объекты вп��отную, устраняя фрагментацию. Адрес объекта в Heap нестабилен в течение жизни программы и V8 автоматически обновляет все pointer при перемещении. Pointer в V8 - живая ссылка, а не статический адрес.
Далее Часть 2 - Сборка мусора: Scavenger, Mark and Sweep, Tri color Marking.
В следующей части Разберем, как именно V8 определяет какие объекты живы, а какие - мусор. Алгоритм Cheney, трехцветная маркировка, Write Barrier и конкурентная маркировка. (ссылка на часть 2)
