Сборка мусора в JavaScript
Сборка мусора — автоматическое управление памятью в JavaScript, основанное на концепции достижимости и алгоритме mark-and-sweep.
Введение в сборку мусора
Сборка мусора автоматически управляет памятью в JavaScript. Она освобождает память, занятую данными, которые больше не нужны, поэтому вам почти никогда не приходится выделять или освобождать память вручную. На этой странице объясняется ключевая концепция, на которой строится вся система — достижимость — а также рассматриваются алгоритм mark-and-sweep, утечки памяти, которые всё же случаются, и то, как WeakMap/WeakSet помогают их избежать.
Понимание этого важно, потому что «автоматически» не означает «без утечек». Сборщик удаляет только то, что можно доказуемо признать недостижимым; если ваш код сохраняет скрытую ссылку, память остаётся выделенной на всё время работы программы.
Достижимость: ключевая концепция
Движок не отслеживает, используется ли объект «по смыслу». Он отслеживает, является ли объект достижимым — существует ли цепочка ссылок, ведущая к нему от какого-либо корня.
Корни — это значения, которые движок всегда хранит:
- Локальные переменные и параметры выполняемой в данный момент функции.
- Переменные и функции текущей цепочки вложенных вызовов.
- Глобальные переменные (свойства
globalThis/window).
Любой объект, достижимый путём прохода по ссылкам от корня — напрямую или через другие достижимые объекты — сохраняется. Всё остальное является мусором.
Пример: ссылка удерживает объект в памяти
Пояснение: Объект { name: "John" } был достижим через user. Копирование ссылки в admin создаёт второй путь к нему. Присвоение user = null убирает один путь, но admin по-прежнему указывает на объект, поэтому он остаётся достижимым и не удаляется сборщиком.
Взаимосвязанные объекты тоже удаляются
Распространённый миф гласит, что объекты, ссылающиеся друг на друга, выживают. Это не так — важна достижимость от корня, а не то, указывают ли объекты друг на друга.
Пояснение: obj1 и obj2 образуют цикл, но после того, как обе корневые переменные устанавливаются в null, ни один корень не ведёт в этот цикл. Весь «остров» становится недостижимым и подлежит сборке. Именно поэтому движки JavaScript используют модель достижимости, а не наивный подсчёт ссылок, который даёт утечку при циклах.
Как работает сборщик: mark-and-sweep
Движки JavaScript освобождают память с помощью алгоритма mark-and-sweep. Он сводит вопрос «этот объект больше не нужен» к точному вопросу «этот объект больше не достижим».
- Пометка (Mark). Начиная с корней, сборщик обходит все достижимые объекты и помечает их. Затем он переходит по их ссылкам, помечает эти объекты и так далее, пока не будет помечен каждый достижимый объект.
- Очистка (Sweep). Каждый непомеченный объект недостижим. Занимаемая им память освобождается.
Запустить этот процесс вручную невозможно, да и не нужно — в языке нет стандартного метода gc(). Реальные движки (например, V8) дополняют базовый алгоритм оптимизациями: генерационная сборка (молодые объекты умирают быстро, поэтому их проверяют чаще) и инкрементальная сборка (работа разбивается на части, чтобы избежать пауз). Описанная выше модель достижимости при этом остаётся неизменной.
Типичные источники утечек памяти
Утечка в JavaScript — это просто объект, который остаётся достижимым, хотя программа с ним уже закончила работу. Сборщик работает правильно — он просто не может определить, что ссылка устарела. Обращайте внимание на следующие паттерны.
Забытые таймеры и интервалы
Активный setInterval (или setTimeout) удерживает свой колбэк в памяти, а колбэк удерживает всё, что он захватил через замыкание. Если никогда не вызвать clearInterval, эта память будет занята на протяжении всей жизни страницы.
Отсоединённые DOM-узлы
Если вы удаляете элемент со страницы, но сохраняете на него ссылку в переменной, узел — вместе со всем его поддеревом — не может быть удалён сборщиком.
let detached = document.getElementById('list');
document.body.removeChild(detached);
// The node is gone from the page, but 'detached' still references it,
// so it stays in memory. Release it when done:
detached = null;Незакрытые обработчики событий
Обработчик, привязанный к DOM-элементу, удерживает в памяти как сам элемент, так и функцию-обработчик (со всем, что она захватила через замыкание). Удаляйте обработчики с помощью removeEventListener, когда они больше не нужны:
<button id="myButton">Click me</button>
<script>
const button = document.getElementById('myButton');
function alertClick() {
alert("Button clicked!");
button.removeEventListener('click', alertClick); // free the listener after first use
}
button.addEventListener('click', alertClick);
</script>Кнопка реагирует только на первый клик: обработчик удаляет себя через removeEventListener, освобождая ссылку, что позволяет сборщику мусора очистить её.
Глобальные кэши, которые только растут
Кэш, хранящийся в объекте уровня модуля или глобальном объекте, делает каждую запись достижимой вечно, если явно не вытеснять устаревшие данные. Неограниченный Map, используемый в качестве кэша, — классический источник медленных утечек.
Замыкания, захватывающие больше, чем вы думаете
Замыкание удерживает в памяти каждую переменную в областях видимости, на которые оно ссылается — даже те, которые фактически никогда не используются. Возврат небольшой внутренней функции из функции с большими локальными переменными может «прижать» эти переменные в памяти. Сводите к минимуму захватываемую область видимости и изучите раздел об области видимости переменных, где описывается, как замыкания сохраняют своё окружение.
Как помогают WeakMap и WeakSet
Map и Set хранят сильные ссылки на свои ключи и значения, поэтому всё, что в них хранится, остаётся достижимым. WeakMap и WeakSet хранят свои ключи слабо: если единственная оставшаяся ссылка на объект — это ссылка внутри WeakMap, объект всё равно может быть собран, и запись исчезнет вместе с ним.
Это делает WeakMap идеальным для привязки дополнительных данных к объектам (кэши, метаданные, работа с DOM-узлами) без принудительного удержания этих объектов в памяти. Поскольку записи могут исчезнуть в любой момент, WeakMap/WeakSet намеренно не поддерживают итерацию и не имеют свойства size.
Лучшие практики
- Отдавайте предпочтение локальным переменным; они автоматически выходят из области видимости и становятся доступными для сборки, когда функция возвращает управление.
- Ограничивайте глобальные переменные — они живут столько, сколько живёт приложение. Используйте модули и блочную область видимости (
let/const). - Останавливайте таймеры (
clearInterval/clearTimeout) и удаляйте обработчики событий с помощьюremoveEventListener, когда они больше не нужны. - Обнуляйте ссылки на отсоединённые DOM-узлы и другие большие объекты, с которыми вы закончили работу.
- Используйте
WeakMap/WeakSetдля кэшей и метаданных, индексируемых объектами, чтобы записи не переживали свои ключи.
Заключение
Сборка мусора в JavaScript полностью построена на достижимости: движок сохраняет любой объект, до которого можно добраться от корня, и освобождает остальные с помощью mark-and-sweep, который справляется даже с циклическими ссылками. «Автоматически» всё равно означает, что вы несёте ответственность за то, чтобы не держать устаревшие ссылки — забытые таймеры, отсоединённые DOM-узлы, постоянно растущие кэши и замыкания, захватывающие лишнее, — вот типичные виновники. Используйте WeakMap и WeakSet, когда хотите связать данные с объектами, не удерживая их в памяти.