Керування оперативною пам'яттю в Node.js повністю делеговано віртуальній машині Google V8. Для побудови високонадійних бекенд-сервісів, здатних безперервно працювати під високим навантаженням без аварійних падінь через вичерпання пам'яті (Out-Of-Memory, OOM), інженеру необхідно розуміти структуру сегментації пам'яті процесу на рівні операційної системи.
Уся оперативна пам'ять процесу Node.js контролюється показником Resident Set Size (RSS) — обсягом фізичної пам'яті RAM, виділеної ядром ОС поточному процесу:
+-----------------------------------------------------------------------+
| Resident Set Size (RSS) Пам'ять ОС |
+-----------------------------------------------------------------------+
| Node.js / C++ Пам'ять | Керована пам'ять Google V8 |
| - Системний код ядра | +-----------------------------------------+ |
| - C++ Буфери та I/O | | Стек V8 (Стекові фрейми та примітиви) | |
| - Стеки пулу потоків | +-----------------------------------------+ |
| - External Memory | | Купа V8 (Heap: аллокація об'єктів) | |
| (ArrayBuffer, WASM) | | - New Space (Nursery / Intermediate) | |
| | | - Old Pointer Space | |
| | | - Old Data Space | |
| | | - Large Object Space (LOS) | |
| | | - Code Space (Машинний код JIT) | |
| | | - Map/Cell Space (Приховані класи/типи) | |
| | +-----------------------------------------+ |
+-----------------------------------------------------------------------+Buffer.allocUnsafe() та спільні сегменти ArrayBuffer.Організація купи V8 базується на слабкій гіпотезі про покоління (Weak Generational Hypothesis): переважна більшість створених у програмі об'єктів є короткоживучими і стають недосяжними майже відразу після аллокації (наприклад, змінні всередині одного циклу обробки HTTP-запиту).
Купа V8 фізично сегментована на ізольовані простори (Spaces):
+--------------------------------------------------------------------+
| Структура купи V8 |
+--------------------------------------------------------------------+
| New Space (Молоде покоління: 1-64 MB) |
| +--------------------------------+-------------------------------+ |
| | From Space (Nursery) | To Space (Intermediate) | |
| +--------------------------------+-------------------------------+ |
| | (Виживання після 2 циклів Minor GC) |
| v |
| Old Space (Старше покоління: до --max-old-space-size) |
| +----------------------------------------------------------------+ |
| | Old Pointer Space: об'єкти з посиланнями на інші об'єкти | |
| | Old Data Space: сирі дані без посилань (рядки, числа) | |
| +----------------------------------------------------------------+ |
| Спеціалізовані простори: |
| +----------------------------------------------------------------+ |
| | Large Object Space: великі об'єкти, що оминають New Space | |
| | Code Space: машинний код, скомпільований TurboFan JIT | |
| | Map/Cell Space: приховані класи (Shapes) та типи даних | |
| +----------------------------------------------------------------+ |
+--------------------------------------------------------------------+--max-old-space-size із лімітами resources.limits.memory пода захищає від примусового завершення процесу через Linux OOM Killer.v8.writeHeapSnapshot() при досягненні 85% заповнення з подальшим аналізом дерева Retainers у Chrome DevTools.Map на LRU-кеші з фіксованими лімітами або слабкі посилання WeakRef та FinalizationRegistry.AbortSignal.Простір New Space поділено на два рівних напівпростори: From-space та To-space.
Old Space очищується за трифазним алгоритмом:
Проєкт Orinoco замінив монолітні блокуючі паузи на три взаємодоповнюючі механізми:
1. Паралельне збирання (Parallel GC):
Головний потік: ---[ STOP ]========================[ RESUME ]--->
Потоки-воркери: ---[ Worker 1: Mark/Compact ]--->
---[ Worker 2: Mark/Compact ]--->
2. Інкрементальне збирання (Incremental GC):
Головний потік: ---[JS]---[Mark 1]---[JS]---[Mark 2]---[JS]---[Sweep]--->
3. Конкурентне фонове збирання (Concurrent GC):
Головний потік: ---[ Виконання коду JS продовжується без зупинки ]--->
Фонові потоки: ---[ Конкурентне маркування та очищення в фоні ]--->Фоновий процес перевіряє used_heap_size через v8.getHeapStatistics(). При досягненні 80% порогу спрацьовує діагностичний тригер.
Функція v8.writeHeapSnapshot() стрімить граф пам'яті напряму на накопичувач без подвоєння споживання пам'яті в процесі.
Інженер відкриває вкладку Memory у DevTools, порівнює два послідовні знімки та сортує за Retained Size.
Локалізація глобальних замикань або незакритих підписок EventEmitter, що утримують важкі об'єкти.
Збереження дампа пам'яті до того, як ядро Linux знищить контейнер сигналом SIGKILL (OOM), має вирішальне значення для діагностики:
import v8 from 'node:v8';
import path from 'node:path';
import fs from 'node:fs';
import { EventEmitter } from 'node:events';
export interface MemoryWatchdogConfig {
checkIntervalMs: number;
heapUsageThresholdPercent: number;
snapshotsDirectory: string;
cooldownMs: number;
}
export class MemoryWatchdog extends EventEmitter {
private timer: NodeJS.Timeout | null = null;
private lastSnapshotTime = 0;
private isGenerating = false;
constructor(private readonly config: MemoryWatchdogConfig) {
super();
if (!fs.existsSync(config.snapshotsDirectory)) {
fs.mkdirSync(config.snapshotsDirectory, { recursive: true });
}
}
public start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
this.evaluateHeapUsage();
}, this.config.checkIntervalMs);
this.timer.unref();
}
public stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private evaluateHeapUsage(): void {
const stats = v8.getHeapStatistics();
const currentHeapUsed = stats.used_heap_size;
const maxHeapAvailable = stats.heap_size_limit;
const usageRatio = currentHeapUsed / maxHeapAvailable;
const usagePercent = Math.round(usageRatio * 100);
this.emit('heartbeat', {
usedMb: Math.round(currentHeapUsed / 1024 / 1024),
totalMb: Math.round(stats.total_heap_size / 1024 / 1024),
limitMb: Math.round(maxHeapAvailable / 1024 / 1024),
usagePercent,
});
if (usagePercent >= this.config.heapUsageThresholdPercent) {
this.triggerSnapshot(usagePercent, stats);
}
}
private triggerSnapshot(usagePercent: number, stats: v8.HeapInfo): void {
const now = Date.now();
// Захист від надмірної генерації знімків через ліміт cooldown
if (this.isGenerating || (now - this.lastSnapshotTime < this.config.cooldownMs)) {
return;
}
this.isGenerating = true;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `heap-oom-warning-${process.pid}-${timestamp}.heapsnapshot`;
const fullPath = path.join(this.config.snapshotsDirectory, filename);
this.emit('snapshot-start', {
usagePercent,
targetPath: fullPath,
heapStats: stats,
});
try {
// v8.writeHeapSnapshot здійснює запис без додаткового виділення пам'яті
const actualPath = v8.writeHeapSnapshot(fullPath);
this.lastSnapshotTime = Date.now();
this.emit('snapshot-complete', {
path: actualPath,
durationMs: this.lastSnapshotTime - now,
});
} catch (err) {
this.emit('snapshot-error', err);
} finally {
this.isGenerating = false;
}
}
}Для кешування великих об'єктів без ризику витоку пам'яті використовуються слабкі посилання стандарту ECMAScript:
/**
* EphemeralCache: Кеш на базі слабких посилань WeakRef.
* Не блокує видалення об'єктів збирачем сміття V8
* при виникненні дефіциту оперативної пам'яті.
*/
export class EphemeralCache<K, V extends object> {
private readonly storage = new Map<K, WeakRef<V>>();
private readonly registry: FinalizationRegistry<K>;
constructor() {
// FinalizationRegistry викликається, коли GC очищує об'єкт
this.registry = new FinalizationRegistry<K>((key) => {
const ref = this.storage.get(key);
if (ref && !ref.deref()) {
this.storage.delete(key);
}
});
}
public set(key: K, value: V): void {
this.storage.set(key, new WeakRef(value));
this.registry.register(value, key, value);
}
public get(key: K): V | undefined {
const ref = this.storage.get(key);
if (!ref) {
return undefined;
}
const value = ref.deref();
if (!value) {
// Об'єкт очищено збирачем GC, видаляємо застарілий ключ
this.storage.delete(key);
return undefined;
}
return value;
}
public has(key: K): boolean {
return this.get(key) !== undefined;
}
}Коли всередині однієї батьківської функції створюються кілька замикань, V8 створює для них єдиний спільний контекст Lexical Environment. Якщо хоча б одне довгоживуче замикання (наприклад, глобальний обробник чи таймер) залишається активним, воно утримує в купі весь контекст цілком — включно з великими змінними, які використовуються іншими замиканнями:
function leakContext() {
const hugeBuffer = Buffer.alloc(100 * 1024 * 1024); // 100 MB
const smallData = 'status_ok';
setInterval(() => {
// Це замикання використовує лише smallData,
// проте V8 утримуватиме hugeBuffer у пам'яті вічно!
console.log(smallData);
}, 1000);
return function unused() {
return hugeBuffer;
};
}Рішення: явно обнуляйте посилання (hugeBuffer = null) або виносьте важкі змінні в ізольовані функції.
Кожен виклик emitter.on('data', handler) додає посилання на функцію до внутрішнього масиву підписників. Якщо emitter є довгоживучим (пул БД або глобальний сервіс), а handler замикає змінні запиту, мільйони об'єктів залишаться в пам'яті. Приглушення MaxListenersExceededWarning через emitter.setMaxListeners(0) лише маскує катастрофічний витік. Завжди передавайте AbortSignal для автоматичного очищення:
emitter.on('event', handler, { signal: req.abortSignal }).
За замовчуванням ліміт купи V8 становить приблизно 1.4 ГБ на 64-бітних системах. У контейнері з лімітом 1 ГБ ядро Linux знищить процес сигналом SIGKILL (OOM Killer), коли загальний RSS перевищить 1 ГБ, бо V8 вважатиме, що має 1.4 ГБ і відкладатиме Major GC. Завжди встановлюйте --max-old-space-size на рівні 70–75% від жорсткого ліміту контейнера, залишаючи 25–30% під стек, C++ буфери та пам'ять libuv.
Зберігання об'єктів у стандартному Map чи літералі {} призводить до нескінченного росту купи: збирач сміття не має права видаляти ключі з активного Map. Застосовуйте перевірені бібліотеки LRU-кешування (наприклад, lru-cache) із суворим обмеженням кількості елементів (max) та часу життя (ttl).
Об'єкт створюється в напівпросторі Nursery. Вказівник пам'яті зміщується вперед за мінімальну кількість операцій.
Якщо об'єкт недосяжний, пам'ять звільняється. Якщо досяжний, він копіюється в To-space у статусі Intermediate.
Об'єкти, що вижили вдруге, переміщуються (промоутяться) з New Space до Old Pointer Space або Old Data Space.
Об'єкт живе в старшому поколінні. Під час інкрементального маркування Orinoco він перефарбовується в сірий, а потім у чорний колір.
При втраті досяжності об'єкт залишається білим. Етап Sweep звільняє слот, а Compact дефрагментує пам'ять.