Управление оперативной памятью в Node.js целиком делегировано виртуальной машине Google V8. Для построения высоконадежных бэкенд-сервисов, способных работать месяцами под непрерывной нагрузкой без деградации и аварийного завершения по Out of Memory (OOM), инженеру необходимо понимать, как именно V8 сегментирует память процесса на уровне операционной системы.
Вся память запущенного процесса Node.js описывается метрикой Resident Set Size (RSS) — объемом физической памяти RAM, выделенной операционной системой процессу:
+-----------------------------------------------------------------------+
| Resident Set Size (RSS) OS Memory |
+-----------------------------------------------------------------------+
| Node.js / C++ Memory | Google V8 Managed Memory |
| - Node.js Core Code | +-----------------------------------------+ |
| - C++ Buffers / I/O | | V8 Stack (Execution frames & primitives)| |
| - Thread Pool stacks | +-----------------------------------------+ |
| - External Memory | | V8 Heap (Object allocations) | |
| (ArrayBuffer, WASM) | | - New Space (Nursery / Intermediate) | |
| | | - Old Pointer Space | |
| | | - Old Data Space | |
| | | - Large Object Space (LOS) | |
| | | - Code Space (JIT compiled) | |
| | | - Map/Cell Space (Hidden classes) | |
| | +-----------------------------------------+ |
+-----------------------------------------------------------------------+Buffer.allocUnsafe() и разделяемые сегменты ArrayBuffer.Организация кучи V8 основана на слабой гипотезе о поколениях (Weak Generational Hypothesis): подавляющее большинство вновь создаваемых объектов в программе являются короткоживущими и становятся недостижимыми практически сразу после своего создания (например, переменные тела обработчика HTTP-запроса).
Куча V8 физически разделена на изолированные сегменты (Spaces):
+--------------------------------------------------------------------+
| V8 Heap Structure |
+--------------------------------------------------------------------+
| New Space (Young Generation: 1-64 MB) |
| +--------------------------------+-------------------------------+ |
| | From Space (Nursery) | To Space (Intermediate) | |
| +--------------------------------+-------------------------------+ |
| | (Выживание после 2 циклов Minor GC) |
| v |
| Old Space (Old Generation: до --max-old-space-size) |
| +----------------------------------------------------------------+ |
| | Old Pointer Space: объекты, содержащие ссылки на другие объекты| |
| | Old Data Space: сырые данные без ссылок (строки, числа) | |
| +----------------------------------------------------------------+ |
| Specialized Spaces: |
| +----------------------------------------------------------------+ |
| | Large Object Space: объекты больше порога, минующие New Space | |
| | Code Space: машинные инструкции, скомпилированные TurboFan | |
| | Map/Cell Space: скрытые классы (Shapes) и метаданные типов | |
| +----------------------------------------------------------------+ |
+--------------------------------------------------------------------+Объект создается в полупространстве From-space. Указатель выделения памяти сдвигается вперед с минимальным оверхедом.
Если объект недостижим из GC Roots, память освобождается. Если достижим, он копируется в To-space и переходит в статус Intermediate.
Если объект переживает повторную сборку Scavenger, он перемещается (промоутится) из New Space в Old Pointer Space или Old Data Space.
Объект живет в старшем поколении. В ходе инкрементальной маркировки Orinoco объект раскрашивается в серый, а затем в черный цвет.
При потере ссылок объект остается белым при маркировке. Фаза Sweep освобождает его слот в списке свободной памяти (Free List), а Compact дефрагментирует страницу.
Почему в виртуальной машине V8 куча разделена на New Space и Old Space согласно гипотезе о поколениях (Weak Generational Hypothesis)?
--max-old-space-size с лимитами resources.limits.memory пода подавляет срабатывание Linux OOM Killer.v8.writeHeapSnapshot() при достижении 85% заполнения кучи и локализация Retainer Tree в Chrome DevTools.Map на двухуровневые LRU-структуры с жестким ограничением объема или на связки WeakRef / FinalizationRegistry.AbortSignal.New Space разделен на два одинаковых полупространства: From-space и To-space.
Old Space очищается по трехфазному алгоритму:
Исторически сборка мусора полностью замораживала выполнение JavaScript (Stop-The-World). Проект 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 Execution непрерывно продолжается ]------>
Фоновые потоки: ---[ Concurrent Marking & Concurrent Sweeping ]--->Фоновый процесс отслеживает heapUsed через v8.getHeapStatistics(). При превышении 80% лимита запускается процедура снятия диагностического слепка.
Через модуль node:v8 вызывается writeHeapSnapshot(). Движок останавливает мутации и сериализует граф кучи напрямую на накопитель без создания двойного расхода памяти.
Инженер загружает файл слепка в DevTools, переключается в представление Comparison между двумя последовательными слепками под нагрузкой.
Поиск узла с максимальным Retained Size. Выявление замыкания, глобального массива или неудалившегося обработчика событий EventEmitter.
В микросервисной архитектуре критично снять диагностический дамп памяти до того, как контейнер будет уничтожен сигналом SIGKILL от Linux OOM Killer.
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;
}
}
}Для кэширования тяжелых вычисленных структур данных без риска утечки памяти используются слабые ссылки, позволяющие сборщику мусора V8 беспрепятственно удалять объекты при дефиците памяти:
/**
* EphemeralCache: кэш с использованием слабых ссылок (WeakRef).
* Не удерживает объекты в памяти, позволяя сборщику мусора V8
* удалять их при очистке кучи.
*/
export class EphemeralCache<K, V extends object> {
private readonly storage = new Map<K, WeakRef<V>>();
private readonly registry: FinalizationRegistry<K>;
constructor() {
// Реестр финализации уведомляет, когда сборщик мусора очистил объект
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 {
// Регистрируем значение в WeakRef и FinalizationRegistry
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;
}
}Как правильно задать флаг --max-old-space-size для Node.js сервиса в контейнере Docker с жестким лимитом памяти 2 ГБ (mem_limit: 2GB)?
Если в пределах одной внешней функции объявлено несколько замыканий, движок 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 является замыканием, создаваемым на каждый входящий HTTP-запрос, то миллионы объектов запросов зависнут в памяти. Предупреждение MaxListenersExceededWarning часто глушат через emitter.setMaxListeners(0), что лишь маскирует критическую утечку памяти.
Решение: всегда используйте AbortSignal для автоматической отписки:
emitter.on('event', handler, { signal: req.abortSignal }).
По умолчанию в Node.js лимит кучи V8 составляет около 1.4 ГБ на 64-битных системах. При развертывании в Docker с лимитом mem_limit: 1GB сборщик мусора V8 не активирует агрессивную сборку Major GC, рассчитывая на доступность 1.4 ГБ. В момент, когда суммарный RSS процесса превышает 1 ГБ, ядро Linux уничтожает процесс сигналом SIGKILL (OOM Killer) без генерации дампов и stack trace.
Решение: всегда явно указывайте --max-old-space-size, выделяя куче V8 не более 70-75% от жесткого лимита контейнера, оставляя 25-30% под стек, C++ буферы и оверхед libuv.
Использование стандартного Map или обычного литерала {} для кэширования ответов по ключу приводит к неограниченному росту кучи: сборщик мусора не имеет права удалять ключи из активного Map.
Решение: используйте проверенные библиотеки LRU-кэширования (например, lru-cache) с обязательным ограничением максимального количества записей (max) и времени жизни (ttl).