Погружение в v8. Часть 4. Управление памятью и сборка мусора.
Anastasia KotovaПредыдущие части
Погружение в V8. Часть 1. История.
Погружение в V8. Часть 2. Из чего состоит движок.
Погружение в v8. Часть 3. Парсинг, AST и анализ кода.
Введение
В своём телеграм-канале я уже делала краткий обзор механизма сборки мусора на примере Node.js. Однако это является одним из важных элементов в V8, поэтому сегодня мы рассмотрим его более подробно.
Многие современные языковые движки, такие как V8, динамически управляют памятью для запуска приложений, избавляя разработчиков от необходимости самостоятельно заботиться об этом. Движок периодически просматривает выделенную приложению память, определяет, какие данные больше не нужны, и очищает её, освобождая место. Этот процесс называется сборкой мусора (Garbage Collection, GC). Для оптимизации V8 использует сложную систему управления памятью с несколькими видами сборщиков мусора.
Основы управления памятью в V8
JavaScript-движок работает с двумя основными областями памяти: стеком и кучей. Стек — это быстрая и компактная структура, где хранятся примитивы и ссылки на объекты. Он работает по принципу LIFO (Last-In, First-Out), и данные в нём живут очень недолго, например, пока выполняется функция. Все «тяжёлые» сущности вроде объектов, массивов или замыканий хранятся в куче (heap), и именно с ней работают сборщики мусора.
Куча в V8 разделена на поколения. Есть молодое поколение (Young Generation), где появляются все новые объекты, и старое поколение (Old Generation), куда со временем перемещаются выжившие. Такое деление основано на статистике: большая часть объектов «умирает молодыми», то есть живут недолго и быстро становятся мусором. Если бы GC проверял всю кучу каждый раз, это было бы очень дорого. Но если сосредоточиться на молодом поколении, можно собрать большое количество мусора быстро и почти незаметно для пользователя.
Generational Garbage Collection
Поколенческая модель в V8 устроена так: все новые объекты создаются в Nursery — самой маленькой области кучи. Если они переживают первую сборку мусора, они перемещаются в Intermediate Generation, а если вторую — то дальше в Old Generation. Для движка это выгодно: короткоживущие объекты исчезают сразу, а GC не тратит время на проверку долгоживущих снова и снова.
Представим, что вы вызываете функцию тысячу раз и каждый раз создаёте временный объект. Почти все они умрут сразу после выхода из функции, и сборщик быстро подчистит Nursery. А вот глобальный кэш приложения доживёт до Old Generation. Там уже действуют более сложные алгоритмы, потому что память становится большой и фрагментированной.
Young Generation и Minor GC
Раньше молодое поколение убиралось с помощью простого алгоритма Cheney. Его суть заключается в следующем. Память делится на две равные части: from-space и to-space. Когда запускается сборка, GC берёт все живые объекты из from-space, копирует их в to-space и переставляет указатели. Всё, что осталось во from-space, считается мусором и освобождается целиком. Затем роли областей меняются.
Такой подход позволяет за один проход избавиться от фрагментации: новые объекты в to-space оказываются в плотном куске памяти, и выделение новых будет работать быстро. Цена — это необходимость скопировать данные, но поскольку речь идёт только о молодом поколении, которое обычно занимает десятки мегабайт, пауза очень короткая.
Однако, хоть этот алгоритм и был эффективен, он не мог использовать преимущества многоядерных процессоров. Поэтому со временем V8 перешёл на Parallel Scavenger. Это параллельный копирующий сборщик, который может использовать несколько потоков для обработки молодого поколения.
Parallel Scavenger работает следующим образом:
- Память молодого поколения по-прежнему делится на две области (from-space и to-space)
- Живые объекты копируются из одной области в другую
- Ключевое отличие: работа распределяется между несколькими потоками динамически
- Используется work-stealing алгоритм — если один поток завершил свою работу, он может помочь другим
Такой подход позволяет уменьшить время сборки молодого поколения, при этом сохраняются все преимущества копирующего алгоритма: отсутствие фрагментации и компактное размещение выживших объектов.
Old Generation и Major GC
Когда объект добрался до Old Generation, всё становится сложнее. Здесь память гораздо больше, и простое копирование уже не подходит. В ход идут алгоритмы Mark-Sweep и Mark-Compact.
Сначала движок делает фазу mark: обходит граф объектов и отмечает все живые. Потом идёт sweep: освобождаются неотмеченные участки памяти. Но такой метод оставляет дыры, и со временем куча фрагментируется. Чтобы это исправить, запускается compacting — объекты сдвигаются, память «сжимается», и освобождаются большие непрерывные куски.
Если бы это всё делалось разом, мы получали бы длинные stop-the-world паузы, из-за которых интерфейс подтормаживал бы и «лагал». Чтобы этого избежать, в V8 используются параллелизм и инкрементальность:
- Concurrent Marking — маркировка выполняется в фоновых потоках параллельно с выполнением JavaScript-кода
- Parallel Sweeping — освобождение памяти происходит в нескольких потоках одновременно
- Incremental Mark-Compact — сжатие памяти разбито на маленькие этапы
Благодаря этому GC перестаёт быть на 100% блокирующим и уносит большую часть работы в фоновый режим.
Orinoco
Orinoco — это общее название для нового поколения GC в V8. Туда вошёл Parallel Scavenger (многопоточная сборка Young Generation), а также все улучшения сборки для Old Generation. То есть теперь сборщик может одновременно и собирать мусор, и выполнять сжатие памяти, пока JavaScript продолжает работать.
Чтобы сделать это возможным, нужны вспомогательные инструменты.
Одним из ключевых стали write barriers. Этот механизм срабатывает при изменении ссылок на объекты в куче. Он используется, чтобы сборщик мусора был осведомлен обо всех изменениях в графе объектов во время инкрементной или параллельной сборки мусора. Основная задача — гарантировать, что обработанные объекты не указывают на ещё необработанные, и избежать пропуска живых объектов в процессе маркировки. При записи нового указателя из write barrier происходит проверка и, при необходимости, новое поле помечается и добавляется в рабочий список маркировки, чтобы сборщик позже мог его обработать. Это предотвращает ошибочное удаление объектов, на которые только что появились ссылки во время работы сборщика мусора.
В свою очередь, remembered sets — это структуры данных, которые отслеживают ссылки между разными поколениями сборки мусора, например, ссылки из старшего поколения (Old Generation) на молодое (Nursery или Intermediate). Это позволяет сборщику мусора эффективно находить объекты, на которые указывают из других поколений, без необходимости полного сканирования всей кучи.
Кроме того, V8 планирует сборку мусора не только по факту переполнения памяти, но и проактивно — используя idle-time планировщик браузера. Когда движок видит свободное окно (например, несколько миллисекунд до следующего кадра), он запускает подходящий этап GC: быстрый minor GC для молодого поколения, инкрементальную фазу маркировки или фоновый sweep для старого поколения. Если дедлайн небольшой, выполняется только часть работы; если окно длинное — можно позволить себе compacting. Ключевая идея — разбить тяжёлые операции на куски и параллелить их, чтобы не блокировать основной поток.
По итогу, все эти механизмы направлены на то, чтобы сборка была как можно менее заметна. И хотя паузы полностью исключить невозможно, они стали короче и реже.
Другие приёмы управления памятью в V8
Когда мы говорим о памяти в V8, чаще всего имеем в виду работу сборщика мусора. Но на самом деле оптимизации есть и гораздо раньше — на уровне того, как движок хранит данные и управляет кучей.
Pointer compression и memory cage. Современные процессоры используют 64-битные адреса, и если хранить каждый указатель в 8 байт, то объём памяти на одни только ссылки сильно вырастает. Чтобы этого избежать, V8 применяет приём сжатия указателей: вместо полного адреса хранится 32-битное смещение от базовой точки (так называемой memory cage). В результате все объекты располагаются внутри одного 4-гигабайтного диапазона, и этого хватает для JS-приложений. Такой подход снижает расход памяти почти вдвое и улучшает работу кэша процессора. Одновременно это повышает безопасность: cage действует как «песочница», не позволяя случайным ссылкам выходить за пределы кучи.
Учет внешней памяти (external memory). Не все данные, с которыми работает JS-код, находятся в куче. Например, ArrayBuffer или Buffer в Node.js могут хранить гигабайты бинарных данных вне V8. Чтобы не потерять контроль, движок учитывает такую память и умеет триггерить GC, если общий объём выходит за пределы разумного. Таким образом, даже ресурсы вне кучи включаются в общий бюджет памяти.
Oilpan и интеграция с DOM. В экосистеме Chromium есть отдельный проект Oilpan, отвечающий за управление памятью для C++-объектов в движке Blink. Его идея в том, чтобы связать GC V8 и сборку C++-объектов в единый механизм. Так, если у вас есть JS-объект и соответствующий ему DOM-узел, они будут собираться синхронно, без утечек и висячих ссылок.
Следующие части
Погружение в v8. Часть 5. Скрытые оптимизации.
Погружение в v8. Часть 6. От среды к среде.