Збирач сміття (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.
Швидкий приклад
let user = { name: 'Alice' }; // об'єкт досяжний від кореня через змінну user
let admin = user; // друге посилання на той самий об'єкт
user = null; // перше посилання прибрали, але об'єкт ще живий:
// його тримає admin, тож GC його не звільнить
admin = null; // тепер об'єкт недосяжний і може бути зібраний
// (коли саме, вирішує рантайм, а не ваш код)Як GC вирішує, що видаляти
Досяжність (reachability)
Корені це те, до чого рантайм має доступ завжди: глобальний об'єкт, змінні у стеку поточних викликів, регістри, активні замикання, дерево DOM. Усе, до чого можна дійти від коренів ланцюжком посилань, вважається живим. Усе інше це сміття.
Mark-and-sweep
- Mark (позначання): обходимо граф об'єктів від коренів і позначаємо всі досяжні.
- 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: дозволяє виконати колбек після того, як об'єкт зібрано. Важливі застереження: час виклику недетермінований, порядок не гарантований, виклику може не бути взагалі (наприклад, під час завершення процесу). Покладатися на нього в логіці не можна, тільки для «м'яких» побічних дій, на кшталт очищення зовнішніх кешів.
Приклад безпечного кешу:
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;
}Витоки пам'яті: практика і діагностика
Типові джерела витоків
- Довгоживучі посилання: кеші, сінглтони, замикання, глобальні структури, збережені вузли DOM.
- Події та таймери: не зняли обробник або
setInterval, підвислі посилання зPromise. - Цикли DOM і JS: тримаєте посилання на видалений з DOM вузол або на його замикання.
- Необмежені структури:
Mapабо масив ростуть без очищення.
Що робити
- Знімати обробники подій і таймери:
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, і лише потім зміни в коді.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.