Skip to main content

Як уникнути витоків пам'яті

Витоків пам'яті уникають, керуючи посиланнями: немає посилання, немає витоку. Усе, що лишається досяжним від коренів (глобальні змінні, замикання, DOM, кеші), не буде зібрано збирачем сміття, тому головні практики це скорочувати час життя об'єктів і вчасно розривати зв'язки: обробники, таймери, кеші, посилання на DOM.

Теорія

TL;DR

  • Базовий принцип: немає посилання, немає витоку. GC збирає лише недосяжні об'єкти.
  • Знімайте слухачі подій тим самим колбеком, чистіть таймери, скасовуйте fetch через AbortController.
  • Не тримайте посилань на detached вузли DOM і не замикайте великі об'єкти без потреби.
  • Кеші: WeakMap / WeakSet для ключів-об'єктів, LRU з лімітом для ключів-рядків.
  • У React це cleanup у useEffect, у Node.js це закриття з'єднань, пулів, стрімів і обробка SIGTERM.
  • FinalizationRegistry лише для «м'якого» прибирання, бізнес-логіку на ньому не будують.
  • Шукайте витоки інструментами: heap snapshot, allocation instrumentation, --trace-gc, метрики RSS і heapUsed.

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

javascript
function mount(button) { const onClick = () => console.log('clicked'); const id = setInterval(tick, 1000); const ac = new AbortController(); button.addEventListener('click', onClick); fetch('https://api.example.com/data', { signal: ac.signal }); // Повертаємо функцію прибирання: усе, що створили, треба зняти return () => { button.removeEventListener('click', onClick); // той самий колбек clearInterval(id); ac.abort(); }; }

Типові джерела витоків і як їх закрити

Обробники подій і підписки

Проблема: підвислі слухачі тримають і вузол DOM, і всі дані з його замикання.

javascript
const onClick = () => {}; button.addEventListener('click', onClick); // ... button.removeEventListener('click', onClick); // обов'язково тим самим колбеком

У React завжди робіть cleanup у useEffect:

javascript
useEffect(() => { const onScroll = () => {}; window.addEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll); }, []);

У NestJS та в роботі з EventEmitter знімайте підписки в onModuleDestroy().

Таймери та інтервали

javascript
const id = setInterval(tick, 1000); // ... clearInterval(id);

У Node.js 20+ для таймерів можна використати AbortSignal:

javascript
const ac = new AbortController(); setTimeout(cb, 5000, { signal: ac.signal }); // ... ac.abort();

Незавершені fetch і стріми

Скасовуйте запити через AbortController і закривайте стріми.

javascript
const c = new AbortController(); const res = await fetch(url, { signal: c.signal }); // ... c.abort();

Detached вузли DOM

Не зберігайте посилань на вузли, видалені з DOM, і обнуляйте кеші з DOM після remove():

javascript
elem.remove(); elemRef = null; // даємо GC шанс

Великі замикання

Не замикайте великі об'єкти без потреби, передавайте в колбеки мінімум даних, а після використання розривайте посилання:

javascript
let big = getHugeObject(); doWork(() => { /* використовуємо лише невелику частину */ }); big = null;

Глобальні сінглтони і кеші

Усе, що лежить у модулі або в глобальній області, живе «вічно». Обмежуйте розмір, додавайте очищення або слабкі посилання.

Кеші без витоків

Для ключів-об'єктів беріть WeakMap або WeakSet: вони не заважають збиранню сміття.

javascript
const memo = new WeakMap(); function compute(obj) { if (memo.has(obj)) return memo.get(obj); const val = heavy(obj); memo.set(obj, val); return val; }

Для ключів-рядків або чисел слабкі колекції не підходять, тож використовуйте LRU з обмеженням:

