Витік пам'яті в JS
Витік пам'яті це ситуація, коли програма утримує посилання на об'єкти, які вже не потрібні, тому збирач сміття (Garbage Collector) не може звільнити їх з пам'яті. Іншими словами, об'єкт більше не використовується, але через «забуті» посилання залишається в пам'яті і поступово з'їдає heap.
Теорія
TL;DR
- Витік, це утримання непотрібних об'єктів у пам'яті через посилання, які ніхто не прибрав.
- Збирач сміття в JS видаляє лише недосяжні об'єкти: якщо є хоч одне посилання, об'єкт живий.
- Основні причини: неявні глобальні змінні, неочищені таймери, не зняті слухачі подій, замикання над великими даними, кеші без ліміту, посилання на видалені DOM-вузли.
- Ознаки: heap росте від знімка до знімка, з'являються detached DOM nodes, інтерфейс поступово гальмує.
- Діагностика: Chrome DevTools, вкладка Memory і heap snapshot; у Node.js,
--inspect,heapdump,process.memoryUsage(). - Профілактика: cleanup таймерів і слухачів, обмежені кеші,
WeakMapіWeakSetзі слабкими посиланнями.
Швидкий приклад
const btn = document.getElementById('btn');
function handleClick() {
console.log('clicked');
}
btn.addEventListener('click', handleClick);
btn.remove(); // елемент прибрано з DOM, але слухач лишився
// handleClick і сам btn залишаються в пам'яті: це detached DOM nodeПравильний варіант: перед видаленням вузла зняти слухача через btn.removeEventListener('click', handleClick) і обнулити зовнішні посилання на елемент.
Як працює збирач сміття і звідки береться витік
JavaScript має автоматичне керування пам'яттю. Збирач сміття періодично шукає досяжні об'єкти, тобто ті, до яких можна дійти по ланцюжку посилань від кореневих об'єктів (глобальний об'єкт, стек викликів, активні замикання).
- Усе, до чого немає посилань (недосяжне), видаляється.
- Усе, на що є хоча б одне посилання, лишається в пам'яті.
Саме звідси й береться проблема: об'єкт більше не потрібен логічно, але десь лишилося посилання на нього, тому збирач сміття вважає його «живим» і не звільняє. Важливо: для рушія немає різниці між потрібним і забутим посиланням, тож витік, це завжди помилка в коді, а не в збирачі сміття.
Типові причини витоків
| Причина | Приклад | Що відбувається |
|---|---|---|
| Глобальні змінні | window.obj = { big: 'data' } | живуть до закриття вкладки |
| Неочищені таймери та інтервали | setInterval(() => {...}, 1000) без clearInterval | колбек тримає замикання і не звільняється |
| Слухачі подій | element.addEventListener('click', handler) без removeEventListener | елемент видалений, а обробник лишився в пам'яті |
| Замикання над великими даними | функції, що захоплюють контекст з великими об'єктами | збирач не може звільнити контекст |
| Кеші та структури даних | Map, що росте без очищення старих ключів | дані не видаляються автоматично |
| DOM-посилання після видалення вузла | const el = document.getElementById('app'); el.remove(); | вузла вже немає в DOM, але змінна el тримає його |
Неявна глобальна змінна:
function createLeak() {
leak = []; // без let / const змінна потрапляє у window
for (let i = 0; i < 1000000; i++) leak.push(i);
}
createLeak();
// leak живе до закриття вкладкиТаймер, який ніхто не зупиняє:
function start() {
const data = new Array(1e6).fill('x');
setInterval(() => console.log(data.length), 1000);
}
start();
// data ніколи не очищається, її тримає замикання колбекаКеш без ліміту:
const cache = new Map();
function heavyCalc(key) {
if (cache.has(key)) return cache.get(key);
const result = Array(1e5).fill(key);
cache.set(key, result); // жоден запис ніколи не видаляється
return result;
}Як виявити витік пам'яті
У браузері (Chrome DevTools):
- Відкрийте вкладку Memory і оберіть Heap snapshot.
- Зробіть знімок пам'яті (Take snapshot).
- Повторіть дію, яку підозрюєте (відкрити і закрити модалку, перейти між сторінками), і зробіть другий знімок через 10 або 30 секунд.
- Порівняйте знімки в режимі Comparison: якщо кількість об'єктів або записів
Detached DOM nodesстабільно зростає, це витік.
Також корисна вкладка Performance з увімкненим прапорцем Memory: там видно, чи heap size повертається до початкового рівня після збирання сміття, чи лінія стабільно повзе вгору.
У Node.js:
node --inspectі Chrome DevTools для heap snapshot.- Інструменти
clinic.js,node --inspect-brk, пакетheapdump. - Періодичний моніторинг через
process.memoryUsage()і метрикиheapUsedтаrss.
Ознаки витоку без профайлера:
- Застосунок поступово сповільнюється під час довгої сесії.
- Пам'ять росте і не повертається назад після навігації.
- Затримки в інтерфейсі, особливо під час скролу та рендерингу.
- Часті й довгі паузи на збирання сміття.
- Вкладка врешті падає з повідомленням про брак пам'яті.
Як запобігати витокам
| Проблема | Рішення |
|---|---|
| Глобальні змінні | Завжди оголошувати через let і const, вмикати strict mode |
| Таймери | Викликати clearTimeout і clearInterval при зупинці |
| Слухачі подій | Знімати через removeEventListener при розмонтуванні |
| Замикання | Не тримати великі об'єкти в області видимості довгоживучих функцій |
| Кеші | Обмежувати розмір і чистити старі записи, або брати WeakMap / WeakSet |
| DOM | Разом з видаленням вузла прибирати всі змінні, що на нього посилаються |
| SPA-застосунки | Стежити за cleanup у useEffect, onUnmounted та подібних хуках |
Окремий інструмент, це WeakMap і WeakSet. Вони тримають слабкі посилання, тобто не заважають збиранню сміття:
const cache = new WeakMap();
function getData(obj) {
if (cache.has(obj)) return cache.get(obj);
const data = heavyCalculation(obj);
cache.set(obj, data);
return data;
}Якщо об'єкт obj більше ніде не використовується, збирач сміття видалить його автоматично, навіть коли він є ключем у WeakMap. Ціна: такі колекції не ітеруються і не мають size, а ключами можуть бути лише об'єкти.
Коротке резюме:
| Що | Опис |
|---|---|
| Memory leak | Утримання непотрібних об'єктів у пам'яті |
| Причина | Посилання, які не звільняються |
| Наслідок | Зростання пам'яті, лаги, падіння вкладки |
| Діагностика | Chrome DevTools, вкладка Memory і heap snapshot |
| Рішення | Чистити таймери, слухачів, кеші та DOM-посилання |
| Профілактика | WeakMap, WeakSet, cleanup-хуки |
Типові помилки
- Вважати, що збирач сміття рахує посилання. Сучасні рушії використовують досяжність (mark and sweep), тому циклічні посилання самі по собі не є витоком: дві структури, що посилаються одна на одну, але недосяжні ззовні, буде зібрано.
- Прибирати слухача анонімною функцією.
removeEventListener('click', () => {...})не спрацює: потрібне те саме посилання на функцію, що передавали вaddEventListener. - Забувати cleanup у компонентах.
useEffectбез функції очищення лишає підписки, таймери йAbortControllerживими після розмонтування компонента. - Плутати високе споживання пам'яті з витоком. Застосунок може просто тримати великий легітимний стан; витік, це коли пам'ять росте і не повертається після збирання сміття.
- Вважати
WeakMapуніверсальним рішенням. Слабким є лише ключ; якщо значення посилається назад на ключ або на щось велике й досяжне, пам'ять не звільниться. - Робити висновки за одним знімком heap. Витік видно лише в динаміці: потрібні щонайменше два знімки після однакового сценарію і примусового збирання сміття.
- Тримати в замиканні весь великий об'єкт заради одного поля. Достатньо скопіювати потрібне значення (
const { id } = user), і решта структури зможе бути зібраною.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.