Garbage collection в Node.js: V8 heap, generational GC и тюнинг
Garbage collection в V8 — это не просто «автоматическая очистка памяти». Это сложная система с несколькими поколениями объектов, разными алгоритмами сборки и прямым влиянием на latency вашего приложения. Разберём, как устроен GC в Node.js, как его мониторить и как снизить паузы с сотен миллисекунд до десятков.
Архитектура V8 heap: поколения объектов
V8 использует generational garbage collection — память разделена на два поколения:
- Young generation (New Space) — сюда попадают все новые объекты. Размер ~1-8 MB (по умолчанию ~2 MB на полупространство). Делится на два полупространства: from-space и to-space
- Old generation (Old Space) — долгоживущие объекты, пережившие несколько циклов GC в young generation. Размер по умолчанию ~1.4 GB (на 64-битных системах), настраивается флагом
--max-old-space-size
Идея проста: большинство объектов умирают молодыми (temporary variables, промежуточные результаты), поэтому их дёшево собирать в маленьком young generation. Объекты, прожившие долго (глобальные переменные, кеши, замыкания), переезжают в old generation и собираются реже.
Дополнительные пространства
- Large Object Space — объекты больше ~256 KB (большие массивы, буферы). Не перемещаются, собираются отдельно
- Code Space — скомпилированный JIT-код
- Map Space — hidden classes (внутренние структуры V8 для оптимизации доступа к свойствам)
Алгоритмы сборки мусора
Scavenge (для young generation)
Быстрый алгоритм на основе Cheney's copying collector. Работает так:
- Все живые объекты из from-space копируются в to-space
- Мёртвые объекты просто игнорируются — они остаются в from-space, который потом целиком очищается
- from-space и to-space меняются местами
Объекты, пережившие два Scavenge-цикла, продвигаются (promote) в old generation. Scavenge очень быстрый (1-5 ms), но требует в два раза больше памяти (два полупространства).
Mark-Sweep-Compact (для old generation)
Трёхфазный алгоритм для old generation:
- Mark — обход графа объектов от корней (global, stack, handles), пометка всех достижимых объектов
- Sweep — проход по памяти, удаление непомеченных объектов, добавление освободившихся блоков в free list
- Compact (опционально) — дефрагментация памяти, перемещение живых объектов в начало, чтобы избежать фрагментации
Mark-Sweep медленнее Scavenge (10-100+ ms), но работает с большим объёмом памяти. V8 использует incremental marking — разбивает Mark-фазу на маленькие шаги, чередуя их с выполнением JavaScript, чтобы снизить паузы.
Concurrent и parallel GC
Современный V8 (с Node.js 12+) использует:
- Parallel GC — несколько потоков работают одновременно во время GC pause (основной поток JS остановлен)
- Concurrent marking — marking идёт в фоновых потоках параллельно с выполнением JS (основной поток не блокируется)
- Concurrent sweeping — sweep тоже в фоне
Благодаря этому major GC pause (для old generation) снизились с сотен миллисекунд до 10-50 ms в типичных случаях.
Мониторинг GC: флаги и метрики
--trace-gc
Самый простой способ увидеть, что происходит:
node --trace-gc server.js
Вывод:
[12345:0x5a2b3c4d5e6f] 42 ms: Scavenge 2.1 (3.2) -> 1.8 (4.2) MB, 1.2 / 0.0 ms (average mu = 0.995, current mu = 0.995) allocation failure
[12345:0x5a2b3c4d5e6f] 156 ms: Mark-sweep 15.3 (18.5) -> 12.1 (17.5) MB, 23.4 / 0.0 ms (average mu = 0.912, current mu = 0.887) allocation failure scavenge might not succeed
Расшифровка:
- Scavenge — тип GC (young generation)
- 2.1 (3.2) → 1.8 (4.2) MB — heap used до/после (в скобках — heap total)
- 1.2 / 0.0 ms — время pause / время в фоне
- allocation failure — причина запуска GC (не хватило места для аллокации)
--trace-gc-verbose
Ещё больше деталей — показывает время каждой фазы (mark, sweep, compact):
node --trace-gc --trace-gc-verbose server.js
--max-old-space-size
Ограничивает размер old generation (в мегабайтах):
node --max-old-space-size=4096 server.js # 4 GB
По умолчанию ~1.4 GB на 64-битных системах. Увеличение даёт больше памяти, но GC pause становятся длиннее (больше объектов для обхода). Уменьшение заставляет GC запускаться чаще, но паузы короче.
--max-semi-space-size
Размер одного полупространства в young generation (в мегабайтах):
node --max-semi-space-size=8 server.js # 8 MB на полупространство
Больше semi-space — реже Scavenge, но дольше паузы. Меньше — чаще Scavenge, короче паузы, но больше overhead.
Программный мониторинг
Через v8.getHeapStatistics():
const v8 = require('v8');
const stats = v8.getHeapStatistics();
console.log({
total_heap_size: `${(stats.total_heap_size / 1024 / 1024).toFixed(2)} MB`,
used_heap_size: `${(stats.used_heap_size / 1024 / 1024).toFixed(2)} MB`,
heap_size_limit: `${(stats.heap_size_limit / 1024 / 1024).toFixed(2)} MB`,
malloced_memory: `${(stats.malloced_memory / 1024 / 1024).toFixed(2)} MB`
});
Для отслеживания GC pause используйте performance.measure или библиотеку gc-stats:
const gc = require('gc-stats')();
gc.on('stats', (stats) => {
console.log(`GC ${stats.gctype}: pause ${stats.pause / 1e6} ms, heap ${stats.after.usedHeapSize / 1024 / 1024} MB`);
if (stats.pause / 1e6 > 50) {
console.warn('Long GC pause detected!');
}
});
GC pause и влияние на latency
Во время GC pause основной поток JavaScript полностью остановлен — запросы не обрабатываются, таймеры не срабатывают. Для high-load API это критично:
- Scavenge pause — обычно 1-5 ms, почти незаметно
- Major GC pause — 10-100+ ms, может вызвать timeout на клиенте
Если у вас SLA 99th percentile latency < 100 ms, а major GC pause 200 ms — вы нарушаете SLA при каждом major GC.
Пример замера latency с учётом GC:
const http = require('http');
const v8 = require('v8');
let requestCount = 0;
let totalLatency = 0;
http.createServer((req, res) => {
const start = Date.now();
// имитация работы
const data = Array.from({ length: 10000 }, (_, i) => ({ id: i, value: Math.random() }));
const result = data.filter(x => x.value > 0.5);
const latency = Date.now() - start;
totalLatency += latency;
requestCount++;
res.writeHead(200);
res.end(JSON.stringify({ count: result.length, avgLatency: totalLatency / requestCount }));
}).listen(3000);
setInterval(() => {
const stats = v8.getHeapStatistics();
console.log(`Heap: ${(stats.used_heap_size / 1024 / 1024).toFixed(2)} MB, Avg latency: ${(totalLatency / requestCount).toFixed(2)} ms`);
}, 5000);
Запускаем с --trace-gc и нагружаем (например, wrk -t4 -c100 -d30s http://localhost:3000). Видим корреляцию: когда происходит major GC, latency скачет.
Оптимизация: снижаем GC pause
1. Object pooling
Вместо создания новых объектов каждый раз — переиспользуем старые:
class ObjectPool {
constructor(factory, reset, size = 100) {
this.factory = factory;
this.reset = reset;
this.pool = Array.from({ length: size }, factory);
this.index = 0;
}
acquire() {
if (this.index < this.pool.length) {
return this.pool[this.index++];
}
return this.factory(); // fallback: создаём новый
}
release(obj) {
if (this.index > 0) {
this.reset(obj);
this.pool[--this.index] = obj;
}
}
}
// использование
const bufferPool = new ObjectPool(
() => Buffer.allocUnsafe(1024),
(buf) => buf.fill(0),
50
);
function handleRequest(data) {
const buf = bufferPool.acquire();
// работа с buf
bufferPool.release(buf);
}
Меньше аллокаций — реже GC, короче паузы.
2. Избегаем больших объектов в young generation
Большие объекты (>256 KB) попадают в Large Object Space и не участвуют в Scavenge, но их копирование при promote дорого. Решение:
- Используйте
Buffer.allocUnsafe()вместо массивов для больших данных - Разбивайте большие структуры на маленькие чанки
- Переиспользуйте буферы через pooling
3. Уменьшаем heap size
Парадоксально, но меньший --max-old-space-size может улучшить latency:
node --max-old-space-size=512 server.js
GC запускается чаще, но паузы короче (меньше объектов для обхода). Подходит для stateless-сервисов с малым объёмом данных в памяти.
4. Избегаем retention
Объекты, на которые есть ссылки, не собираются. Типичные ошибки:
- Глобальные массивы/Map без ограничения размера
- Замыкания, захватывающие большие объекты
- Event listeners без отписки
Используйте WeakMap, WeakSet для кешей, где ключи — объекты. GC сам почистит, когда ключ станет недостижим.
Практический кейс: снижаем GC pause с 200ms до 20ms
Реальный проект: Express API для обработки JSON. Нагрузка 1000 RPS, 99th percentile latency ~150 ms, но периодически скачет до 300+ ms. Запускаем с --trace-gc:
node --trace-gc --trace-gc-verbose server.js
Видим major GC каждые 10-15 секунд с pause 180-220 ms. Heap used растёт до 800 MB перед GC.
Шаг 1: Профилируем аллокации
Используем clinic heapprofiler:
clinic heapprofiler -- node server.js
# нагружаем, останавливаем
Flamegraph показывает: 60% аллокаций — парсинг JSON и создание response-объектов.
Шаг 2: Object pooling для response
Вместо создания нового объекта на каждый запрос — пул из 200 объектов:
const responsePool = new ObjectPool(
() => ({ status: 'ok', data: null, timestamp: 0 }),
(obj) => { obj.data = null; obj.timestamp = 0; },
200
);
app.post('/process', (req, res) => {
const response = responsePool.acquire();
response.data = processData(req.body);
response.timestamp = Date.now();
res.json(response);
responsePool.release(response);
});
Шаг 3: Уменьшаем heap size
Приложение не держит большой state, поэтому снижаем лимит:
node --max-old-space-size=512 --trace-gc server.js
Результат:
- Major GC pause: 180-220 ms → 15-25 ms (в 9 раз быстрее)
- GC частота: каждые 10-15 сек → каждые 5-7 сек (чаще, но незаметно)
- 99th percentile latency: 150 ms → 80 ms
- Heap used: 800 MB → 350 MB
Ключевой фактор — меньше аллокаций (pooling) + меньший heap (короче обход графа объектов).
Подводные камни
- Incremental marking не панацея — если аллокации идут быстрее, чем marking успевает пометить, V8 переключается на full stop-the-world GC
- Concurrent GC использует CPU — фоновые потоки GC конкурируют с JS-потоком за ядра. На машинах с малым количеством ядер может быть хуже
- Большие объекты в Large Object Space — не перемещаются, могут вызвать фрагментацию. Используйте streaming для больших данных
- Native addons — память, выделенная в C++, не управляется V8 GC. Следите за утечками через Valgrind
Вывод
Garbage collection в V8 — это generational система с быстрым Scavenge для молодых объектов и медленным Mark-Sweep-Compact для старых. Современный V8 использует concurrent и parallel GC, снижая паузы до 10-50 ms в типичных случаях. Для мониторинга используйте --trace-gc и v8.getHeapStatistics(). Оптимизация — это object pooling, уменьшение heap size, избегание больших объектов и retention. В реальных проектах можно снизить GC pause в 5-10 раз, улучшив latency и стабильность под нагрузкой.