Що таке збирач сміття в JavaScript?
Збирач сміття (garbage collector) - це вбудована частина рушія JavaScript, яка автоматично знаходить і звільняє пам'ять об'єктів, до яких більше немає доступу з активного коду.
Теорія
TL;DR
- Уяви готельну покоївку: вона забирає залишений багаж тільки після того, як переконалась, що жоден гість не тримає квитанцію на нього.
- Немає посилань від коренів (глобальні змінні, стек викликів) = об'єкт є сміттям.
- V8 використовує mark-and-sweep: обходить все досяжне від коренів, решту - звільняє.
- Циклічні посилання не спричиняють витоків у сучасних рушіях. Mark-and-sweep їх виявляє.
- Примусово запустити GC у продакшені не можна. Реальні витоки - забуті таймери, відв'язані DOM-вузли та глобальні змінні, що ростуть.
Швидкий приклад
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 стартує від набору коренів: глобальний об'єкт, поточний стек викликів, активні хендли. Від кожного кореня він обходить усі посилання - властивості, замикання, ланцюжки прототипів - і позначає кожен досяжний об'єкт. Після завершення обходу все непозначене звільняється.
Головна ідея: досяжність, а не підрахунок посилань. Цикл між двома об'єктами нічого не означає, якщо жоден з них не досяжний від кореня. Обидва будуть зібрані.
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 звільняє пам'ять
let obj = { prop: "value" };
delete obj.prop; // видаляє ключ з об'єкта
// сам obj досі виділений в heap - розмір пам'яті не змінивсяdelete видаляє ключ з об'єкта. Щоб зробити об'єкт кандидатом на збір, треба обнулити посилання: obj = null.
Помилка: зберігати необмежений стан у глобальних змінних
window.cache = [];
function add() {
window.cache.push(new Array(1_000_000));
}
// cache є коренем, росте вічноТримай дані всередині функції або модуля. Для кешів, ключами яких є об'єкти, використовуй WeakMap, щоб записи звільнялись автоматично.
Помилка: не прибирати обробники подій
element.addEventListener("click", handler);
// елемент видалений з DOM, але замикання handler досі живеВикликай removeEventListener в коді очищення або використовуй AbortController для скасування кількох слухачів одразу.
Помилка: очікувати миттєвого збору
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.
Приклади
Базовий: обрізання посилання
function allocate() {
let data = new Array(100_000).fill("x"); // велике виділення в heap
return data;
}
let result = allocate(); // result тримає масив
result = null; // масив більше не досяжний, кандидат на збірПісля result = null зникає останнє посилання. Масив зі 100 тисяч елементів стає сміттям на наступному проході збору.
Середній: очищення в React запобігає витоку
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 для метаданих без витоку
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 і захищає.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.