Skip to main content

Як працює збір сміття в 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 сам планує запуски.

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

javascript
// Витік: '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 MBScavenge (алгоритм Чені)1-5мс
Старе поколіннядо ~1.5 GBMark-Sweep-Compact100мс+

Моложе покоління маленьке навмисно. Більшість об'єктів живе коротко: тимчасові змінні, request-об'єкти, проміжні значення. Старе покоління тримає тих, хто вижив.

Як працює Scavenge (молоде покоління)

Молоде покоління ділиться на два рівних напівпростори: "From" і "To". Нові об'єкти потрапляють у "From". Коли "From" заповнюється, V8 запускає scavenge:

  1. Знайти всі живі об'єкти у "From" через корені
  2. Скопіювати живі об'єкти в "To"
  3. Повністю очистити "From"
  4. Поміняти "From" і "To" місцями

Об'єкти, що пережили два scavenge, переміщуються до старого покоління. На практиці туди потрапляє лише ~25%. Решта гине раніше. Саме тому scavenge швидкий - він обходить тільки живі об'єкти, а не весь heap.

javascript
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 зазвичай непомітні у добре написаних застосунках. Але щойно старе покоління наповнюється, навіть інкрементне позначення не рятує від паузи.

Моніторинг і налаштування

bash
# Бачити кожну подію 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. До падіння процесу застосунок вже деградує хвилинами.

javascript
// Спостерігати за 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: Глобальні кеші

javascript
// Неправильно - глобальна змінна є коренем, вміст Map ніколи не збирається global.cache = new Map(); // Правильно - обмежений кеш з витісненням const { LRUCache } = require('lru-cache'); const cache = new LRUCache({ max: 1000 });

Глобальні змінні - корені. Все досяжне через глобальну живе до завершення процесу.

Помилка 2: Слухачі подій без очищення

javascript
// Неправильно - слухач тримає замикання нескінченно function setup(heavyObject) { emitter.on('data', () => process(heavyObject)); // замикання захоплює heavyObject } // Правильно emitter.once('data', handler); // або явно видаляй emitter.removeListener('data', handler);

Кожен виклик on() реєструє замикання (closure). Якщо замикання захоплює великий об'єкт, той живе стільки, скільки живе слухач. Це одна з найпоширеніших причин витоків пам'яті в Node.js.

Помилка 3: Ручний gc() у продакшені

javascript
// Неправильно - блокує потік на синхронний повний GC setInterval(() => global.gc(), 1000);

global.gc() працює лише з --expose-gc. Використовуй у тестах для вимірювання стану купи між операціями. У продакшені V8 планує GC краще ніж будь-який фіксований інтервал.

Помилка 4: Ігнорування швидкості просування

Якщо більше ~25% об'єктів молодого покоління переходить до старого, мажорний GC запускається занадто часто і паузи накопичуються. Слідкуй за v8.getHeapStatistics().used_heap_size_after_gc в часі. Зростаючий показник - ознака тиску просування, зазвичай від довгоживучих об'єктів у гарячих шляхах.

Помилка 5: Великі буфери у циклах

javascript
// Неправильно - швидко потрапляє в старе покоління, фрагментує купу 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-об'єкти в модульних змінних, замикання що живуть довше ніж очікувалось.

Приклади

Базовий: спостерігаємо за купою в реальному часі

javascript
// 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-кеш без витіснення

javascript
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

javascript
// Тонко: кожне замикання слухача тримає весь зовнішній 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 означає одне замикання, одне посилання - незалежно від кількості слухачів.

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

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

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

Дочитали статтю?