Як уникнути витоків пам'яті
Витоків пам'яті уникають, керуючи посиланнями: немає посилання, немає витоку. Усе, що лишається досяжним від коренів (глобальні змінні, замикання, 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.
Швидкий приклад
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, і всі дані з його замикання.
const onClick = () => {};
button.addEventListener('click', onClick);
// ...
button.removeEventListener('click', onClick); // обов'язково тим самим колбекомУ React завжди робіть cleanup у useEffect:
useEffect(() => {
const onScroll = () => {};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);У NestJS та в роботі з EventEmitter знімайте підписки в onModuleDestroy().
Таймери та інтервали
const id = setInterval(tick, 1000);
// ...
clearInterval(id);У Node.js 20+ для таймерів можна використати AbortSignal:
const ac = new AbortController();
setTimeout(cb, 5000, { signal: ac.signal });
// ...
ac.abort();Незавершені fetch і стріми
Скасовуйте запити через AbortController і закривайте стріми.
const c = new AbortController();
const res = await fetch(url, { signal: c.signal });
// ...
c.abort();Detached вузли DOM
Не зберігайте посилань на вузли, видалені з DOM, і обнуляйте кеші з DOM після remove():
elem.remove();
elemRef = null; // даємо GC шансВеликі замикання
Не замикайте великі об'єкти без потреби, передавайте в колбеки мінімум даних, а після використання розривайте посилання:
let big = getHugeObject();
doWork(() => { /* використовуємо лише невелику частину */ });
big = null;Глобальні сінглтони і кеші
Усе, що лежить у модулі або в глобальній області, живе «вічно». Обмежуйте розмір, додавайте очищення або слабкі посилання.
Кеші без витоків
Для ключів-об'єктів беріть WeakMap або WeakSet: вони не заважають збиранню сміття.
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 з обмеженням:
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().
- Prisma:
- Стріми: завжди
.destroy()або.end()при помилках і таймаутах. EventEmitter:removeListenerабоremoveAllListenersпід час вивантаження.- Уникайте безконтрольних
setInterval, замість них берітьsetTimeoutу циклі з перевірками і можливістю скасування. - Обробляйте сигнали завершення:
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 ви лише вгадуєте.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.