Skip to main content

Що таке збирач сміття в JavaScript?

Збирач сміття (garbage collector) - це вбудована частина рушія JavaScript, яка автоматично знаходить і звільняє пам'ять об'єктів, до яких більше немає доступу з активного коду.

Теорія

TL;DR

  • Уяви готельну покоївку: вона забирає залишений багаж тільки після того, як переконалась, що жоден гість не тримає квитанцію на нього.
  • Немає посилань від коренів (глобальні змінні, стек викликів) = об'єкт є сміттям.
  • V8 використовує mark-and-sweep: обходить все досяжне від коренів, решту - звільняє.
  • Циклічні посилання не спричиняють витоків у сучасних рушіях. Mark-and-sweep їх виявляє.
  • Примусово запустити GC у продакшені не можна. Реальні витоки - забуті таймери, відв'язані DOM-вузли та глобальні змінні, що ростуть.

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

javascript
let user = { name: "Alice" }; // пам'ять виділена в heap console.log(user.name); // "Alice" - об'єкт досяжний user = null; // останнє посилання обрізане // { name: "Alice" } більше не досяжний від жодного кореня // V8 звільнить його на наступному циклі збору console.log(typeof user); // "object" - null залишається значенням

Після user = null жодна частина програми не може дістатись до того об'єкта. Він стає кандидатом на збір. Коли саме рушій звільнить пам'ять - вирішує він сам.

Як працює mark-and-sweep

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

Головна ідея: досяжність, а не підрахунок посилань. Цикл між двома об'єктами нічого не означає, якщо жоден з них не досяжний від кореня. Обидва будуть зібрані.

