Skip to main content

Збирач сміття (Garbage Collector)

Збирач сміття (garbage collector, GC) це частина рантайму (V8 у Chrome і Node.js, SpiderMonkey у Firefox тощо), яка автоматично звільняє пам'ять, зайняту об'єктами, до яких більше не можна дістатися від коренів (roots) програми. Головний критерій це досяжність (reachability): якщо об'єкт досяжний від коренів (глобальні об'єкти, стек поточних викликів, регістри, активні замикання, дерево DOM тощо), він живий; якщо недосяжний, його пам'ять може бути звільнена.

Теорія

TL;DR

  • GC видаляє недосяжні об'єкти, а не «непотрібні»: єдиний критерій це досяжність від коренів.
  • Базовий алгоритм це mark-and-sweep, часто з compaction (ущільненням) проти фрагментації.
  • Сучасні реалізації поколінські (young і old), інкрементальні та конкурентні, щоб зменшити паузи.
  • Підрахунок посилань (reference counting) у JS не застосовують: він ламається на циклах A -> B -> A.
  • WeakMap / WeakSet не заважають збиранню, FinalizationRegistry дає колбек після збирання, але без гарантій часу.
  • Для розробника ключове це керування посиланнями: слухачі, таймери, кеші, замикання, вузли DOM.

Швидкий приклад

javascript
let user = { name: 'Alice' }; // об'єкт досяжний від кореня через змінну user let admin = user; // друге посилання на той самий об'єкт user = null; // перше посилання прибрали, але об'єкт ще живий: // його тримає admin, тож GC його не звільнить admin = null; // тепер об'єкт недосяжний і може бути зібраний // (коли саме, вирішує рантайм, а не ваш код)

Як GC вирішує, що видаляти

Досяжність (reachability)

Корені це те, до чого рантайм має доступ завжди: глобальний об'єкт, змінні у стеку поточних викликів, регістри, активні замикання, дерево DOM. Усе, до чого можна дійти від коренів ланцюжком посилань, вважається живим. Усе інше це сміття.

Mark-and-sweep

  1. Mark (позначання): обходимо граф об'єктів від коренів і позначаємо всі досяжні.
  2. Sweep (замітання): проходимо купою і звільняємо всі непозначені.

Додатково може виконуватися compaction (ущільнення): переміщення вцілілих об'єктів, щоб усунути фрагментацію пам'яті.

Чому не reference counting

Лічильники посилань ламаються на циклах (A -> B -> A): два об'єкти тримають одне одного, лічильники ніколи не падають до нуля, і виникає витік. Тому в JS використовують трасувальні GC (mark-and-sweep або mark-compact), для яких цикл не проблема: якщо компонент недосяжний цілком, його буде зібрано.

Поколінський, інкрементальний і конкурентний GC

Поколінський GC (generational)

Спостереження: «більшість об'єктів живуть недовго». Звідси:

  • Young, нове покоління (nursery, new space): сюди потрапляють нові об'єкти, тут часті швидкі збирання (minor GC, scavenging).
  • Old, старе покоління (tenured, old space): сюди просувають (promote) об'єкти, що пережили кілька мінорних збирань, збираються рідше, але дорожче (major GC).

Технічно це реалізується через:

  • bump-pointer allocation (дуже швидке виділення підряд),
  • copying GC для young: два напівпростори, живі об'єкти копіюються з from-space до to-space,
  • mark-sweep або mark-compact для old.

Write barrier і remembered set

Щоб молоде і старе покоління коректно посилались одне на одного, рантайм використовує write barrier разом із card marking / remembered set: недорогий запис метаданих під час створення посилань «зі старого в молоде», щоб мінорний GC знав, які старі об'єкти можуть тримати молоді.

Трикольорове маркування і короткі паузи

Повна «зупинка світу» (stop-the-world) з великими паузами неприйнятна ані в UI, ані на сервері, тому застосовують:

  • Tri-color marking (білий, сірий, чорний): формальна модель позначання, яка дозволяє інкрементально просувати обхід графа.
  • Incremental GC: позначання розбивають на маленькі порції, між ними застосунок продовжує працювати.
  • Concurrent GC: частину роботи (наприклад, розмітку) виконують паралельно із застосунком в іншому потоці.
  • Паралельні marking і sweeping, compaction з мінімізацією пауз.

Ідея проста: зменшувати jank і фризи коштом маленьких передбачуваних пауз.

Як це влаштовано у V8

Простори купи