javascript
class LRU { constructor(limit = 500) { this.limit = limit; this.map = new Map(); } get(k) { const v = this.map.get(k); if (v) { this.map.delete(k); this.map.set(k, v); } return v; } set(k, v) { if (this.map.has(k)) this.map.delete(k); this.map.set(k, v); if (this.map.size > this.limit) { const oldest = this.map.keys().next().value; this.map.delete(oldest); // витісняємо найстаріший запис } } }

Окремо: FinalizationRegistry годиться лише для м'якого прибирання зовнішніх кешів і хендлів. Не покладайтеся на час і порядок виклику, бізнес-логіку на фіналізаторах не будують.

React і Next.js

  • Cleanup усіх побічних ефектів: useEffect має повертати () => { ... }.
  • Не зберігайте величезні об'єкти в useRef чи в контексті без потреби.
  • Уникайте нескінченних ланцюжків мікрозадач із Promise: вони затримують і рендер, і збирання сміття.
  • Відписуйтеся від Observable і WebSocket у cleanup.
  • Для великих списків використовуйте віртуалізацію, щоб одночасно існувало менше об'єктів.

Node.js і NestJS

  • Закривайте з'єднання і пули:
    • Prisma: await prisma.$disconnect() на завершення.
    • Redis, MongoDB, PostgreSQL: .quit(), .close(), .end() в onModuleDestroy().
  • Стріми: завжди .destroy() або .end() при помилках і таймаутах.
  • EventEmitter: removeListener або removeAllListeners під час вивантаження.
  • Уникайте безконтрольних setInterval, замість них беріть setTimeout у циклі з перевірками і можливістю скасування.
  • Обробляйте сигнали завершення:
javascript
process.on('SIGTERM', async () => { await app.close(); // закриваємо сервер, базу, черги process.exit(0); });

Для асинхронності та черг: ставте таймаути і ліміти на паралелізм (наприклад, семафори або p-limit), у мережі та стрімах тримайте backpressure, тобто не читайте швидше, ніж обробляєте, і не тримайте в пам'яті великих масивів промісів, обробляйте чанками.

Інструменти пошуку витоків і гардрейли

Браузер

  • Memory, Heap snapshot: шукайте «Detached HTML elements» і retainers.
  • Allocation instrumentation: показує, хто створює і хто утримує об'єкти.
  • Performance: довгі мікрозадачі та часті збирання сміття це запах проблеми.

Node.js

  • --inspect разом із Chrome DevTools, далі heap snapshots.
  • heapdump або clinic для знімків у бойовому середовищі.
  • Логи збирача: node --trace-gc app.js, для діагностики, не для продакшену.
  • Метрики: моніторте RSS і heapUsed, ставте алерти на тренд зростання.

Гардрейли в коді та CI

  • Ліміти на розміри кешів і черг.
  • E2E-тест: «прогнали сценарій N разів, пам'ять стабілізувалася».
  • Правила лінтера: заборона анонімних слухачів там, де потрібне зняття, і заборона setInterval без явного clearInterval.

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

  • Зняття слухача іншою функцією. removeEventListener('click', () => {}) не знімає нічого: потрібне те саме посилання на колбек, що і при додаванні.
  • Забутий clearInterval під час розмонтування. Інтервал переживає компонент і тримає все його замикання.
  • Збереження посилання на вузол після remove(). Вузол стає detached, але живим, разом із усім піддеревом.
  • Необмежений кеш у модульній області. Глобальний Map без витіснення росте, доки живе процес.
  • Спроба використати WeakMap з ключами-рядками. Слабкі колекції приймають лише об'єкти, для примітивних ключів потрібен LRU.
  • Надія на FinalizationRegistry замість явного закриття ресурсів. Колбек може не виконатися ніколи.
  • Виклик global.gc() або сподівання, що = null негайно звільнить пам'ять. Час збирання визначає рантайм.
  • Пошук витоку «на око» замість знімків купи. Без порівняння двох snapshot і аналізу retainers ви лише вгадуєте.

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

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

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