javascript
function createCycle() { let a = {}; let b = {}; a.ref = b; b.ref = a; // цикл створено } createCycle(); // a і b виходять за межі видимості // жоден не досяжний від жодного кореня // V8 збирає обидва - витоку немає

Старі рушії з підрахунком посилань мали проблеми саме з таким патерном. Mark-and-sweep - ні.

Покоління і поступове маркування

V8 ділить heap на дві зони. Молоде покоління (young generation) містить нещодавно виділені об'єкти, більшість з яких швидко стають сміттям. V8 очищає цю зону швидкими scavenge-проходами. Об'єкти, що пережили кілька раундів, переходять до старого покоління (old generation), де повний mark-and-sweep запускається рідше.

З версії V8 7.4 маркування відбувається поступово. Рушій зупиняє головний потік короткими відрізками замість одного довгого блокування. Це зменшує підтикання в UI-застосунках. Node 22+ постачається з GC Orinoco, який покращує пропускну здатність для серверних навантажень.

З JS-коду все це непомітно. Але саме тому DevTools іноді показує пам'ять, яка виглядає вільною, але ще не зібрана.

Причини витоків пам'яті

setInterval - найпідступніший витік у продакшені. Якось я дебажив Node.js-сервіс, що ріс на 50 МБ на годину, і причиною виявився інтервал всередині обробника запитів, який ніхто не прибирав. GC збирає тільки те, до чого немає доступу, тому якщо посилання залишається живим - об'єкт залишається в пам'яті назавжди.

Чотири найпоширеніші причини:

  • Таймери: setInterval тримає свій колбек і всі замикання, на які він посилається, живими до виклику clearInterval.
  • Обробники подій: слухач на DOM-елементі тримає посилання на свій колбек. Видаляєш елемент без видалення слухача - обидва залишаються в пам'яті.
  • Глобальні змінні: window.cache = [] є коренем. Все, що туди потрапляє, ніколи не збирається. Для кешів, ключами яких є об'єкти, використовуй WeakMap.
  • Відв'язані DOM-вузли: видалення елемента з дерева DOM не звільняє його, якщо JS-змінна досі тримає посилання.

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

Помилка: думати що delete звільняє пам'ять

javascript
let obj = { prop: "value" }; delete obj.prop; // видаляє ключ з об'єкта // сам obj досі виділений в heap - розмір пам'яті не змінився

delete видаляє ключ з об'єкта. Щоб зробити об'єкт кандидатом на збір, треба обнулити посилання: obj = null.

Помилка: зберігати необмежений стан у глобальних змінних

javascript
window.cache = []; function add() { window.cache.push(new Array(1_000_000)); } // cache є коренем, росте вічно

Тримай дані всередині функції або модуля. Для кешів, ключами яких є об'єкти, використовуй WeakMap, щоб записи звільнялись автоматично.

Помилка: не прибирати обробники подій

javascript
element.addEventListener("click", handler); // елемент видалений з DOM, але замикання handler досі живе

Викликай removeEventListener в коді очищення або використовуй AbortController для скасування кількох слухачів одразу.

Помилка: очікувати миттєвого збору

javascript
largeObj = null; // пам'ять може ще секунди показуватись як зайнята в DevTools

Присвоєння null робить об'єкт кандидатом. GC запускається за власним розкладом і не реагує на твій код. Відстежуй тренди в heap snapshot, а не окремі виміри.

Реальне використання

  • React: функції очищення в useEffect (clearInterval, removeEventListener) запобігають витокам замикань у дашбордах і SPA.
  • Node.js/Express: WeakMap для кешів на рівні запиту дозволяє записам збиратись, коли об'єкт запиту виходить за межі видимості.
  • Lodash: використовує WeakMap всередині memoize, щоб кешовані результати звільнялись разом з вихідним об'єктом.
  • Chrome DevTools, вкладка Memory: snapshot до і після дії дозволяє порівняти Retained Size і знайти, що тримає пам'ять.

Питання на співбесіді

Q: Як V8 виявляє, що об'єкт недосяжний?
A: Стартує від коренів (глобальний об'єкт, стек) і обходить усі посилання. Об'єкти, до яких обхід не дійшов, є недосяжними. Це фаза маркування в mark-and-sweep.

Q: Чи спричиняють циклічні посилання витоки в сучасному JS?
A: Ні. Mark-and-sweep перевіряє досяжність від коренів, а не кількість посилань. Проблема існувала в старих рушіях з підрахунком посилань, але не у V8.

Q: Як примусово запустити GC під час розробки?
A: Запусти Node.js з --expose-gc і викликай global.gc(). У браузерах такого способу немає в продакшені. Chrome DevTools дозволяє вручну запустити збір у вкладці Memory.

Q: У чому різниця між молодим і старим поколіннями?
A: Молоде покоління тримає короткоживучі об'єкти і очищується швидкими scavenge-проходами. Старе покоління тримає об'єкти, що пережили кілька раундів, і очищується повним mark-and-sweep. Більшість об'єктів ніколи не виходять з молодого покоління, тому типові застосунки залишаються швидкими.

Q: Сервіс на Node.js виростає з 300 МБ до 2 ГБ за добу. Як дебажити?
A: Роби heap snapshot з інтервалом і порівнюй колонку Retained Size. Дивись, що росте - скоріш за все масив, Map або EventEmitter, що накопичує записи. Підозрілі кандидати: setInterval-колбеки і будь-який глобальний кеш без обмеження розміру. Додай process.memoryUsage() до ендпоінту метрик, щоб підтвердити тренд до відкриття DevTools.

Приклади

Базовий: обрізання посилання

javascript
function allocate() { let data = new Array(100_000).fill("x"); // велике виділення в heap return data; } let result = allocate(); // result тримає масив result = null; // масив більше не досяжний, кандидат на збір

Після result = null зникає останнє посилання. Масив зі 100 тисяч елементів стає сміттям на наступному проході збору.

Середній: очищення в React запобігає витоку

javascript
function Dashboard() { const [items, setItems] = useState([]); useEffect(() => { const id = setInterval(() => { setItems(prev => [...prev, { ts: Date.now() }]); }, 1000); return () => clearInterval(id); // очищення при розмонтуванні }, []); return <div>{items.length} items loaded</div>; }

Без clearInterval інтервал продовжує працювати після розмонтування Dashboard. Кожен тік додає запис до items, замикання тримає стан компонента живим, heap росте до перезавантаження сторінки. Функція очищення - це те, що дозволяє GC виконати свою роботу.

Просунутий: WeakMap для метаданих без витоку

javascript
const metadata = new WeakMap(); function attachMeta(obj, info) { metadata.set(obj, info); } let request = { id: 42 }; attachMeta(request, { startTime: Date.now() }); request = null; // WeakMap не заважає збору сміття // коли request збирається, його запис у metadata зникає автоматично // звичайна Map тримала б посилання і спричинила витік

Ключі WeakMap утримуються слабко. Коли request стає null і інших посилань немає - об'єкт збирається, а відповідний запис у WeakMap зникає разом з ним. Звичайна Map тримала б об'єкт живим нескінченно - це саме той патерн витоку, від якого WeakMap і захищає.

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

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

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

Дочитали статтю?