Купа поділена на простори: new space (напівпростори копіювального GC), old space (mark-sweep або mark-compact), code space, map space, large object space (без переміщення об'єктів).

Minor GC і Major GC

  • Minor GC (Scavenger): швидко копіює живі об'єкти з from-space до to-space, ті, що пережили N збирань, отримують promotion у old space.
  • Major GC: точна (precise) розмітка, потім sweep або compact, інкрементальна і конкурентна, щоб скорочувати паузи.
  • Тонкощі: write barriers, remembered sets, паралельні marking і sweeping, евристики щодо тиску на пам'ять і фрагментації.

Коли GC запускається

  • Немає вільного місця для нового виділення (allocation failure).
  • Тиск на пам'ять з боку ОС або хоста.
  • Евристики за зростанням купи і частотою мінорних збирань.
  • У DevTools збирання можна запустити вручну, але лише для налагодження.

Слабкі посилання і фіналізація

  • WeakMap і WeakSet: ключі (для WeakMap) та елементи (у WeakSet) не перешкоджають збиранню. Щойно об'єкт більше ніде не досяжний, GC може його звільнити, і запис зникне «сам». Це корисно для кешів і мемоїзації без витоків.
  • FinalizationRegistry: дозволяє виконати колбек після того, як об'єкт зібрано. Важливі застереження: час виклику недетермінований, порядок не гарантований, виклику може не бути взагалі (наприклад, під час завершення процесу). Покладатися на нього в логіці не можна, тільки для «м'яких» побічних дій, на кшталт очищення зовнішніх кешів.

Приклад безпечного кешу:

javascript
const cache = new WeakMap(); // ключ це об'єкт, значення це обчислення function heavy(obj) { if (cache.has(obj)) return cache.get(obj); const val = compute(obj); cache.set(obj, val); return val; }

Витоки пам'яті: практика і діагностика

Типові джерела витоків

  1. Довгоживучі посилання: кеші, сінглтони, замикання, глобальні структури, збережені вузли DOM.
  2. Події та таймери: не зняли обробник або setInterval, підвислі посилання з Promise.
  3. Цикли DOM і JS: тримаєте посилання на видалений з DOM вузол або на його замикання.
  4. Необмежені структури: Map або масив ростуть без очищення.

Що робити

  • Знімати обробники подій і таймери:
javascript
const handler = () => {}; el.addEventListener('click', handler); // ... el.removeEventListener('click', handler);
  • Для кешів, прив'язаних до об'єктів, використовувати WeakMap або WeakSet.
  • Уникати зайвих глобальних і модульних посилань.
  • Обережно із замиканнями: не замикайте в них зайві великі об'єкти.
  • Обмежувати розмір кешів (наприклад, LRU) і очищати їх.
  • У React стежити за cleanup у useEffect.

Інструменти діагностики

  • Chrome DevTools, вкладки Performance і Memory: Heap snapshot, Allocation instrumentation, шукаємо «Detached HTML nodes» і зростання retainers.
  • performance.memory (браузер, експериментально), груба телеметрія.
  • Node.js: --inspect, heap snapshots, clinic і heapdump, профайлери, прапорець --max-old-space-size.
  • Ідея тесту: проженіть сценарій N разів, пам'ять має стабілізуватися. Постійне зростання це сигнал витоку.

Обмеження, про які варто пам'ятати

  • GC недетермінований: ви не контролюєте точний час збирання.
  • Фіналізатори не гарантовано спрацюють до виходу процесу або закриття вкладки.
  • Великі паузи все ще можливі, хоча сучасні збирачі намагаються робити їх короткими.

Типові помилки

  • Думати, що GC видаляє «непотрібні» об'єкти. Критерій один: недосяжність. Об'єкт, на який є хоч одне живе посилання з кореня, не буде зібрано, навіть якщо він вам більше не потрібен.
  • Вірити, що цикл посилань спричиняє витік. Це проблема підрахунку посилань, а не трасувальних GC: недосяжний цикл збирається без проблем.
  • Покладатися на FinalizationRegistry для звільнення ресурсів. Колбек може не виконатися ніколи, закривайте файли, сокети та підписки явно.
  • Вважати delete obj.prop або x = null командою збирачу. Це лише прибирає одне посилання, збирання відбудеться тоді, коли вирішить рантайм.
  • Не знімати слухачі та setInterval під час знищення компонента. Обробник тримає і вузол DOM, і все його замикання, це найчастіший витік у SPA.
  • Використовувати звичайний Map як кеш за об'єктами. Він тримає ключі сильно, тому об'єкти житимуть, доки живе кеш, для такого сценарію потрібен WeakMap.
  • Ганятися за мікрооптимізаціями замість вимірювань. Спочатку heap snapshot і порівняння retainers, і лише потім зміни в коді.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.