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);

Практика

  1. Открой Memory tab в DevTools, сделай heap snapshot, найди самые крупные объекты
  2. Создай утечку с забытым setInterval, обнаружь её через Allocation Timeline, исправь
  3. Создай Detached DOM node, найди его в heap snapshot по фильтру "Detached"
  4. Реализуй LRU-кэш с ограничением размера
  5. Используй WeakMap для хранения метаданных DOM-элементов без утечек
  6. Напиши Node.js-скрипт с мониторингом process.memoryUsage — проверь, что GC работает (пилообразный график)

Связанные темы

Ресурсы