Skip to main content

Витік пам'яті в 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 зі слабкими посиланнями.

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

javascript
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 тримає його

Неявна глобальна змінна:

javascript
function createLeak() { leak = []; // без let / const змінна потрапляє у window for (let i = 0; i < 1000000; i++) leak.push(i); } createLeak(); // leak живе до закриття вкладки

Таймер, який ніхто не зупиняє:

javascript
function start() { const data = new Array(1e6).fill('x'); setInterval(() => console.log(data.length), 1000); } start(); // data ніколи не очищається, її тримає замикання колбека

Кеш без ліміту:

javascript
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):

  1. Відкрийте вкладку Memory і оберіть Heap snapshot.
  2. Зробіть знімок пам'яті (Take snapshot).
  3. Повторіть дію, яку підозрюєте (відкрити і закрити модалку, перейти між сторінками), і зробіть другий знімок через 10 або 30 секунд.
  4. Порівняйте знімки в режимі 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. Вони тримають слабкі посилання, тобто не заважають збиранню сміття:

javascript
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-хуки

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

  1. Вважати, що збирач сміття рахує посилання. Сучасні рушії використовують досяжність (mark and sweep), тому циклічні посилання самі по собі не є витоком: дві структури, що посилаються одна на одну, але недосяжні ззовні, буде зібрано.
  2. Прибирати слухача анонімною функцією. removeEventListener('click', () => {...}) не спрацює: потрібне те саме посилання на функцію, що передавали в addEventListener.
  3. Забувати cleanup у компонентах. useEffect без функції очищення лишає підписки, таймери й AbortController живими після розмонтування компонента.
  4. Плутати високе споживання пам'яті з витоком. Застосунок може просто тримати великий легітимний стан; витік, це коли пам'ять росте і не повертається після збирання сміття.
  5. Вважати WeakMap універсальним рішенням. Слабким є лише ключ; якщо значення посилається назад на ключ або на щось велике й досяжне, пам'ять не звільниться.
  6. Робити висновки за одним знімком heap. Витік видно лише в динаміці: потрібні щонайменше два знімки після однакового сценарію і примусового збирання сміття.
  7. Тримати в замиканні весь великий об'єкт заради одного поля. Достатньо скопіювати потрібне значення (const { id } = user), і решта структури зможе бути зібраною.

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

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

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