Сборка мусора в V8: Scavenger, Mark and Sweep и Tri-color Marking
- пятница, 27 февраля 2026 г. в 00:00:04
В части 1 мы выяснили, что объекты в Heap не освобождаются сами по себе этим занимается Garbage Collector. Но как именно GC решает, что можно удалить? Если подумать, задача не такая очевидная, объект не нужен - понятие логическое, а GC работает с физическим графом ссылок.
Ответ в том, что GC не пытается угадать намерения программы. Он задаёт более простой вопрос, можно ли добраться до этого объекта из работающего кода? Если нельзя объект недостижим, и никакой код уже не сможет его использовать. Значит, память можно вернуть.
Алгоритм, реализующий эту логику, называется Mark and Sweep.
Представьте детектива, которому нужно найти всех выживших жителей города после катастрофы. У него есть список точек эвакуации - мест, откуда точно есть живые люди. От каждого выжившего он узнаёт адреса знакомых, идёт туда, проверяет - и так далее, обходя весь граф связей. Все, кого нашёл - живы. Все, кого не нашёл - считаются погибшими.
Это и есть Mark-and-Sweep. Детектив - GC. Точки эвакуации - GC Roots. Связи - pointer между объектами. Обход графа - фаза Mark. Освобождение памяти - фаза Sweep.
Алгоритм состоит из двух фаз. В фазе Mark GC обходит граф объектов, начиная от корней, и помечает все достижимые объекты как живые. В фазе Sweep GC проходит по памяти и освобождает блоки, не получившие метку.
GC Roots - объекты, которые считаются живыми по определению, без проверок. В V8 это глобальный объект (global в Node.js, window в браузере) и всё достижимое из него; активные Stack-фреймы со всеми локальными переменными выполняющихся функций; встроенные объекты V8 вроде прототипов; и pointer из Code Space на объекты данных.
GC Roots ├── global object → responseCache → result → metadata ├── Stack frame: main() → local vars → ... ├── Stack frame: processRequest() → buffer → ... └── Built-in prototypes → Array.prototype → ...
Всё, что достижимо из любого корня через любую цепочку pointer - живо. Всё остальное - мусор.
Наивный Mark-and-Sweep работает корректно, но у него есть существенная проблема, пока GC обходит граф объектов, программа не должна этот граф менять. Если код работает параллельно с маркировкой и создаёт новые pointer или удаляет старые - GC может пропустить живой объект (и освободит нужную память) или не убрать мёртвый (утечка).
Простое решение - stop the world (STW), остановить выполнение программы на время маркировки, провести её полностью, затем возобновить. Граф заморожен, GC работает со стабильным снимком.
Стоимость STW пропорциональна размеру живого Heap. Для Old Space в сотни мегабайт это десятки и сотни миллисекунд. Для HTTP сервера это означает, что все входящие запросы зависают те, что обычно отвечают за 5 мс, вдруг занимают 300 мс. Для современных приложений это неприемлемо.
V8 решал эту проблему постепенно, через несколько поколений алгоритмов. Чтобы понять их, нужен ещё один инструмент.
Если маркировку нельзя проводить разом - нужно уметь прерывать её и потом продолжать. Для этого необходимо отслеживать прогресс, знать, какие объекты уже обработаны, какие в процессе, а какие ещё не тронуты.
Для этого в V8 используется Tri color Marking - трёхцветная маркировка. Каждому объекту присваивается один из трёх цветов.
Белый - объект ещё не посещён GC. В начале маркировки все объекты белые. В конце маркировки оставшиеся белые объекты - мусор, который можно собирать.
Серый - объект помечен как достижимый, но его pointer ещё не обработаны. Серые объекты - это очередь работы GC. Маркировка продолжается, пока есть хоть один серый объект.
Чёрный - объект помечен как достижимый и все его pointer обработаны. Чёрный объект полностью завершён - к нему GC не вернётся.

Алгоритм прост, начать с GC Roots, покрасить их в серый. Взять серый объект, покрасить все его белые соседи в серый, сам объект - в чёрный. Повторять, пока серых нет. Оставшиеся белые - мусор.
Tri color Marking позволяет прерывать маркировку в любой момент - состояние корректно сохранено в цветах объектов. Это открывает путь к инкрементальной маркировке, GC обрабатывает несколько серых объектов, возвращает управление программе на несколько миллисекунд, затем берёт следующую порцию.
Без инкрементальной маркировки: │──── программа ─────│────── STW GC 300 мс ──────│──── программа ─────│ С инкрементальной маркировкой: │── прог ──│GC│── прог ──│GC│── прог ──│GC│ sweep │── прог ──│ 5ms 5ms 5ms 10ms
Вместо одной паузы в 300 мс - серия пауз по 5 мс. Суммарная работа GC та же, но влияние на latency несравнимо меньше.
Инкрементальная маркировка создаёт тонкую проблему. Пока GC делает паузу и программа работает, программа может изменить граф объектов так, что инвариант трёхцветной маркировки нарушится:

