Garbage Collection
Garbage Collection (GC) — автоматическое управление памятью в JavaScript. Движок (V8, SpiderMonkey) находит объекты, которые больше не используются, и освобождает память. Понимание GC критически важно для предотвращения утечек памяти.
Зачем нужно
JavaScript автоматически выделяет память при создании объектов и освобождает, когда они не нужны. Но автоматика не идеальна: замыкания, забытые таймеры, отсоединённые DOM-узлы создают утечки. Приложение начинает потреблять всё больше памяти, тормозить, крашиться. Знание GC позволяет писать код без утечек и отлаживать проблемы.
Где используется
Любое JavaScript-приложение: SPA (особенно долгоживущие), Node.js-серверы (процесс работает днями/неделями), игры, визуализации. Чем дольше работает приложение, тем критичнее утечки.
Предпосылки
Замыкания, Типы данных, DOM API, Chrome DevTools
Как работает память в JavaScript
1. ВЫДЕЛЕНИЕ (Allocation)
const obj = { name: 'Иван' }; // Выделена память в куче (heap)
const arr = [1, 2, 3]; // Выделена память для массива
const str = 'Привет'; // Выделена память для строки
2. ИСПОЛЬЗОВАНИЕ (Usage)
console.log(obj.name); // Чтение из памяти
arr.push(4); // Изменение в памяти
3. ОСВОБОЖДЕНИЕ (Release)
// Автоматически, когда объект недостижим
obj = null; // Старый объект { name: 'Иван' } станет мусором
Стек vs Куча
СТЕК (Stack): КУЧА (Heap):
- Примитивы (number, string, - Объекты
boolean, null, undefined) - Массивы
- Ссылки на объекты - Функции
- Фиксированный размер - Динамический размер
- Быстрый доступ (LIFO) - GC работает здесь
let x = 42; // Число 42 — в стеке
let obj = { a: 1 }; // Ссылка в стеке, объект { a: 1 } — в куче
let arr = [1, 2, 3]; // Ссылка в стеке, массив — в куче
function foo() {
let local = { b: 2 }; // Создан в куче
} // local выходит из scope → объект станет мусором
Алгоритм Mark-and-Sweep
V8 (Chrome, Node.js) использует mark-and-sweep — основной алгоритм GC:
Фаза 1: MARK (Пометка)
- Начинаем от "корней" (roots): глобальный объект, стек вызовов, замыкания
- Обходим граф объектов по ссылкам
- Помечаем каждый достижимый объект как "живой"
Фаза 2: SWEEP (Очистка)
- Проходим по всей куче
- Удаляем все НЕ помеченные объекты
- Освобождаем память
// Пример достижимости
let user = { name: 'Иван' }; // user → { name: 'Иван' } — достижим
let admin = user; // admin → тот же объект — 2 ссылки
user = null; // Одна ссылка убрана, но admin ещё ссылается
// Объект { name: 'Иван' } ещё ДОСТИЖИМ через admin → НЕ удаляется
admin = null; // Последняя ссылка убрана
// Объект { name: 'Иван' } НЕДОСТИЖИМ → GC удалит его
// Циклические ссылки — НЕ проблема для mark-and-sweep
function createCycle() {
let a = {};
let b = {};
a.ref = b; // a → b
b.ref = a; // b → a (цикл!)
}
createCycle;
// После выхода из функции ни a, ни b недостижимы от корней
// Mark-and-sweep удалит оба, несмотря на циклические ссылки
Поколения объектов (Generational GC)
V8 делит кучу на поколения для оптимизации:
Young Generation (Молодое поколение) — 1-8 MB
├── Nursery (Semi-space A)
└── Intermediate (Semi-space B)
Old Generation (Старое поколение) — до сотен MB
└── Объекты, пережившие 2+ цикла GC
Гипотеза поколений:
"Большинство объектов умирают молодыми"
Временные переменные, промежуточные результаты — живут миллисекунды.
Minor GC (Scavenge) — для Young Generation
- Запускается часто (миллисекунды)
- Быстрый (1-2 мс)
- Копирует живые объекты из одного semi-space в другой
- Объекты, пережившие 2 цикла → переходят в Old Generation
Major GC (Mark-Compact) — для Old Generation
- Запускается редко
- Медленнее (10-100+ мс)
- Mark-and-sweep + компактификация (дефрагментация)
- Может вызвать заметную паузу (GC pause)
Reference Counting (историческая справка)
// Старый алгоритм (IE6-7): подсчёт ссылок
// Каждый объект хранит счётчик: сколько ссылок на него
let obj = {}; // refcount = 1
let ref = obj; // refcount = 2
ref = null; // refcount = 1
obj = null; // refcount = 0 → удалить
// Проблема: циклические ссылки → утечка!
let a = {};
let b = {};
a.ref = b; // b.refcount = 2
b.ref = a; // a.refcount = 2
a = null; // a.refcount = 1 (b всё ещё ссылается)
b = null; // b.refcount = 1 (a всё ещё ссылается)
// Оба объекта никогда не будут удалены!
// Современные движки (V8) используют mark-and-sweep — эта проблема решена
Утечки памяти в JavaScript
1. Забытые таймеры и интервалы
// УТЕЧКА: интервал никогда не очищается
function startPolling() {
const data = getLargeData; // 10 MB данных
setInterval(() => {
// data удерживается замыканием, даже если не нужна
console.log(data.length);
}, 1000);
}
// startPolling → data НИКОГДА не освободится
// ИСПРАВЛЕНИЕ: очищаем интервал
function startPolling() {
const data = getLargeData;
const intervalId = setInterval(() => {
console.log(data.length);
}, 1000);
// Очистить при необходимости
return => clearInterval(intervalId);
}
const stopPolling = startPolling;
// Когда больше не нужно:
stopPolling;
2. Замыкания, удерживающие ссылки
// УТЕЧКА: замыкание держит огромный массив
function processData() {
const hugeArray = new Array(1000000).fill('x'); // ~10 MB
return function getLength() {
return hugeArray.length; // hugeArray удерживается!
};
}
const getLen = processData;
// hugeArray живёт в памяти, пока жив getLen
// ИСПРАВЛЕНИЕ: сохранить только нужное
function processData() {
const hugeArray = new Array(1000000).fill('x');
const length = hugeArray.length; // Извлекаем нужное
return function getLength() {
return length; // Только число, hugeArray освобождён
};
}
3. Отсоединённые DOM-узлы (Detached DOM)
// УТЕЧКА: DOM-узел удалён из дерева, но ссылка жива
let button = document.getElementById('myButton');
function onClick() {
// ...обработчик
}
button.addEventListener('click', onClick);
// Удаляем кнопку из DOM
button.parentNode.removeChild(button);
// Но переменная button ВСЁ ЕЩЁ ссылается на узел!
// Узел "отсоединён" от DOM, но не может быть удалён GC
// ИСПРАВЛЕНИЕ:
button.removeEventListener('click', onClick);
button.parentNode.removeChild(button);
button = null; // Убираем ссылку
// УТЕЧКА в SPA: компонент хранит ссылку на DOM
class Widget {
constructor {
this.element = document.createElement('div');
document.body.appendChild(this.element);
}
destroy {
// ПЛОХО: забыли удалить DOM-элемент
// this.element остаётся в памяти
}
// ХОРОШО:
destroy {
this.element.remove();
this.element = null;
}
}
4. Глобальные переменные
// УТЕЧКА: случайная глобальная переменная
function processRequest(data) {
results = data.map(transform); // Без let/const → глобальная!
// window.results = [...] — живёт вечно
}
// ИСПРАВЛЕНИЕ:
'use strict'; // TypeError: results is not defined
function processRequest(data) {
const results = data.map(transform); // Локальная
return results;
}
5. Event listeners не удалены
// УТЕЧКА: подписки без отписки (SPA)
class ChatComponent {
constructor {
// Каждый раз при создании компонента — новая подписка
window.addEventListener('resize', this.handleResize);
document.addEventListener('keydown', this.handleKey);
}
// Компонент уничтожен, но обработчики остались!
}
// ИСПРАВЛЕНИЕ:
class ChatComponent {
constructor {
this.handleResize = this.handleResize.bind(this);
this.handleKey = this.handleKey.bind(this);
window.addEventListener('resize', this.handleResize);
document.addEventListener('keydown', this.handleKey);
}
destroy {
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('keydown', this.handleKey);
}
}
// React — useEffect cleanup
function ChatComponent() {
useEffect(() => {
const handler = (e) => console.log(e.key);
window.addEventListener('keydown', handler);
// Cleanup — вызывается при размонтировании
return => window.removeEventListener('keydown', handler);
}, );
}
6. Кэш без ограничений
// УТЕЧКА: кэш растёт бесконечно
const cache = new Map();
function getData(key) {
if (!cache.has(key)) {
cache.set(key, computeExpensive(key));
}
return cache.get(key);
}
// cache растёт без ограничений
// ИСПРАВЛЕНИЕ: LRU-кэш с лимитом
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value); // Перемещаем в конец (свежий)
return value;
}
set(key, value) {
this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size > this.maxSize) {
// Удаляем самый старый (первый)
const oldest = this.cache.keys().next().value;
this.cache.delete(oldest);
}
}
}
WeakRef и FinalizationRegistry
WeakRef — слабая ссылка
// Обычная ссылка (сильная) — объект не удалится GC
let obj = { data: 'важное' };
let strongRef = obj; // Сильная ссылка
obj = null;
// Объект жив — strongRef удерживает
// WeakRef — НЕ препятствует сборке мусора
let obj2 = { data: 'важное' };
let weakRef = new WeakRef(obj2); // Слабая ссылка
obj2 = null;
// GC МОЖЕТ удалить объект, несмотря на weakRef
// Использование WeakRef
const value = weakRef.deref; // Получить объект или undefined
if (value) {
console.log(value.data); // Объект ещё жив
} else {
console.log('Объект был удалён GC');
}
WeakRef — кэш с автоматической очисткой
class WeakCache {
#cache = new Map();
get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;
const value = ref.deref;
if (!value) {
this.#cache.delete(key); // Объект удалён GC, чистим запись
return undefined;
}
return value;
}
set(key, value) {
this.#cache.set(key, new WeakRef(value));
}
}
FinalizationRegistry — реакция на GC
// Выполнить код когда объект удалён GC
const registry = new FinalizationRegistry((heldValue) => {
console.log(`Объект "${heldValue}" был удалён GC`);
// Очистка: закрыть файл, отправить метрику и т.д.
});
let obj = { data: 'test' };
registry.register(obj, 'my-object'); // Зарегистрировать объект
obj = null; // Когда GC удалит объект, вызовется callback
WeakMap и WeakSet — встроенные слабые коллекции
// WeakMap — ключи НЕ препятствуют GC
const metadata = new WeakMap();
let element = document.getElementById('app');
metadata.set(element, { clicks: 0, created: Date.now() });
element.remove();
element = null;
// element удалён → запись в WeakMap автоматически исчезнет
// WeakSet — значения НЕ препятствуют GC
const visited = new WeakSet();
let page = { url: '/home' };
visited.add(page);
page = null; // Объект удалён → исчезнет из WeakSet
Отладка утечек в Chrome DevTools
Memory tab — Heap Snapshot
1. F12 → Memory tab
2. Выбери "Heap snapshot" → Take snapshot
3. Выполни действие (открой/закрой модальное окно)
4. Сделай ещё один snapshot
5. Сравни: Comparison view показывает новые объекты
Фильтры:
- "Detached" — отсоединённые DOM-узлы (УТЕЧКА!)
- Retained Size — сколько памяти удерживает объект
- Shallow Size — размер самого объекта
Allocation Timeline
1. Memory tab → "Allocation instrumentation on timeline"
2. Start recording
3. Выполни действие, которое вызывает утечку
4. Stop recording
5. Синие столбцы — выделение памяти
6. Серые столбцы — уже освобождённые
7. Синие столбцы, которые НЕ стали серыми — потенциальная утечка
Performance Monitor
1. F12 → More tools → Performance Monitor
2. Следи за:
- JS Heap Size — должен расти и падать (пилообразный график)
- DOM Nodes — не должно расти бесконечно
- JS Event Listeners — не должно расти без причины
Если JS Heap Size только растёт — утечка!
Node.js — отладка памяти
// Мониторинг памяти в Node.js
setInterval(() => {
const usage = process.memoryUsage;
console.log({
rss: `${Math.round(usage.rss / 1024 / 1024)} MB`, // Всего
heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} MB`, // Куча
heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} MB`,
external: `${Math.round(usage.external / 1024 / 1024)} MB`,
});
}, 5000);
// Принудительный GC (только с --expose-gc)
// node --expose-gc app.js
if (global.gc) {
global.gc;
}
# Heap snapshot в Node.js
node --inspect app.js
# Открыть chrome://inspect → подключиться → Memory tab
Частые ошибки
1. Думать, что GC мгновенный
// GC работает КОГДА ХОЧЕТ, а не когда вы обнулили ссылку
let big = new Array(1e7);
big = null;
// Память НЕ освободится мгновенно!
// GC запустится в удобный момент (idle time)
// Нельзя полагаться на точное время освобождения
2. Утечки в setInterval внутри SPA-компонентов
// React: setInterval без cleanup
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval( => setCount(c => c + 1), 1000);
// ОБЯЗАТЕЛЬНО вернуть cleanup!
return => clearInterval(id);
}, );
return <span>{count}</span>;
}
3. Хранение ссылок на удалённые DOM-элементы в массиве
// ПЛОХО: массив удерживает все созданные элементы
const allElements = ;
function createWidget() {
const el = document.createElement('div');
document.body.appendChild(el);
allElements.push(el); // Ссылка навсегда!
return el;
}
function removeWidget(el) {
el.remove();
// el всё ещё в allElements → Detached DOM node
}
// ИСПРАВЛЕНИЕ: удалять из массива тоже
function removeWidget(el) {
el.remove();
const idx = allElements.indexOf(el);
if (idx !== -1) allElements.splice(idx, 1);
}
4. console.log в production
// console.log удерживает ссылки на объекты в DevTools!
const data = loadHugeDataset; // 50 MB
console.log(data); // DevTools держит ссылку → data не освободится
// Убирайте console.log из production:
// webpack: new webpack.DefinePlugin + terser
// Или: if (process.env.NODE_ENV !== 'production') console.log(data);
Практика
- Открой Memory tab в DevTools, сделай heap snapshot, найди самые крупные объекты
- Создай утечку с забытым setInterval, обнаружь её через Allocation Timeline, исправь
- Создай Detached DOM node, найди его в heap snapshot по фильтру "Detached"
- Реализуй LRU-кэш с ограничением размера
- Используй WeakMap для хранения метаданных DOM-элементов без утечек
- Напиши Node.js-скрипт с мониторингом process.memoryUsage — проверь, что GC работает (пилообразный график)