Як працює збір сміття в Node.js (V8)?
Garbage collection (збір сміття) в Node.js - це процес, завдяки якому V8 автоматично звільняє пам'ять, відстежуючи об'єкти, досяжні з активних коренів, і видаляючи все інше в двох поколіннях купи.
Теорія
TL;DR
- V8 ділить купу на молоде покоління (мале, швидкий scavenge, 1-5мс) і старе покоління (більше, повільний mark-sweep-compact, 100мс+)
- Аналогія: прибирання в готелі. Нові гості отримують швидку щоденну перевірку. Довгожителі - генеральне прибирання лише коли закінчуються вільні кімнати.
- Об'єкти, що пережили 2 scavenge, переходять до старого покоління. Туди потрапляє лише ~25%.
- Паузи більше 50мс у продакшені - ознака тиску GC. Моніторь через
--trace-gc. - Ручний виклик
global.gc()блокує потік на 100-500мс. Нехай V8 сам планує запуски.
Швидкий приклад
// Витік: 'leaks' - це корінь, V8 ніколи не збере вміст
let leaks = [];
setInterval(() => {
for (let i = 0; i < 1000; i++) {
leaks.push(new Array(100000).fill('x')); // ~400KB кожен запис
}
console.log('Heap:', process.memoryUsage().heapUsed / 1024 / 1024, 'MB');
}, 1000);
// Вивід: Heap: 10 MB -> 50 MB -> 200 MB -> OOM crash
// V8 починає з коренів. 'leaks' - корінь. Все що він тримає - живе вічно.Масив leaks - корінь GC. V8 стартує з коренів і позначає все досяжне. Оскільки leaks завжди досяжний, весь його вміст теж. Нічого не звільняється.
Два покоління з різними стратегіями
V8 ділить купу на дві зони з принципово різними підходами:
| Зона | Розмір | Алгоритм | Типова пауза |
|---|---|---|---|
| Молоде покоління | 1-32 MB | Scavenge (алгоритм Чені) | 1-5мс |
| Старе покоління | до ~1.5 GB | Mark-Sweep-Compact | 100мс+ |
Моложе покоління маленьке навмисно. Більшість об'єктів живе коротко: тимчасові змінні, request-об'єкти, проміжні значення. Старе покоління тримає тих, хто вижив.
Як працює Scavenge (молоде покоління)
Молоде покоління ділиться на два рівних напівпростори: "From" і "To". Нові об'єкти потрапляють у "From". Коли "From" заповнюється, V8 запускає scavenge:
- Знайти всі живі об'єкти у "From" через корені
- Скопіювати живі об'єкти в "To"
- Повністю очистити "From"
- Поміняти "From" і "To" місцями
Об'єкти, що пережили два scavenge, переміщуються до старого покоління. На практиці туди потрапляє лише ~25%. Решта гине раніше. Саме тому scavenge швидкий - він обходить тільки живі об'єкти, а не весь heap.
function handleRequest(req) {
const parsed = JSON.parse(req.body); // народжується в молодому поколінні
const result = transform(parsed); // теж молоде покоління
return result;
// parsed стає недосяжним і гине під час наступного scavenge
}Як працює Mark-Sweep-Compact (старе покоління)
Мажорний GC проходить три фази.
Mark (позначення): Починаючи з коренів (глобальний scope, стеки викликів, внутрішні handles V8), V8 обходить весь граф об'єктів і позначає кожен досяжний. Все непозначене вважається мертвим.
Sweep (очищення): Мертві об'єкти звільняються. У пам'яті залишаються дірки - фрагментована купа.
Compact (компактація): V8 переміщує живі об'єкти разом, щоб ліквідувати фрагментацію. Починаючи з V8 8.0, компактація використовує алгоритм sliding і частково виконується у фонових потоках.
Позначення: [●][○][●][○][○][●] (● = досяжний, ○ = мертвий)
Очищення: [●][ ][●][ ][ ][●]
Компактація: [●][●][●][ ]Компактація необов'язкова. Якщо фрагментація низька, V8 пропускає її і економить 20-50% CPU.
Інкрементний та паралельний GC
Старий підхід "зупини світ" призупиняв виконання JS на весь цикл mark-sweep. Сучасний V8 розбиває це на менші операції:
- Інкрементне позначення (з V8 6.x): фаза mark виконується маленькими шматками між тиками JS. Паузи падають нижче 5мс.
- Паралельне очищення: sweep виконується у фонових потоках поки JS продовжує роботу.
- Паралельна компактація: часткова компактація у фонових потоках (V8 7.0+).
- Ліниве очищення: сторінки очищуються лише коли нові виділення потребують простору.
Node 12+ вмикає інкрементне позначення за замовчуванням. Тому паузи GC зазвичай непомітні у добре написаних застосунках. Але щойно старе покоління наповнюється, навіть інкрементне позначення не рятує від паузи.
Моніторинг і налаштування
# Бачити кожну подію GC з таймінгом
node --trace-gc server.js
# Приклад виводу:
# [12345] 52ms: Scavenge 4.2 -> 3.8 MB, 1.2ms <- норма
# [12345] 2100ms: Mark-sweep 180 -> 90 MB, 85ms <- проблемаЯ бачив, як тиск старого покоління накопичується тихо під навантаженням. Перший сигнал - повторювані записи 80-100мс mark-sweep у --trace-gc, а не OOM. До падіння процесу застосунок вже деградує хвилинами.
// Спостерігати за GC зсередини процесу
const { PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 50) {
console.warn(`Повільний GC: ${entry.kind} - ${entry.duration.toFixed(0)}мс`);
}
});
});
obs.observe({ entryTypes: ['gc'] });Ключові флаги:
--max-old-space-size=4096: підняти ліміт старого покоління до 4 GB (за замовчуванням ~1.5 GB на 64-bit)--max-semi-space-size=16: встановити напівпростір молодого покоління в 16 MB (за замовчуванням 4 MB в Node 20)v8.getHeapStatistics(): читати поточний стан купи з коду
Типові помилки
Помилка 1: Глобальні кеші
// Неправильно - глобальна змінна є коренем, вміст Map ніколи не збирається
global.cache = new Map();
// Правильно - обмежений кеш з витісненням
const { LRUCache } = require('lru-cache');
const cache = new LRUCache({ max: 1000 });Глобальні змінні - корені. Все досяжне через глобальну живе до завершення процесу.
Помилка 2: Слухачі подій без очищення
// Неправильно - слухач тримає замикання нескінченно
function setup(heavyObject) {
emitter.on('data', () => process(heavyObject)); // замикання захоплює heavyObject
}
// Правильно
emitter.once('data', handler);
// або явно видаляй
emitter.removeListener('data', handler);Кожен виклик on() реєструє замикання (closure). Якщо замикання захоплює великий об'єкт, той живе стільки, скільки живе слухач. Це одна з найпоширеніших причин витоків пам'яті в Node.js.
Помилка 3: Ручний gc() у продакшені
// Неправильно - блокує потік на синхронний повний GC
setInterval(() => global.gc(), 1000);global.gc() працює лише з --expose-gc. Використовуй у тестах для вимірювання стану купи між операціями. У продакшені V8 планує GC краще ніж будь-який фіксований інтервал.
Помилка 4: Ігнорування швидкості просування
Якщо більше ~25% об'єктів молодого покоління переходить до старого, мажорний GC запускається занадто часто і паузи накопичуються. Слідкуй за v8.getHeapStatistics().used_heap_size_after_gc в часі. Зростаючий показник - ознака тиску просування, зазвичай від довгоживучих об'єктів у гарячих шляхах.
Помилка 5: Великі буфери у циклах
// Неправильно - швидко потрапляє в старе покоління, фрагментує купу
for (let i = 0; i < 1000; i++) {
const buf = Buffer.alloc(1e6); // 1MB кожну ітерацію
process(buf);
}
// Правильно - перевикористовуй той самий буфер
const buf = Buffer.allocUnsafe(1e6);
for (let i = 0; i < 1000; i++) {
buf.fill(0);
process(buf);
}Де зустрічається в реальних проектах
- Express API: необмежений
Map-кеш у middleware викликає OOM під навантаженням. Використовуйlru-cacheз параметромmax. - Puppeteer:
page.evaluate()створює відокремлені DOM-дерева в старому поколінні. Явно викликайpage.close()після завершення роботи зі сторінкою. - NestJS: скоупи dependency injection можуть тримати довгоживучі інстанції.
WeakRef(Node 14.6+) дозволяє синглтонам збиратись, коли на них більше ніхто не посилається. - Ізоморфний React: серверний рендеринг виділяє багато короткоживучих virtual DOM об'єктів на запит. Налаштування
--max-semi-space-size=16допомагає тримати їх у молодому поколінні довше. - Redis-клієнти: пули з'єднань використовують
WeakMapдля тимчасового стану callback, щоб callback не переживав своє з'єднання.
Питання на співбесіді
Q: Яка різниця між scavenge і mark-sweep?
A: Scavenge копіює об'єкти-виживших з молодого покоління і займає 1-5мс. Mark-sweep сканує все старе покоління, звільняє мертві об'єкти і може займати 100мс і більше. В mark-sweep копіювання немає - тільки позначення і звільнення.
Q: Як V8 визначає витоки пам'яті?
A: Автоматично не визначає. Ти береш heap snapshot через --inspect і Chrome DevTools, потім шукаєш відокремлені DOM-дерева що ростуть, записи Map що ніколи не зменшуються, або об'єкти замикань що накопичуються в старому поколінні між знімками.
Q: Що змінило інкрементне позначення у V8?
A: До нього фаза mark зупиняла JS на весь час свого виконання. Інкрементне позначення розбиває її на малі шматки, зазвичай менше 5мс кожен, чергуючи з виконанням JS. Node 12+ вмикає це за замовчуванням - тому паузи "зупини світ" рідкість у сучасних Node-застосунках, якщо старе покоління не переповнене.
Q: Як WeakRef і FinalizationRegistry взаємодіють з GC?
A: WeakRef тримає посилання, яке не заважає збору. V8 може зібрати цільовий об'єкт навіть якщо на нього вказує WeakRef. FinalizationRegistry викликає callback після того як об'єкт зібрано. Обидва доступні з Node 14.6 і корисні для кешів, де об'єкти мають вмирати природно без явного видалення.
Q: Коли V8 пропускає компактацію і чому це важливо?
A: V8 вирішує автоматично на основі рівня фрагментації. Компактація коштує 20-50% додаткового CPU, але ліквідує дірки в купі. Якщо її пропустити при високій фрагментації, виділення пам'яті може завершитись помилкою навіть якщо є вільне місце. Деталі видно у виводі --trace-gc-verbose.
Q: Що таке швидкість просування і чому вона критична при масштабуванні?
A: Це відсоток об'єктів молодого покоління, що переходять до старого. Понад ~25% - мажорний GC запускається постійно і паузи накопичуються. Висока швидкість просування зазвичай означає довгоживучі об'єкти в гарячих шляхах: request-об'єкти в модульних змінних, замикання що живуть довше ніж очікувалось.
Приклади
Базовий: спостерігаємо за купою в реальному часі
// node --trace-gc heap-demo.js
const snapshots = [];
setInterval(() => {
// Кожен тік виділяє короткоживучі дані
const temp = new Array(100000).fill({ value: Math.random() });
const stats = process.memoryUsage();
snapshots.push(stats.heapUsed); // зберігаємо число, не temp
console.log(`Heap: ${(stats.heapUsed / 1024 / 1024).toFixed(1)} MB`);
// temp виходить за scope тут - готовий до наступного scavenge
}, 500);
// Heap залишається відносно стабільним бо temp гине кожен тік
// Заміни snapshots.push(stats.heapUsed) на snapshots.push(temp)
// і спостерігай як купа росте без зупинкиtemp створюється і відкидається кожні 500мс. Залишається в молодому поколінні і швидко збирається scavenge. Купа стабільна. Але варто запушити temp у snapshots - і він стає коренем, heap зростає до краша.
Середній: Express-кеш без витіснення
const express = require('express');
const app = express();
// Росте вічно - кожен унікальний user ID додає постійний запис у старому поколінні
const cache = new Map();
app.get('/user/:id', async (req, res) => {
const { id } = req.params;
if (!cache.has(id)) {
cache.set(id, await db.getUser(id)); // повний user-об'єкт, ніколи не звільняється
}
res.json(cache.get(id));
});
// Після 10k унікальних user ID: heapUsed > 200MB, мажорний GC кожні ~2с, паузи 100мс
// Виправлення: обмежений кеш з TTL
const { LRUCache } = require('lru-cache');
const boundedCache = new LRUCache({
max: 500,
ttl: 1000 * 60 * 5, // 5 хвилин
});Необмежений Map переміщує все до старого покоління. LRU-кеш з max: 500 тримає тиск на старе покоління стабільним незалежно від кількості унікальних користувачів.
Просунутий: замикання що захоплює великий scope
// Тонко: кожне замикання слухача тримає весь зовнішній scope живим
function createProcessor(config) {
const lookupTable = loadLargeTable(config.tablePath); // 8MB у пам'яті
const emitter = new (require('events'))();
// 1000 слухачів - кожен захоплює lookupTable
// V8 бачить 1000 окремих замикань, кожне з посиланням на 8MB таблицю
for (let i = 0; i < 1000; i++) {
emitter.on('process', (item) => {
return lookupTable[item.key]; // читає один ключ, але тримає всі 8MB
});
}
return emitter;
}
// v8.getHeapSpaceStatistics() покаже старе покоління біля максимуму
// Кожен emitter.on() = ще одне посилання що тримає lookupTable живою
// Виправлення: одне замикання, одне посилання
function createProcessorFixed(config) {
const lookupTable = loadLargeTable(config.tablePath);
const lookup = (key) => lookupTable[key]; // одна функція-обгортка
const emitter = new (require('events'))();
emitter.on('process', (item) => lookup(item.key)); // один слухач
return emitter;
}Кожне замикання, зареєстроване через on(), тримає власне посилання на lookupTable. З 1000 слухачів - 1000 посилань на той самий 8MB об'єкт. V8 не може його звільнити поки всі 1000 не видалено. Одна функція-обгортка lookup означає одне замикання, одне посилання - незалежно від кількості слухачів.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.