Программа во время паузы:
A.ref = C // чёрный A теперь ссылается на белый C B.ref = null // единственная другая ссылка на C удалена
Теперь C достижим только через A. Но A уже чёрный - GC не посетит его повторно. C останется белым и будет собран как мусор, хотя он живой. Это нарушение tri-color инварианта, чёрный объект не должен иметь прямых ссылок на белые.
Решение - Write Barrier. V8 автоматически вставляет проверку перед каждой записью pointer в объект. Когда программа выполняет A.ref = C, Write Barrier перехватывает операцию и красит C в серый - добавляя его в очередь маркировки.
// Что вы пишете: a.ref = c; // Что V8 выполняет: writeBarrier(a, c); // если a чёрный и c белый → покрасить c в серый a.ref = c; // сама запись
Write Barrier добавляет небольшой overhead на каждую запись pointer. Это цена инкрементальной маркировки, но она незначительна по сравнению с устранёнными STW-паузами.
Инкрементальная маркировка уменьшает паузы, но не убирает их полностью. V8 пошёл дальше - конкурентная маркировка, вспомогательные потоки GC выполняют маркировку одновременно с основным потоком, не останавливая его совсем.
Инкрементальная маркировка (только основной поток): Main thread: │── прог ──│GC│── прог ──│GC│── прог ──│ sweep │ GC threads: │ │ Конкурентная маркировка (фоновые потоки): Main thread: │──────────── программа ──────────────│финал│sweep│ GC threads: │──────── concurrent marking ─────────│
Write Barrier здесь ещё важнее, основной поток и GC-потоки работают с графом одновременно. V8 использует атомарные операции для корректности без тяжёлых мьютексов.
После маркировки все объекты имеют финальный цвет - чёрные живы, белые мертвы.
Sweep проходит по страницам памяти и добавляет белые объекты в списки свободных блоков (free lists). Будущие аллокации в Old Space берут блоки из этих списков. Sweep выполняется конкурентно и лениво - V8 не обязательно обрабатывает все страницы сразу, а может откладывать до момента, когда нужна память для аллокации.
После sweep Old Space может быть фрагментирован - объекты живы, но между ними пустые дыры разного размера. Compaction (уплотнение) решает это, V8 физически перемещает живые объекты, укладывая их вплотную, и обновляет все pointer. Это требует короткой STW-паузы - именно она создаёт самые длинные паузы, которые можно увидеть в --trace-gc. V8 уплотняет не весь Old Space при каждом Major GC, а только наиболее фрагментированные страницы.
Major GC (Mark-Sweep-Compact) обслуживает Old Space. New Space собирается другим алгоритмом - Scavenger - и именно он выполняется в 95% случаев.
Основа Scavenger - алгоритм Cheney, основанный на копировании. New Space разделён на два равных semi-space, From-Space и To-Space. Всегда только один из них активен - там происходят аллокации. Второй пустой.
Когда From-Space заполняется, запускается Scavenger. Он не ищет мусор напрямую - он копирует всё живое в To-Space, и всё, что не скопировалось, автоматически становится мусором.
Фаза 1 - Копирование корней. GC проходит по всем ссылкам из GC Roots и Stack-фреймов, которые ведут в New Space. Каждый живой объект копируется в To-Space. На старом месте остаётся forwarding pointer - адрес, куда переехал объект.
Фаза 2 - Сканирование To-Space. Каждый скопированный объект в To-Space может иметь pointer на другие объекты в From-Space, которые ещё не скопированы. Scavenger проходит по ним и копирует их тоже - пока в To-Space не останется необработанных объектов.
Фаза 3 - Завершение. Когда scan догнал free - все достижимые объекты скопированы и все pointer обновлены. Роли semi-space меняются, бывший To-Space становится новым From-Space, бывший From-Space объявляется пустым To-Space. Всё, что в нём осталось - мусор, который никуда не надо явно удалять.
Почему тратить половину New Space впустую - оправдано? Потому что копирование решает сразу три задачи за один проход, сборку мусора (мёртвые объекты просто не копируются), уплотнение (живые объекты укладываются компактно без фрагментации) и восстановление быстрой аллокации через bump pointer. Алгоритм на месте решил бы только первую задачу и потребовал бы отдельного уплотнения. 16 МБ суммарного New Space - приемлемая цена.
Forwarding pointer решает тонкую проблему. Если два объекта X и Y оба ссылаются на объект C, то при обработке X объект C копируется в C', и на старом месте остаётся forwarding pointer. При обработке Y Scavenger находит этот forwarding pointer и просто обновляет ссылку на C' - без повторного копирования. Без этого механизма один объект мог быть скопирован несколько раз, и разные pointer указывали бы на разные копии одного объекта.
При каждом Scavenger V8 решает, стоит ли продвигать живой объект в Old Space вместо копирования в To-Space? Решение основано на нескольких факторах - возрасте объекта (сколько Scavenger-циклов он пережил), давлении на память, заполненности To-Space. По умолчанию типичный порог - два пережитых цикла, но V8 адаптирует его динамически.
Это важно понимать при ревью кода. Посмотрите на такой пример:
function processItems(items) { const cache = {}; for (let i = 0; i < items.length; i++) { cache[items[i].id] = transform(items[i]); } return cache; }
Если processItems обрабатывает тысячи элементов, cache растёт в New Space, переживает Scavenger и продвигается в Old Space. Это нормально. Но если внутри цикла создаются временные объекты, которые случайно задерживаются дольше одного Scavenger-цикла - они тоже попадут в Old Space как мусор и спровоцируют дорогой Major GC.
Классический алгоритм Cheney однопоточен. Современный V8 использует Parallel Scavenger - несколько вспомогательных потоков работают одновременно, каждый сканируя свою часть To-Space. Это сократило типичное время Scavenger с ~10 мс до ~2 - 4 мс.
Параллельность требует синхронизации при работе с forwarding pointer, два потока могут одновременно попытаться скопировать один объект. V8 использует атомарную операцию compare and swap (CAS) - первый поток устанавливает forwarding pointer, второй видит, что он уже установлен, и использует его. Lock-free синхронизация без мьютексов.
Major GC запускается, когда Old Space приближается к своему лимиту. Он состоит из трёх последовательных фаз.
Сначала идёт Mark - конкурентная и инкрементальная маркировка через tri-color algorithm. Вспомогательные потоки GC работают параллельно с основным, Write Barrier обеспечивает корректность. Эта фаза занимает большую часть времени, но минимально влияет на latency именно благодаря конкурентности.
Затем - Sweep, конкурентный проход по страницам, добавление мёртвых объектов в free lists.
Наконец, опциональный Compact - уплотнение наиболее фрагментированных страниц. Он требует короткой финальной STW-паузы для обновления pointer.
Major GC timeline: │← concurrent marking (параллельно с программой) →│STW│← concurrent sweep →│ ↑ короткая финальная STW-пауза: обработка изменений во время маркировки + опциональный compact
При запуске Scavenger нужно найти все живые объекты в New Space. GC Roots - один источник. Но объекты в Old Space тоже могут ссылаться на объекты в New Space. Если не учесть это, Scavenger может собрать объект, на который ссылается Old Space, - это катастрофа.
Чтобы обнаружить такие ссылки, Scavenger теоретически должен был бы просканировать весь Old Space. Это уничтожило бы всю выгоду от разделения поколений.
Решение - Remembered Set. V8 поддерживает отдельную структуру данных, которая записывает все pointer из Old Space в New Space. Когда объект в Old Space получает ссылку на объект в New Space - Write Barrier добавляет её в Remembered Set. Scavenger использует Remembered Set как дополнительные корни - без сканирования всего Old Space.
Old Space: New Space: ┌──────────┐ ┌──────────┐ │ OldObj │───── ptr ─────→ │ NewObj │ └──────────┘ └──────────┘ │ ▼ Remembered Set: { OldObj.field → NewObj } ↑ Scavenger использует как корень
Зная, как работает GC, несколько паттернов плохого кода становятся очевидны. Если функция вызывается тысячи раз и каждый раз создаёт объект, который живёт чуть дольше одного Scavenger-цикла - этот объект продвигается в Old Space как мусор. Паттерн особенно коварен с Promise, промисы и их внутренние объекты могут пережить Scavenger, пока ожидают разрешения. Высокий Promise-throughput незаметно загрязняет Old Space.
Стоимость маркировки пропорциональна количеству живых объектов, а не мёртвых. Если в Old Space хранятся гигабайты живых данных - неограниченный кэш, огромные структуры - каждый Major GC обходит их все. Даже с инкрементальной маркировкой суммарное время GC растёт. Живой Heap нужно держать минимально необходимым.
Если Old Space близок к лимиту во время Scavenger, promotion невозможна - V8 вынужден запустить Major GC прямо посреди Minor GC. Это самый неприятный вид паузы, неожиданно длинная задержка там, где ожидалась короткая.
Аналогия с детективом точна, но создаёт ложное представление о последовательной работе. В современном V8 маркировку выполняют несколько потоков одновременно - каждый обрабатывает свою часть серых объектов с синхронизацией через атомарные операции.
Кроме того, GC не возвращает память операционной системе сразу после сборки. V8 держит освобождённую память в своём пуле для последующих аллокаций. process.memoryUsage().heapUsed может снизиться после GC, но process.memoryUsage().heapTotal останется прежним.
Scavenger и Major GC связаны через Remembered Set и Write Barrier. Каждая запись pointer из Old в New обновляет Remembered Set. Если Old Space большой и активно ссылается на New Space - Scavenger замедляется, даже если сам New Space небольшой.
Как устроена память в V8, что живёт на Stack, что в Heap, и что такое pointer. (ссылка на первую часть).
Как утечки памяти возникают в JavaScript, как их находить через DevTools, и когда использовать WeakRef.